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
60 changes: 60 additions & 0 deletions backend/alembic/versions/20260715_activity_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Add activity table (append-only local activity log).

Revision ID: 20260715activitylog
Revises: 20260714_vault_credentials
Create Date: 2026-07-15
"""

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "20260715activitylog"
down_revision = "20260714_vault_credentials"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"activity",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("category", sa.String(length=32), nullable=False),
sa.Column("action", sa.String(length=32), nullable=False),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"media_id",
sa.Integer(),
sa.ForeignKey("media.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=True,
),
)
op.create_index("ix_activity_category", "activity", ["category"])
op.create_index("ix_activity_action", "activity", ["action"])
op.create_index("ix_activity_user_id", "activity", ["user_id"])
op.create_index("ix_activity_media_id", "activity", ["media_id"])
op.create_index("ix_activity_created_at", "activity", ["created_at"])
op.create_index("ix_activity_user_created", "activity", ["user_id", "created_at"])


def downgrade() -> None:
op.drop_index("ix_activity_user_created", table_name="activity")
op.drop_index("ix_activity_created_at", table_name="activity")
op.drop_index("ix_activity_media_id", table_name="activity")
op.drop_index("ix_activity_user_id", table_name="activity")
op.drop_index("ix_activity_action", table_name="activity")
op.drop_index("ix_activity_category", table_name="activity")
op.drop_table("activity")
16 changes: 16 additions & 0 deletions backend/src/find_api/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ class Settings(BaseSettings):
# Trashed assets older than this many days are eligible for permanent
# auto-purge (via POST /trash/purge). 0 disables age-based purging.
TRASH_RETENTION_DAYS: int = 30
# Activity log rows older than this many days are eligible for auto-purge
# (via POST /activity/purge). 0 disables age-based purging.
ACTIVITY_RETENTION_DAYS: int = 90
Comment thread
Abhash-Chakraborty marked this conversation as resolved.
BATCH_SIZE: int = 1
EMBEDDING_DIM: int = 768 # SigLIP ViT-B-16 dimension

Expand Down Expand Up @@ -139,6 +142,19 @@ def validate_positive_int(cls, value: int, info):
raise ValueError(f"{info.field_name} must be greater than 0")
return value

@field_validator("ACTIVITY_RETENTION_DAYS", "TRASH_RETENTION_DAYS")
@classmethod
def validate_retention_days(cls, value: int, info):
"""Reject negative retention windows.

0 is a documented "keep forever" switch, but a negative value has no
meaning -- it would silently disable cleanup instead of failing, so a
typo like -1 in the environment looks like it worked.
"""
if value < 0:
raise ValueError(f"{info.field_name} must be 0 or greater")
return value

@field_validator("MAX_IMAGE_PIXELS")
@classmethod
def validate_image_pixel_ceiling(cls, value: int):
Expand Down
1 change: 1 addition & 0 deletions backend/src/find_api/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def init_db():
try:
# Import all models to register them for metadata creation
from find_api.models import ( # noqa: F401
activity,
album,
app_setting,
cluster,
Expand Down
12 changes: 12 additions & 0 deletions backend/src/find_api/core/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from find_api.core.auth import get_current_user, is_shared_mode
from find_api.core.database import get_db
from find_api.models.activity import Activity
from find_api.models.media import Media
from find_api.models.user import User

Expand Down Expand Up @@ -117,3 +118,14 @@ def can_access_media(media: Media, user: Optional[User]) -> bool:
if user is None or user.role == "admin":
return True
return media.uploader_user_id == user.id


def scope_activity_query(query: Q, user: Optional[User]) -> Q:
"""Restrict an Activity query to rows the user is allowed to see.

Mirrors :func:`scope_media_query`: local mode and admins see every row;
a regular member only sees activity recorded under their own user id.
"""
if user is None or user.role == "admin":
return query
return query.filter(Activity.user_id == user.id)
2 changes: 2 additions & 0 deletions backend/src/find_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from find_api.routers import album
from find_api.routers import shared_link
from find_api.routers import partner
from find_api.routers import activity

# Configure logging
logging.basicConfig(
Expand Down Expand Up @@ -142,6 +143,7 @@ async def lifespan(app: FastAPI):
app.include_router(album.router, prefix="/api", tags=["albums"])
app.include_router(shared_link.router, prefix="/api", tags=["shared-links"])
app.include_router(partner.router, prefix="/api", tags=["partners"])
app.include_router(activity.router, prefix="/api", tags=["activity"])
app.include_router(search.router, prefix="/api", tags=["search"])
app.include_router(clusters.router, prefix="/api", tags=["clusters"])
app.include_router(cluster.router, prefix="/api", tags=["cluster-ops"])
Expand Down
2 changes: 2 additions & 0 deletions backend/src/find_api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Database models
"""

from find_api.models.activity import Activity
from find_api.models.media import Media
from find_api.models.cluster import Cluster
from find_api.models.face import Face
Expand All @@ -18,6 +19,7 @@
from find_api.models.partner_share import PartnerShare

__all__ = [
"Activity",
"Media",
"Cluster",
"Face",
Expand Down
55 changes: 55 additions & 0 deletions backend/src/find_api/models/activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Append-only local activity log.

Records privacy-safe events for diagnosing and understanding what changed:
upload/processing outcomes, archive/trash/restore, vault lock/unlock, and
settings updates. ``payload`` must stay small and typed — never image bytes,
embeddings, OCR text, captions, secrets, or raw session tokens (see
services/activity_log.py for the writer that enforces this at call sites).

Rows are written once and never mutated. Both foreign keys use SET NULL (not
CASCADE) so the log outlives the user/media it references, keeping the
history readable after a delete.
"""

from sqlalchemy import Column, DateTime, ForeignKey, Index, Integer, JSON, String
from sqlalchemy.sql import func

from find_api.core.database import Base


class Activity(Base):
"""One append-only activity log entry."""

__tablename__ = "activity"

id = Column(Integer, primary_key=True, index=True)

# Broad grouping for the category filter, e.g. "upload", "media", "vault",
# "settings".
category = Column(String(32), nullable=False, index=True)
# Specific event within the category, e.g. "completed", "failed",
# "archived", "trashed", "restored", "locked", "unlocked", "updated".
action = Column(String(32), nullable=False, index=True)

# Owner scoping (populated in shared mode, null in local mode) — mirrors
# Media.uploader_user_id.
user_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
# Related media, when applicable. Kept even after the media row is gone.
media_id = Column(
Integer, ForeignKey("media.id", ondelete="SET NULL"), nullable=True, index=True
)

# Small, privacy-safe details (e.g. filename, sanitized error, changed
# setting key). See module docstring for what must never go here.
payload = Column(JSON, nullable=True)

created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)

__table_args__ = (Index("ix_activity_user_created", "user_id", "created_at"),)

def __repr__(self):
return (
f"<Activity(id={self.id}, category={self.category}, action={self.action})>"
)
98 changes: 98 additions & 0 deletions backend/src/find_api/routers/activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Local activity log — read history and manage retention.

Rows are written elsewhere (see services/activity_log.py) at the point of
each state change; this router only reads and prunes them.
"""

from typing import Optional

from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session

from find_api.core.config import settings
from find_api.core.database import get_db
from find_api.core.dependencies import get_required_user
from find_api.models.activity import Activity
from find_api.models.user import User
from find_api.services import activity_log

router = APIRouter()


class ActivityPurgeResponse(BaseModel):
"""Summary of a clear/purge request."""

message: str
deleted_count: int


def _serialize_activity(entry: Activity) -> dict:
return {
"id": entry.id,
"category": entry.category,
"action": entry.action,
"user_id": entry.user_id,
"media_id": entry.media_id,
"payload": entry.payload,
"created_at": entry.created_at.isoformat() if entry.created_at else None,
}


@router.get("/activity")
def list_activity(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
category: Optional[str] = Query(None, description="e.g. 'upload', 'vault'"),
action: Optional[str] = Query(None, description="e.g. 'completed', 'failed'"),
media_id: Optional[int] = Query(None, description="Filter to one asset's history"),
db: Session = Depends(get_db),
user: Optional[User] = Depends(get_required_user),
):
"""List activity log entries, newest first."""
rows, total = activity_log.list_activity(
db,
user,
skip=skip,
limit=limit,
category=category,
action=action,
media_id=media_id,
)
items = [_serialize_activity(row) for row in rows]
page = (skip // limit) + 1 if limit else 1
return {"items": items, "total": total, "skip": skip, "page": page, "limit": limit}


@router.post("/activity/clear", response_model=ActivityPurgeResponse)
def clear_activity(
db: Session = Depends(get_db),
user: Optional[User] = Depends(get_required_user),
):
"""Delete every activity row the current user can see."""
deleted = activity_log.clear_activity(db, user)
return {"message": "Activity cleared", "deleted_count": deleted}


@router.post("/activity/purge", response_model=ActivityPurgeResponse)
def purge_expired_activity(
db: Session = Depends(get_db),
user: Optional[User] = Depends(get_required_user),
):
"""Delete activity rows older than the retention window.

Mirrors ``POST /trash/purge``: age-bounded, intended for a scheduled or
manual auto-purge. ``ACTIVITY_RETENTION_DAYS=0`` disables it (no-op).
"""
retention_days = settings.ACTIVITY_RETENTION_DAYS
if retention_days <= 0:
return {
"message": "Auto-purge disabled (ACTIVITY_RETENTION_DAYS=0)",
"deleted_count": 0,
}

deleted = activity_log.purge_expired_activity(db, user, retention_days)
return {
"message": f"Purged activity older than {retention_days} days",
"deleted_count": deleted,
}
Comment thread
Abhash-Chakraborty marked this conversation as resolved.
35 changes: 35 additions & 0 deletions backend/src/find_api/routers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from find_api.models.app_setting import AppSetting
from find_api.models.user import User
from find_api.services.activity_log import record_activity

router = APIRouter()

Expand Down Expand Up @@ -182,16 +183,32 @@ def update_settings(
changed. The value is read back immediately by this API process; see
models/app_setting.py for the cross-process propagation caveat.
"""
previous_prefs = load_runtime_preferences(db)
previous_trash_retention_days = _trash_retention_days(db)
changes = []

if request.accel_mode is not None:
# Defensive: Literal already constrains this, but guard the raw write.
if request.accel_mode not in _VALID_ACCEL_MODES:
raise HTTPException(422, "accel_mode must be one of auto, gpu, cpu")
if request.accel_mode != previous_prefs.accel_mode:
changes.append(
(ACCEL_MODE_KEY, previous_prefs.accel_mode, request.accel_mode)
)
_upsert_setting(db, ACCEL_MODE_KEY, request.accel_mode)

if request.ai_enabled is not None:
if request.ai_enabled != previous_prefs.ai_enabled:
changes.append(
(AI_ENABLED_KEY, previous_prefs.ai_enabled, request.ai_enabled)
)
_upsert_setting(db, AI_ENABLED_KEY, str(request.ai_enabled).lower())

if request.map_enabled is not None:
if request.map_enabled != previous_prefs.map_enabled:
changes.append(
(MAP_ENABLED_KEY, previous_prefs.map_enabled, request.map_enabled)
)
_upsert_setting(db, MAP_ENABLED_KEY, str(request.map_enabled).lower())

if request.ml_mode is not None:
Expand All @@ -201,6 +218,8 @@ def update_settings(
422,
f"ML mode '{request.ml_mode}' is not installed in this artifact",
)
if request.ml_mode != previous_prefs.ml_mode:
changes.append((ML_MODE_KEY, previous_prefs.ml_mode, request.ml_mode))
_upsert_setting(db, ML_MODE_KEY, request.ml_mode)

if request.trash_retention_days is not None:
Expand All @@ -209,6 +228,14 @@ def update_settings(
422,
"trash_retention_days must be between 0 and 3650",
)
if request.trash_retention_days != previous_trash_retention_days:
changes.append(
(
TRASH_RETENTION_DAYS_KEY,
previous_trash_retention_days,
request.trash_retention_days,
)
)
_upsert_setting(
db,
TRASH_RETENTION_DAYS_KEY,
Expand All @@ -217,5 +244,13 @@ def update_settings(

if request.model_fields_set:
db.commit()
for key, previous_value, new_value in changes:
record_activity(
db,
"settings",
"updated",
user_id=user.id if user else None,
payload={"key": key, "from": previous_value, "to": new_value},
)

return _settings_response(db)
Loading