diff --git a/app/db/repositories.py b/app/db/repositories.py index 7f08d4d..ac95348 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from datetime import datetime from sqlalchemy import and_, or_, select @@ -7,6 +8,18 @@ from sqlalchemy.orm import Session from app.db.models import Activity, ActivityStatus, SyncRun, SyncRunStatus, SyncUser, utcnow +@dataclass(frozen=True) +class UserDashboardRow: + id: int + name: str + enabled: bool + health_state: str + action_reason: str | None + last_sync_at: datetime | None + last_activity_name: str | None + last_activity_status: str | None + + class UserRepository: def __init__(self, session: Session) -> None: self.session = session @@ -32,6 +45,28 @@ class UserRepository: self.session.commit() return user + def dashboard_rows(self) -> list[UserDashboardRow]: + users = self.list_all() + rows = [] + for user in users: + last_run = self.session.scalar( + select(SyncRun).where(SyncRun.user_id == user.id).order_by(SyncRun.started_at.desc()).limit(1) + ) + last_activity = self.session.scalar( + select(Activity).where(Activity.user_id == user.id).order_by(Activity.created_at.desc()).limit(1) + ) + rows.append(UserDashboardRow( + id=user.id, + name=user.name, + enabled=user.enabled, + health_state=user.health_state.value, + action_reason=user.action_reason, + last_sync_at=last_run.finished_at if last_run else None, + last_activity_name=last_activity.activity_name if last_activity else None, + last_activity_status=last_activity.status.value if last_activity else None, + )) + return rows + class ActivityRepository: def __init__(self, session: Session) -> None: diff --git a/app/main.py b/app/main.py index 4223c19..eae2fd9 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,7 @@ from app.mywhoosh.client import MyWhooshClient from app.security.credentials import CredentialCipher from app.sync.manager import SyncManager from app.sync.scheduler import SyncScheduler +from app.web.operations import router as operations_router from app.web.routes import router as web_router @@ -63,6 +64,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: https_only=resolved.session_https_only, ) app.include_router(web_router) + app.include_router(operations_router) @app.get("/healthz") def healthz() -> dict[str, str]: diff --git a/app/web/operations.py b/app/web/operations.py new file mode 100644 index 0000000..9e03e16 --- /dev/null +++ b/app/web/operations.py @@ -0,0 +1,78 @@ +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import func, select + +from app.auth.admin import require_admin +from app.auth.csrf import ensure_csrf_token, validate_csrf +from app.db.models import Activity +from app.db.repositories import UserRepository +from app.sync.manager import SyncAlreadyRunning +from app.web.routes import templates + +router = APIRouter() + +APP_VERSION = "1.0.0" + + +def _normalize_outcome(item): + if isinstance(item, Exception): + return { + "status": "error", + "user_id": None, + "discovered": 0, + "imported": 0, + "skipped": 0, + "failed": 0, + "message": str(item), + } + return { + "status": item.status, + "user_id": item.user_id, + "discovered": item.discovered, + "imported": item.imported, + "skipped": item.skipped, + "failed": item.failed, + "message": item.message, + } + + +@router.post("/users/{user_id}/sync", response_class=HTMLResponse) +async def manual_sync(request: Request, user_id: int, csrf_token: str = Form(...)): + require_admin(request) + validate_csrf(request, csrf_token) + try: + outcome = await request.app.state.sync_manager.sync_user(user_id) + except SyncAlreadyRunning: + return HTMLResponse("Sync already running for this user", status_code=409) + return templates.TemplateResponse( + request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]} + ) + + +@router.post("/sync-all", response_class=HTMLResponse) +async def manual_sync_all(request: Request, csrf_token: str = Form(...)): + require_admin(request) + validate_csrf(request, csrf_token) + outcomes = await request.app.state.sync_manager.sync_all_enabled() + return templates.TemplateResponse( + request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(o) for o in outcomes]} + ) + + +@router.get("/system", response_class=HTMLResponse) +def system_page(request: Request): + require_admin(request) + settings = request.app.state.settings + scheduler = request.app.state.scheduler + with request.app.state.session_factory() as session: + user_count = len(UserRepository(session).list_all()) + activity_count = session.scalar(select(func.count()).select_from(Activity)) or 0 + return templates.TemplateResponse(request, "system.html", { + "csrf_token": ensure_csrf_token(request), + "app_version": APP_VERSION, + "sync_interval_minutes": settings.sync_interval_minutes, + "last_tick": scheduler.last_tick, + "next_tick": scheduler.next_tick, + "user_count": user_count, + "activity_count": activity_count, + }) diff --git a/app/web/routes.py b/app/web/routes.py index 2d4e242..1edd7af 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -62,11 +62,11 @@ def login( def dashboard(request: Request): require_admin(request) with request.app.state.session_factory() as session: - users = UserRepository(session).list_all() + rows = UserRepository(session).dashboard_rows() return templates.TemplateResponse( request, "dashboard.html", - {"users": users, "csrf_token": ensure_csrf_token(request)}, + {"rows": rows, "csrf_token": ensure_csrf_token(request)}, ) diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index 320affb..c47920b 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -4,13 +4,28 @@ {% block content %}

Dashboard

-

Add user

+

Add user | System

+ +
+ + +
+