Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
50 changes: 29 additions & 21 deletions backend/src/api/routers/entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
}
)

Expand Down
56 changes: 25 additions & 31 deletions backend/src/api/routers/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import csv
import json
from datetime import date, datetime, time, timedelta
from io import StringIO
from typing import Any

Expand Down Expand Up @@ -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:
{
Expand All @@ -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,
Expand All @@ -150,8 +143,9 @@ async def get_logs(
return create_success_response(
data={
"logs": logs,
"count": len(logs),
"count": total,
"limit": limit,
"offset": offset,
}
)

Expand Down
11 changes: 11 additions & 0 deletions backend/src/api/schemas/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": {
Expand Down
5 changes: 5 additions & 0 deletions backend/src/models/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
67 changes: 62 additions & 5 deletions backend/src/repositories/activity_log.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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]:
"""
Expand Down Expand Up @@ -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]
Expand All @@ -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)
)
Expand Down
Loading
Loading