diff --git a/backend/src/alembic/versions/a7d4e8f2c6b1_add_log_retention_days_setting.py b/backend/src/alembic/versions/a7d4e8f2c6b1_add_log_retention_days_setting.py new file mode 100644 index 0000000..d6f609a --- /dev/null +++ b/backend/src/alembic/versions/a7d4e8f2c6b1_add_log_retention_days_setting.py @@ -0,0 +1,40 @@ +"""Add settings.log_retention_days for activity-log pruning + +Activity logs previously grew forever (only manual Clear All). The +automation cycle now prunes rows older than this many days; 0 disables +pruning. + +Revision ID: a7d4e8f2c6b1 +Revises: f1b8c5d3a9e2 +Create Date: 2026-07-21 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a7d4e8f2c6b1" +down_revision: str | Sequence[str] | None = "f1b8c5d3a9e2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("settings", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "log_retention_days", + sa.Integer(), + nullable=False, + server_default="30", + comment="Activity logs older than this many days are pruned (0 = keep forever)", + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("settings", schema=None) as batch_op: + batch_op.drop_column("log_retention_days") diff --git a/backend/src/api/routers/entries.py b/backend/src/api/routers/entries.py index 3c4c2d2..a0fb0dd 100644 --- a/backend/src/api/routers/entries.py +++ b/backend/src/api/routers/entries.py @@ -4,6 +4,7 @@ filtering entries, and getting entry statistics. """ +from datetime import date, datetime, time, timedelta from typing import Any from fastapi import APIRouter, HTTPException, Query, status @@ -23,53 +24,58 @@ "/", response_model=dict[str, Any], summary="List entries", - description="Get entry history with optional filtering.", + description="Get entry history with combinable filters and pagination.", ) async def list_entries( giveaway_service: GiveawayServiceDep, status_filter: str | None = Query( default=None, alias="status", - description="Filter by status (success, failed)", - pattern="^(success|failed)$" + description="Filter by status (success, failed, pending)", + pattern="^(success|failed|pending)$" ), entry_type: str | None = Query( default=None, - description="Filter by entry type (manual, auto, wishlist)", - pattern="^(manual|auto|wishlist)$" + description="Filter by entry type (manual, auto, wishlist, dlc)", + pattern="^(manual|auto|wishlist|dlc)$" ), + giveaway_id: int | None = Query(default=None, description="Filter by giveaway ID"), + from_date: date | None = Query(default=None, description="Only entries on/after this date"), + to_date: date | None = Query(default=None, description="Only entries on/before this date"), limit: int = Query(default=50, ge=1, le=200, description="Maximum results"), offset: int = Query(default=0, ge=0, description="Offset for pagination"), ) -> dict[str, Any]: """ - List entries with filtering options. + List entries with combinable filters. - Returns: - Success response with list of entries including giveaway data + All filters compose (AND). ``count`` in the response is the total number + of matching rows (not the page size), so clients can paginate. Example response: { "success": true, "data": { "entries": [...], - "count": 50 + "count": 137, + "limit": 50, + "offset": 0 } } """ - # Use giveaway_service.get_entry_history which delegates to entry_repo - if status_filter: - entries = await giveaway_service.get_entry_history(limit=limit, status=status_filter) - elif entry_type: - entries = await giveaway_service.entry_repo.get_by_entry_type(entry_type, limit=limit) - else: - entries = await giveaway_service.get_entry_history(limit=limit) + rows, total = await giveaway_service.entry_repo.search( + status=status_filter, + entry_type=entry_type, + giveaway_id=giveaway_id, + from_date=datetime.combine(from_date, time.min) if from_date else None, + # to_date is inclusive: match everything before the next midnight + to_date=datetime.combine(to_date + timedelta(days=1), time.min) if to_date else None, + limit=limit, + offset=offset, + ) - # Convert to response format with giveaway data entry_list = [] - for entry in entries: + for entry, giveaway in rows: entry_data = EntryResponse.model_validate(entry).model_dump() - # Fetch associated giveaway - giveaway = await giveaway_service.giveaway_repo.get_by_id(entry.giveaway_id) if giveaway: entry_data["giveaway"] = { "id": giveaway.id, @@ -86,7 +92,9 @@ async def list_entries( return create_success_response( data={ "entries": entry_list, - "count": len(entry_list), + "count": total, + "limit": limit, + "offset": offset, } ) diff --git a/backend/src/api/routers/system.py b/backend/src/api/routers/system.py index 7b94fa1..76b8915 100644 --- a/backend/src/api/routers/system.py +++ b/backend/src/api/routers/system.py @@ -6,6 +6,7 @@ import csv import json +from datetime import date, datetime, time, timedelta from io import StringIO from typing import Any @@ -84,24 +85,20 @@ async def system_info() -> dict[str, Any]: async def get_logs( notification_service: NotificationServiceDep, limit: int = Query(default=50, ge=1, le=500, description="Number of logs to retrieve"), + offset: int = Query(default=0, ge=0, description="Offset for pagination"), level: str | None = Query(default=None, description="Filter by log level (info, warning, error)"), event_type: str | None = Query( - default=None, description="Filter by event type (scan, entry, error, config, scheduler)" + default=None, description="Filter by event type (scan, entry, error, config, scheduler, win)" ), + search: str | None = Query(default=None, description="Case-insensitive message search"), + from_date: date | None = Query(default=None, description="Only logs on/after this date"), + to_date: date | None = Query(default=None, description="Only logs on/before this date"), ) -> dict[str, Any]: """ - Get recent activity logs. + Get activity logs with combinable filters and pagination. - Retrieves recent activity logs from the system. - - Args: - notification_service: Notification service dependency - limit: Maximum number of logs to retrieve (1-500, default 50) - level: Optional filter by log level - event_type: Optional filter by event type - - Returns: - dict: List of recent logs + All filters compose (AND); ``count`` is the total number of matching + rows, not the page size. Example Response: { @@ -111,31 +108,27 @@ async def get_logs( { "id": 123, "level": "info", - "message": "Entered giveaway for Portal 2", - "event_type": "entry", + "message": "Scan completed", + "event_type": "scan", "created_at": "2024-01-15T10:30:00" } ], - "count": 1, - "limit": 50 + "count": 412, + "limit": 50, + "offset": 0 } } """ - # Get activity logs based on filter - if level: - activity_logs = await notification_service.get_logs_by_level( - level=level, - limit=limit, - ) - elif event_type: - activity_logs = await notification_service.get_logs_by_event_type( - event_type=event_type, - limit=limit, - ) - else: - activity_logs = await notification_service.get_recent_logs(limit=limit) + activity_logs, total = await notification_service.search_logs( + level=level, + event_type=event_type, + search=search, + from_date=datetime.combine(from_date, time.min) if from_date else None, + to_date=datetime.combine(to_date + timedelta(days=1), time.min) if to_date else None, + limit=limit, + offset=offset, + ) - # Convert to log format logs = [ { "id": log.id, @@ -150,8 +143,9 @@ async def get_logs( return create_success_response( data={ "logs": logs, - "count": len(logs), + "count": total, "limit": limit, + "offset": offset, } ) diff --git a/backend/src/api/schemas/settings.py b/backend/src/api/schemas/settings.py index b66ecaf..154e0b3 100644 --- a/backend/src/api/schemas/settings.py +++ b/backend/src/api/schemas/settings.py @@ -139,6 +139,12 @@ class SettingsBase(BaseModel): ge=0, examples=[12], ) + log_retention_days: int = Field( + default=30, + description="Activity logs older than this many days are pruned (0 = keep forever)", + ge=0, + examples=[30], + ) @field_validator("entry_delay_max") @classmethod @@ -341,6 +347,11 @@ class SettingsUpdate(BaseModel): description="Maximum delay between entries (seconds)", ge=0, ) + log_retention_days: int | None = Field( + default=None, + description="Activity logs older than this many days are pruned (0 = keep forever)", + ge=0, + ) model_config = { "json_schema_extra": { diff --git a/backend/src/models/settings.py b/backend/src/models/settings.py index 6b9bbb5..23dd1fa 100644 --- a/backend/src/models/settings.py +++ b/backend/src/models/settings.py @@ -184,6 +184,11 @@ class Settings(Base, TimestampMixin): default=12, comment="Maximum delay between entries (seconds)", ) + log_retention_days: Mapped[int] = mapped_column( + Integer, + default=30, + comment="Activity logs older than this many days are pruned (0 = keep forever)", + ) # ==================== Metadata ==================== last_synced_at: Mapped[datetime | None] = mapped_column( diff --git a/backend/src/repositories/activity_log.py b/backend/src/repositories/activity_log.py index f94f68d..ec4d4b4 100644 --- a/backend/src/repositories/activity_log.py +++ b/backend/src/repositories/activity_log.py @@ -1,7 +1,8 @@ """Repository for ActivityLog model.""" +from datetime import datetime -from sqlalchemy import desc, select +from sqlalchemy import delete, desc, func, select from sqlalchemy.ext.asyncio import AsyncSession from models.activity_log import ActivityLog @@ -157,6 +158,49 @@ async def get_by_event_type( ) return list(result.scalars().all()) + async def search( + self, + *, + level: str | None = None, + event_type: str | None = None, + search: str | None = None, + from_date: datetime | None = None, + to_date: datetime | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[ActivityLog], int]: + """ + Combinable filtered log listing with true total count. + + All filters compose (AND); ``search`` matches the message + case-insensitively. Returns one page (newest first) plus the total + number of matching rows for pagination. + """ + conditions = [] + if level: + conditions.append(ActivityLog.level == level) + if event_type: + conditions.append(ActivityLog.event_type == event_type) + if search: + conditions.append(ActivityLog.message.ilike(f"%{search}%")) + if from_date: + conditions.append(ActivityLog.created_at >= from_date) + if to_date: + conditions.append(ActivityLog.created_at < to_date) + + count_query = select(func.count()).select_from(ActivityLog).where(*conditions) + total = (await self.session.execute(count_query)).scalar_one() + + page_query = ( + select(ActivityLog) + .where(*conditions) + .order_by(desc(ActivityLog.created_at)) + .limit(limit) + .offset(offset) + ) + result = await self.session.execute(page_query) + return list(result.scalars().all()), total + async def count_by_level(self, level: str) -> int: """ Count activity logs by severity level. @@ -171,9 +215,24 @@ async def count_by_level(self, level: str) -> int: >>> error_count = await repo.count_by_level("error") """ result = await self.session.execute( - select(ActivityLog).where(ActivityLog.level == level) + select(func.count()).select_from(ActivityLog).where(ActivityLog.level == level) + ) + return result.scalar_one() + + async def delete_older_than(self, cutoff: datetime) -> int: + """ + Delete logs created before ``cutoff`` (retention pruning). + + Returns: + Number of logs deleted + + Note: + Does NOT commit; the caller must call session.commit(). + """ + result = await self.session.execute( + delete(ActivityLog).where(ActivityLog.created_at < cutoff) ) - return len(list(result.scalars().all())) + return int(result.rowcount or 0) # type: ignore[attr-defined] async def get_all(self) -> list[ActivityLog]: """ @@ -205,7 +264,6 @@ async def delete_all(self) -> int: >>> deleted_count = await repo.delete_all() >>> await session.commit() """ - from sqlalchemy import delete result = await self.session.execute(delete(ActivityLog)) # execute() is typed as Result, but DELETE always yields a CursorResult. return int(result.rowcount or 0) # type: ignore[attr-defined] @@ -220,7 +278,6 @@ async def count(self) -> int: Example: >>> total = await repo.count() """ - from sqlalchemy import func result = await self.session.execute( select(func.count()).select_from(ActivityLog) ) diff --git a/backend/src/repositories/entry.py b/backend/src/repositories/entry.py index a86b356..a47a7be 100644 --- a/backend/src/repositories/entry.py +++ b/backend/src/repositories/entry.py @@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from models.entry import Entry +from models.giveaway import Giveaway from repositories.base import BaseRepository @@ -94,6 +95,63 @@ async def get_recent( result = await self.session.execute(query) return list(result.scalars().all()) + async def search( + self, + *, + status: str | None = None, + entry_type: str | None = None, + giveaway_id: int | None = None, + from_date: datetime | None = None, + to_date: datetime | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[tuple[Entry, Giveaway | None]], int]: + """ + Combinable filtered entry listing with true total count. + + All filters compose (AND). Returns one page of (entry, giveaway) + pairs — the giveaway joined in a single query, no per-row lookups — + plus the total number of matching rows for pagination. + + Args: + status: Filter by status (success/failed/pending) + entry_type: Filter by type (manual/auto/wishlist/dlc) + giveaway_id: Filter by giveaway + from_date: Only entries created at/after this time + to_date: Only entries created before this time + limit: Page size + offset: Rows to skip + + Returns: + Tuple of (page rows, total matching count) + """ + conditions = [] + if status: + conditions.append(self.model.status == status) + if entry_type: + conditions.append(self.model.entry_type == entry_type) + if giveaway_id: + conditions.append(self.model.giveaway_id == giveaway_id) + if from_date: + conditions.append(self.model.created_at >= from_date) + if to_date: + conditions.append(self.model.created_at < to_date) + + count_query = select(func.count()).select_from(self.model).where(*conditions) + total = (await self.session.execute(count_query)).scalar_one() + + page_query = ( + select(self.model, Giveaway) + .outerjoin(Giveaway, self.model.giveaway_id == Giveaway.id) + .where(*conditions) + .order_by(self.model.created_at.desc()) + .limit(limit) + .offset(offset) + ) + result = await self.session.execute(page_query) + rows = [(entry, giveaway) for entry, giveaway in result.all()] + return rows, total + async def get_by_status( self, status: str, limit: int | None = None ) -> list[Entry]: diff --git a/backend/src/services/notification_service.py b/backend/src/services/notification_service.py index cc0117c..69af9ca 100644 --- a/backend/src/services/notification_service.py +++ b/backend/src/services/notification_service.py @@ -6,6 +6,7 @@ """ import json +from datetime import datetime, timedelta from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -224,6 +225,54 @@ async def get_logs_by_event_type( """ return await self.repo.get_by_event_type(event_type=event_type, limit=limit) + async def search_logs( + self, + *, + level: str | None = None, + event_type: str | None = None, + search: str | None = None, + from_date: datetime | None = None, + to_date: datetime | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[ActivityLog], int]: + """ + Combinable filtered log listing with total count (see repo.search). + + Example: + >>> logs, total = await service.search_logs(level="error", search="scan") + """ + return await self.repo.search( + level=level, + event_type=event_type, + search=search, + from_date=from_date, + to_date=to_date, + limit=limit, + offset=offset, + ) + + async def prune_old_logs(self, retention_days: int) -> int: + """ + Delete logs older than ``retention_days`` days. + + Args: + retention_days: Age cutoff in days; values <= 0 disable pruning + + Returns: + Number of logs deleted + + Example: + >>> deleted = await service.prune_old_logs(30) + """ + if retention_days <= 0: + return 0 + cutoff = utcnow() - timedelta(days=retention_days) + deleted = await self.repo.delete_older_than(cutoff) + if deleted: + await self.session.commit() + return deleted + async def get_error_count(self) -> int: """ Get count of error-level logs. @@ -293,53 +342,10 @@ async def log_scan_complete( details={"new": new_count, "updated": updated_count}, ) - async def log_entry_success( - self, giveaway_code: str, game_name: str, points: int - ) -> ActivityLog: - """ - Convenience method to log successful giveaway entry. - - Args: - giveaway_code: Giveaway code - game_name: Name of the game - points: Points spent - - Returns: - Created ActivityLog object - - Example: - >>> await service.log_entry_success("AbCd1", "Portal 2", 50) - """ - return await self.log_activity( - level="info", - event_type="entry", - message=f"Entered giveaway: {game_name} ({points}P)", - details={"code": giveaway_code, "game": game_name, "points": points}, - ) - - async def log_entry_failure( - self, giveaway_code: str, game_name: str, reason: str - ) -> ActivityLog: - """ - Convenience method to log failed giveaway entry. - - Args: - giveaway_code: Giveaway code - game_name: Name of the game - reason: Failure reason - - Returns: - Created ActivityLog object - - Example: - >>> await service.log_entry_failure("AbCd1", "Portal 2", "Insufficient points") - """ - return await self.log_activity( - level="warning", - event_type="entry", - message=f"Failed to enter {game_name}: {reason}", - details={"code": giveaway_code, "game": game_name, "reason": reason}, - ) + # Entry attempts are NOT logged here: they are first-class Entry rows + # (the History page), so mirroring them into the activity log would + # duplicate every entry event. The activity log carries system events + # only (scan, scheduler, config, win, error). async def log_error(self, error_type: str, message: str, details: dict[str, Any] | None = None) -> ActivityLog: """ diff --git a/backend/src/workers/automation.py b/backend/src/workers/automation.py index 21fd192..4619de3 100644 --- a/backend/src/workers/automation.py +++ b/backend/src/workers/automation.py @@ -8,6 +8,7 @@ 5. Sync entered giveaways 6. Safety-sweep unchecked giveaways 7. Process eligible giveaways (enter them) +8. Prune activity logs past retention This is the engine driven both by the scheduler (interval job) and by the manual ``/run`` trigger. It shares its bootstrap with the other workers via @@ -208,6 +209,17 @@ async def automation_cycle() -> dict[str, Any]: logger.error("process_entries_failed", error=str(e)) results["entries"]["error"] = str(e) + # === STEP 5: Prune old activity logs === + try: + retention_days = settings.log_retention_days or 0 + pruned = await notification_service.prune_old_logs(retention_days) + results["log_prune"] = {"deleted": pruned, "retention_days": retention_days} + if pruned: + logger.info("activity_logs_pruned", deleted=pruned, days=retention_days) + except Exception as e: + logger.error("log_prune_failed", error=str(e)) + results["log_prune"] = {"error": str(e)} + # Calculate total cycle time end_time = datetime.now(UTC) results["cycle_time"] = round((end_time - start_time).total_seconds(), 2) diff --git a/backend/src/workers/processor.py b/backend/src/workers/processor.py index 0554737..670c3c4 100644 --- a/backend/src/workers/processor.py +++ b/backend/src/workers/processor.py @@ -207,12 +207,6 @@ async def _process_entries( stats["points_spent"] += entry.points_spent points -= entry.points_spent - await notification_service.log_entry_success( - giveaway_code=giveaway.code, - game_name=giveaway.game_name, - points=entry.points_spent - ) - await event_manager.broadcast_event( "entry_success", { @@ -228,15 +222,10 @@ async def _process_entries( points_spent=entry.points_spent, ) else: - # None means the entry was skipped (e.g. unsafe) or failed. + # None means the entry was skipped (e.g. unsafe) or failed; + # the Entry row records the reason — no activity log needed. stats["failed"] += 1 - await notification_service.log_entry_failure( - giveaway_code=giveaway.code, - game_name=giveaway.game_name, - reason="Entry returned none" - ) - logger.warning( "giveaway_entry_failed", code=giveaway.code, @@ -246,10 +235,11 @@ async def _process_entries( except Exception as e: stats["failed"] += 1 - await notification_service.log_entry_failure( - giveaway_code=giveaway.code, - game_name=giveaway.game_name, - reason=str(e) + # Unexpected exceptions are real system events (unlike ordinary + # entry failures, which live as Entry rows in History). + await notification_service.log_error( + error_type="entry", + message=f"Entering {giveaway.game_name} ({giveaway.code}) crashed: {e}", ) logger.error( diff --git a/backend/tests/unit/test_api_routers_entries.py b/backend/tests/unit/test_api_routers_entries.py index b24a31c..ec59cc6 100644 --- a/backend/tests/unit/test_api_routers_entries.py +++ b/backend/tests/unit/test_api_routers_entries.py @@ -63,68 +63,97 @@ def create_mock_giveaway( return mock +def _call_list_entries(mock_service, **overrides): + kwargs = dict( + giveaway_service=mock_service, + status_filter=None, + entry_type=None, + giveaway_id=None, + from_date=None, + to_date=None, + limit=50, + offset=0, + ) + kwargs.update(overrides) + return list_entries(**kwargs) + + @pytest.mark.asyncio async def test_list_entries_all(): """Test listing all entries.""" mock_service = AsyncMock() mock_entry = create_mock_entry() mock_giveaway = create_mock_giveaway() - mock_service.get_entry_history.return_value = [mock_entry] - mock_service.giveaway_repo.get_by_id.return_value = mock_giveaway + mock_service.entry_repo.search.return_value = ([(mock_entry, mock_giveaway)], 1) - result = await list_entries( - giveaway_service=mock_service, - status_filter=None, + result = await _call_list_entries(mock_service) + + assert result["success"] is True + assert result["data"]["count"] == 1 + assert result["data"]["entries"][0]["giveaway"]["game_name"] == mock_giveaway.game_name + mock_service.entry_repo.search.assert_called_once_with( + status=None, entry_type=None, + giveaway_id=None, + from_date=None, + to_date=None, limit=50, offset=0, ) - assert result["success"] is True - assert result["data"]["count"] == 1 - mock_service.get_entry_history.assert_called_once_with(limit=50) - @pytest.mark.asyncio -async def test_list_entries_by_status(): - """Test filtering entries by status.""" +async def test_list_entries_count_is_total_not_page_size(): + """The count field reports total matching rows for pagination.""" mock_service = AsyncMock() - mock_entry = create_mock_entry(status="success") + mock_entry = create_mock_entry() mock_giveaway = create_mock_giveaway() - mock_service.get_entry_history.return_value = [mock_entry] - mock_service.giveaway_repo.get_by_id.return_value = mock_giveaway + mock_service.entry_repo.search.return_value = ([(mock_entry, mock_giveaway)], 137) - result = await list_entries( - giveaway_service=mock_service, - status_filter="success", - entry_type=None, - limit=50, - offset=0, + result = await _call_list_entries(mock_service, limit=1) + + assert result["data"]["count"] == 137 + assert len(result["data"]["entries"]) == 1 + + +@pytest.mark.asyncio +async def test_list_entries_combined_filters_forwarded(): + """Status, type and date filters all reach the repo together.""" + from datetime import date + + mock_service = AsyncMock() + mock_service.entry_repo.search.return_value = ([], 0) + + result = await _call_list_entries( + mock_service, + status_filter="failed", + entry_type="wishlist", + from_date=date(2026, 7, 1), + to_date=date(2026, 7, 15), + offset=20, ) assert result["success"] is True - mock_service.get_entry_history.assert_called_once_with(limit=50, status="success") + call = mock_service.entry_repo.search.call_args.kwargs + assert call["status"] == "failed" + assert call["entry_type"] == "wishlist" + assert call["from_date"] == datetime(2026, 7, 1, 0, 0) + # to_date is inclusive: forwarded as the next midnight + assert call["to_date"] == datetime(2026, 7, 16, 0, 0) + assert call["offset"] == 20 @pytest.mark.asyncio -async def test_list_entries_by_type(): - """Test filtering entries by type.""" +async def test_list_entries_missing_giveaway_omits_key(): + """An entry whose giveaway is gone still serializes.""" mock_service = AsyncMock() - mock_entry = create_mock_entry(entry_type="auto") - mock_giveaway = create_mock_giveaway() - mock_service.entry_repo.get_by_entry_type.return_value = [mock_entry] - mock_service.giveaway_repo.get_by_id.return_value = mock_giveaway + mock_entry = create_mock_entry() + mock_service.entry_repo.search.return_value = ([(mock_entry, None)], 1) - result = await list_entries( - giveaway_service=mock_service, - status_filter=None, - entry_type="auto", - limit=50, - offset=0, - ) + result = await _call_list_entries(mock_service) assert result["success"] is True - mock_service.entry_repo.get_by_entry_type.assert_called_once_with("auto", limit=50) + assert "giveaway" not in result["data"]["entries"][0] @pytest.mark.asyncio diff --git a/backend/tests/unit/test_api_routers_system.py b/backend/tests/unit/test_api_routers_system.py index 057292c..acc323d 100644 --- a/backend/tests/unit/test_api_routers_system.py +++ b/backend/tests/unit/test_api_routers_system.py @@ -74,6 +74,21 @@ async def test_system_info_includes_config(): assert isinstance(data["database_url"], str) +def _call_get_logs(mock_service, **overrides): + kwargs = dict( + notification_service=mock_service, + limit=50, + offset=0, + level=None, + event_type=None, + search=None, + from_date=None, + to_date=None, + ) + kwargs.update(overrides) + return get_logs(**kwargs) + + @pytest.mark.asyncio async def test_get_logs(): """Test GET /system/logs endpoint.""" @@ -82,58 +97,65 @@ async def test_get_logs(): create_mock_activity_log(1, "info", "scan", "Test log 1"), create_mock_activity_log(2, "warning", "entry", "Test log 2"), ] - mock_service.get_recent_logs.return_value = mock_logs + mock_service.search_logs.return_value = (mock_logs, 2) - result = await get_logs(notification_service=mock_service, limit=50, level=None, event_type=None) + result = await _call_get_logs(mock_service) assert result["success"] is True assert "data" in result assert result["data"]["count"] == 2 assert result["data"]["limit"] == 50 assert len(result["data"]["logs"]) == 2 - mock_service.get_recent_logs.assert_called_once_with(limit=50) @pytest.mark.asyncio -async def test_get_logs_with_level_filter(): - """Test GET /system/logs with level filter.""" - mock_service = AsyncMock() - mock_logs = [ - create_mock_activity_log(1, "error", "error", "Error message"), - ] - mock_service.get_logs_by_level.return_value = mock_logs +async def test_get_logs_combinable_filters(): + """Level, event type, search and dates are all forwarded together.""" + from datetime import date, time, timedelta - result = await get_logs(notification_service=mock_service, limit=50, level="error", event_type=None) + mock_service = AsyncMock() + mock_service.search_logs.return_value = ([], 0) - assert result["success"] is True - assert result["data"]["count"] == 1 - mock_service.get_logs_by_level.assert_called_once_with( + result = await _call_get_logs( + mock_service, level="error", - limit=50, + event_type="scan", + search="drift", + from_date=date(2026, 7, 1), + to_date=date(2026, 7, 15), + offset=100, ) + assert result["success"] is True + call = mock_service.search_logs.call_args.kwargs + assert call["level"] == "error" + assert call["event_type"] == "scan" + assert call["search"] == "drift" + assert call["from_date"] == datetime.combine(date(2026, 7, 1), time.min) + assert call["to_date"] == datetime.combine(date(2026, 7, 15) + timedelta(days=1), time.min) + assert call["offset"] == 100 + @pytest.mark.asyncio -async def test_get_logs_with_custom_limit(): - """Test GET /system/logs with custom limit.""" +async def test_get_logs_count_is_total_not_page_size(): + """The count field reports total matching rows for pagination.""" mock_service = AsyncMock() - mock_logs = [] - mock_service.get_recent_logs.return_value = mock_logs + mock_log = create_mock_activity_log(1, "info", "scan", "Test") + mock_service.search_logs.return_value = ([mock_log], 412) - result = await get_logs(notification_service=mock_service, limit=100, level=None, event_type=None) + result = await _call_get_logs(mock_service, limit=1) - assert result["success"] is True - assert result["data"]["limit"] == 100 - mock_service.get_recent_logs.assert_called_once_with(limit=100) + assert result["data"]["count"] == 412 + assert len(result["data"]["logs"]) == 1 @pytest.mark.asyncio async def test_get_logs_empty_result(): """Test GET /system/logs with no logs.""" mock_service = AsyncMock() - mock_service.get_recent_logs.return_value = [] + mock_service.search_logs.return_value = ([], 0) - result = await get_logs(notification_service=mock_service, limit=50, level=None, event_type=None) + result = await _call_get_logs(mock_service) assert result["success"] is True assert result["data"]["count"] == 0 @@ -145,9 +167,9 @@ async def test_get_logs_formats_correctly(): """Test GET /system/logs formats log data correctly.""" mock_service = AsyncMock() mock_log = create_mock_activity_log(123, "info", "entry", "Test message") - mock_service.get_recent_logs.return_value = [mock_log] + mock_service.search_logs.return_value = ([mock_log], 1) - result = await get_logs(notification_service=mock_service, limit=50, level=None, event_type=None) + result = await _call_get_logs(mock_service) log = result["data"]["logs"][0] assert log["id"] == 123 @@ -165,9 +187,9 @@ async def test_get_logs_handles_null_created_at(): mock_service = AsyncMock() mock_log = create_mock_activity_log(1, "info", "scan", "Test") mock_log.created_at = None - mock_service.get_recent_logs.return_value = [mock_log] + mock_service.search_logs.return_value = ([mock_log], 1) - result = await get_logs(notification_service=mock_service, limit=50, level=None, event_type=None) + result = await _call_get_logs(mock_service) log = result["data"]["logs"][0] assert log["created_at"] is None diff --git a/backend/tests/unit/test_repositories_entry.py b/backend/tests/unit/test_repositories_entry.py index bf7120b..3ada9fb 100644 --- a/backend/tests/unit/test_repositories_entry.py +++ b/backend/tests/unit/test_repositories_entry.py @@ -76,6 +76,41 @@ async def test_get_by_giveaway_not_found(test_db): assert result is None +@pytest.mark.asyncio +async def test_search_combines_filters_joins_giveaway_and_counts(test_db, sample_giveaway): + """search composes filters, joins the giveaway, and reports the true total.""" + async with test_db() as session: + repo = EntryRepository(session) + + old_success = await repo.create( + giveaway_id=sample_giveaway, points_spent=50, entry_type="auto", status="success" + ) + old_success.created_at = utcnow() - timedelta(days=10) + await repo.create( + giveaway_id=sample_giveaway, points_spent=0, entry_type="auto", status="failed" + ) + await repo.create( + giveaway_id=sample_giveaway, points_spent=30, entry_type="wishlist", status="success" + ) + await session.commit() + + # Combined status + type filter + rows, total = await repo.search(status="success", entry_type="auto") + assert total == 1 + entry, giveaway = rows[0] + assert entry.entry_type == "auto" + assert giveaway.game_name == "Test Game" # joined, no extra query + + # Date filter excludes the old entry + rows, total = await repo.search(from_date=utcnow() - timedelta(days=1)) + assert total == 2 + + # Pagination: total stays the full match count + rows, total = await repo.search(limit=1, offset=1) + assert total == 3 + assert len(rows) == 1 + + @pytest.mark.asyncio async def test_get_recent(test_db, sample_giveaway): """Test getting recent entries ordered by creation time.""" diff --git a/backend/tests/unit/test_services_notification_service.py b/backend/tests/unit/test_services_notification_service.py index 1e6f715..4b5e394 100644 --- a/backend/tests/unit/test_services_notification_service.py +++ b/backend/tests/unit/test_services_notification_service.py @@ -266,48 +266,49 @@ async def test_log_scan_complete(test_db): @pytest.mark.asyncio -async def test_log_entry_success(test_db): - """Test convenience method for logging successful entry.""" +async def test_search_logs_combinable_filters(test_db): + """search_logs composes level, event type, text and date filters.""" async with test_db() as session: service = NotificationService(session) - log = await service.log_entry_success( - giveaway_code="AbCd1", - game_name="Portal 2", - points=50 - ) + await service.log_activity(level="info", event_type="scan", message="Scan completed") + await service.log_activity(level="error", event_type="scan", message="Scan drifted") + await service.log_activity(level="error", event_type="error", message="Boom") - assert log.level == "info" - assert log.event_type == "entry" - assert "Portal 2" in log.message - assert "50P" in log.message + logs, total = await service.search_logs(level="error", event_type="scan") + assert total == 1 + assert logs[0].message == "Scan drifted" - details = json.loads(log.details) - assert details["code"] == "AbCd1" - assert details["game"] == "Portal 2" - assert details["points"] == 50 + logs, total = await service.search_logs(search="scan") + assert total == 2 # case-insensitive message match + + logs, total = await service.search_logs(limit=1, offset=1) + assert total == 3 + assert len(logs) == 1 @pytest.mark.asyncio -async def test_log_entry_failure(test_db): - """Test convenience method for logging failed entry.""" +async def test_prune_old_logs(test_db): + """prune_old_logs deletes rows past retention; 0 disables pruning.""" + from datetime import timedelta + + from core.time import utcnow + async with test_db() as session: service = NotificationService(session) - log = await service.log_entry_failure( - giveaway_code="AbCd1", - game_name="Portal 2", - reason="Insufficient points" - ) + old = await service.log_activity(level="info", event_type="scan", message="Ancient") + old.created_at = utcnow() - timedelta(days=60) + await service.log_activity(level="info", event_type="scan", message="Recent") + await session.commit() - assert log.level == "warning" - assert log.event_type == "entry" - assert "Portal 2" in log.message - assert "Insufficient points" in log.message + assert await service.prune_old_logs(0) == 0 # disabled + assert await service.get_logs_count() == 2 - details = json.loads(log.details) - assert details["code"] == "AbCd1" - assert details["reason"] == "Insufficient points" + assert await service.prune_old_logs(30) == 1 + logs, total = await service.search_logs() + assert total == 1 + assert logs[0].message == "Recent" @pytest.mark.asyncio @@ -352,20 +353,18 @@ async def test_multiple_operations(test_db): async with test_db() as session: service = NotificationService(session) - # Log various activities + # Log various activities (entry attempts are Entry rows, not logs) await service.log_scan_start(pages=3) - await service.log_entry_success("GA1", "Game 1", 50) - await service.log_entry_success("GA2", "Game 2", 75) - await service.log_entry_failure("GA3", "Game 3", "Already entered") + await service.log_error("api", "SteamGifts timeout") await service.log_scan_complete(new_count=10, updated_count=5) # Verify all logs all_logs = await service.get_recent_logs(limit=100) - assert len(all_logs) == 5 + assert len(all_logs) == 3 # Check specific log types - entry_logs = await service.get_logs_by_event_type("entry") - assert len(entry_logs) == 3 + error_logs = await service.get_logs_by_event_type("error") + assert len(error_logs) == 1 scan_logs = await service.get_logs_by_event_type("scan") assert len(scan_logs) == 2 diff --git a/frontend/src/hooks/useEntries.ts b/frontend/src/hooks/useEntries.ts index 81f4e1f..e2449c9 100644 --- a/frontend/src/hooks/useEntries.ts +++ b/frontend/src/hooks/useEntries.ts @@ -18,7 +18,7 @@ export const entryKeys = { */ export interface EntryFilters { status?: 'success' | 'failed' | 'pending' | 'all'; - type?: 'manual' | 'auto' | 'wishlist' | 'all'; + type?: 'manual' | 'auto' | 'wishlist' | 'dlc' | 'all'; giveaway_id?: number; from_date?: string; to_date?: string; @@ -69,12 +69,11 @@ export function useEntries(filters: EntryFilters = {}) { if (filters.to_date) { params.set('to_date', filters.to_date); } - if (filters.limit) { - params.set('limit', String(filters.limit)); - } + const limitParam = filters.limit || 20; + params.set('limit', String(limitParam)); + params.set('offset', String(((filters.page || 1) - 1) * limitParam)); - const queryString = params.toString(); - const endpoint = `/api/v1/entries/${queryString ? `?${queryString}` : ''}`; + const endpoint = `/api/v1/entries/?${params.toString()}`; const response = await api.get(endpoint); if (!response.success) { diff --git a/frontend/src/hooks/useLogs.test.tsx b/frontend/src/hooks/useLogs.test.tsx index ebfae16..3440e19 100644 --- a/frontend/src/hooks/useLogs.test.tsx +++ b/frontend/src/hooks/useLogs.test.tsx @@ -88,7 +88,7 @@ describe('useLogs', () => { limit: 50, pages: 1, }); - expect(mockApi.get).toHaveBeenCalledWith('/api/v1/system/logs'); + expect(mockApi.get).toHaveBeenCalledWith('/api/v1/system/logs?limit=50&offset=0'); }); it('should fetch with filters', async () => { @@ -108,7 +108,7 @@ describe('useLogs', () => { }); expect(mockApi.get).toHaveBeenCalledWith( - '/api/v1/system/logs?level=error&event_type=entry&search=failed' + '/api/v1/system/logs?level=error&event_type=entry&search=failed&limit=50&offset=0' ); }); diff --git a/frontend/src/hooks/useLogs.ts b/frontend/src/hooks/useLogs.ts index ac5398d..1b9053f 100644 --- a/frontend/src/hooks/useLogs.ts +++ b/frontend/src/hooks/useLogs.ts @@ -68,12 +68,11 @@ export function useLogs(filters: LogFilters = {}) { if (filters.search) { params.set('search', filters.search); } - if (filters.limit) { - params.set('limit', String(filters.limit)); - } + const limitParam = filters.limit || 50; + params.set('limit', String(limitParam)); + params.set('offset', String(((filters.page || 1) - 1) * limitParam)); - const queryString = params.toString(); - const endpoint = `/api/v1/system/logs${queryString ? `?${queryString}` : ''}`; + const endpoint = `/api/v1/system/logs?${params.toString()}`; const response = await api.get(endpoint); if (!response.success) { diff --git a/frontend/src/hooks/useSettings.test.tsx b/frontend/src/hooks/useSettings.test.tsx index c8a190a..5f0073e 100644 --- a/frontend/src/hooks/useSettings.test.tsx +++ b/frontend/src/hooks/useSettings.test.tsx @@ -66,6 +66,7 @@ const mockSettings: Settings = { max_scan_pages: 5, entry_delay_min: 1000, entry_delay_max: 3000, + log_retention_days: 30, last_synced_at: '2024-01-01T00:00:00Z', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index 021c86d..ef051cd 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -134,6 +134,12 @@ export function History() { > Wishlist + handleTypeFilter('dlc')} + > + DLC + diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 4bf1a19..f7fd53d 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -64,6 +64,7 @@ function SettingsForm({ settings }: { settings: SettingsType }) { max_scan_pages: settings.max_scan_pages, entry_delay_min: settings.entry_delay_min, entry_delay_max: settings.entry_delay_max, + log_retention_days: settings.log_retention_days, })); const [hasChanges, setHasChanges] = useState(false); const [showPhpsessid, setShowPhpsessid] = useState(false); @@ -313,6 +314,13 @@ function SettingsForm({ settings }: { settings: SettingsType }) { onChange={(e) => handleChange('max_entries_per_cycle', e.target.value ? parseInt(e.target.value) : null)} helperText="Limit entries per cycle (empty = unlimited)" /> + handleChange('log_retention_days', parseInt(e.target.value) || 0)} + helperText="Activity logs older than this are pruned (0 = keep forever)" + /> diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f4f97f9..f9186fe 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -107,6 +107,7 @@ export interface Settings { max_scan_pages: number; entry_delay_min: number; entry_delay_max: number; + log_retention_days: number; last_synced_at: string | null; created_at: string; updated_at: string;