from datetime import timedelta from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, status from fastapi.responses import HTMLResponse, RedirectResponse, Response from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import AsyncSessionLocal, get_db from app.crud import audit as audit_crud from app.crud import augmented_part as augmented_part_crud from app.crud import imported_part as imported_part_crud from app.crud import imported_part_config as imported_part_config_crud from app.crud import part_type as part_type_crud from app.crud import user as user_crud from app.deps import get_optional_web_user from app.models.user import User from app.services import augmented_part_sync as augmented_part_sync_service from app.services import fit_matching as fit_matching_service from app.services import jobs from app.services import ldap as ldap_service from app.services import odata as odata_service from app.services import scope_guide as scope_guide_service from app.services.auth import issue_token_pair from app.web.admin import web_require from app.web.templates import templates router = APIRouter(include_in_schema=False) _SYNC_CARD_ID = "dashboard-sync-card" def _safe_next(next_url: str) -> str: """Return next_url only if it's a safe same-site path, else "/". Guards against open-redirect: rejects absolute URLs and protocol-relative ("//host", "/\\host") targets, and never bounces back to the auth pages. """ if ( next_url.startswith("/") and not next_url.startswith(("//", "/\\")) and not next_url.startswith(("/login", "/logout")) ): return next_url return "/" def _set_auth_cookie(response: Response, access_token: str) -> None: response.set_cookie( key=settings.cookie_name, value=access_token, httponly=True, secure=settings.cookie_secure, samesite="lax", max_age=settings.session_timeout_hours * 3600, path="/", ) async def _dashboard_stats(db: AsyncSession) -> dict: scope_type = await part_type_crud.get_by_name( db, scope_guide_service.SCOPE_TYPE_NAME ) scope_count = ( await augmented_part_crud.count_filtered(db, part_type_id=scope_type.id) if scope_type is not None else 0 ) return { "scope_count": scope_count, "skipped_count": await augmented_part_crud.count_unlinked_filtered(db), } def _dashboard_permissions(user: User) -> dict: return { "can_refresh_odata": user.is_superuser or "imported_parts:write" in user.permissions, "can_sync_augmented_parts": user.is_superuser or "augmented_parts:write" in user.permissions, "can_recompute_fits": user.is_superuser or "fits:write" in user.permissions, } async def _sync_card_ctx( db: AsyncSession, user: User, status_message: str | None = None, status_error: str | None = None, ) -> dict: return { **(await _dashboard_stats(db)), **_dashboard_permissions(user), "status_message": status_message, "status_error": status_error, } @router.get("/", response_class=HTMLResponse) async def dashboard( request: Request, db: AsyncSession = Depends(get_db), user: User | None = Depends(get_optional_web_user), ): # Anonymous visitors land on the public Scopes page instead of being # bounced to login; the dashboard itself requires an account. if user is None: return RedirectResponse(url="/scopes", status_code=status.HTTP_302_FOUND) return templates.TemplateResponse( request, "dashboard.html", {"user": user, **(await _sync_card_ctx(db, user))}, ) @router.post("/dashboard/refresh-odata", response_class=HTMLResponse) async def dashboard_refresh_odata( request: Request, db: AsyncSession = Depends(get_db), actor: User = Depends(web_require("imported_parts:write")), ): config = await imported_part_config_crud.get(db) if not config or not config.odata_url: return templates.TemplateResponse( request, "_dashboard_sync_card.html", await _sync_card_ctx( db, actor, status_error="OData URL is not configured." ), ) actor_id = actor.id async def _run(job_id: str) -> None: async with AsyncSessionLocal() as db: config = await imported_part_config_crud.get(db) if not config or not config.odata_url: await jobs.mark_error(job_id, "OData URL is not configured.") return try: records = await odata_service.fetch( config.odata_url, config.username, config.password ) created, updated = await imported_part_crud.upsert_bulk(db, records) except Exception as exc: await jobs.mark_error(job_id, f"Sync failed: {exc}") return actor_row = await user_crud.get(db, actor_id) await audit_crud.log( db, actor=actor_row, action="update", resource_type="ImportedPart", resource_id=None, description=( f"OData sync: {created} created, {updated} updated " f"({len(records)} total records)" ), ) await jobs.mark_done( job_id, message=f"Sync complete: {created} created, {updated} updated.", ) job = await jobs.start("imported_parts_refresh", _run) return templates.TemplateResponse( request, "admin/_job_progress.html", { "container_id": _SYNC_CARD_ID, "poll_url": f"/dashboard/refresh-odata/jobs/{job.id}", "job": job, }, ) @router.get("/dashboard/refresh-odata/jobs/{job_id}", response_class=HTMLResponse) async def dashboard_refresh_odata_job_status( request: Request, job_id: str, db: AsyncSession = Depends(get_db), actor: User = Depends(web_require("imported_parts:write")), ): job = await jobs.get(db, job_id) if job is None: return templates.TemplateResponse( request, "_dashboard_sync_card.html", await _sync_card_ctx( db, actor, status_error="Refresh job not found — it may have expired." ), ) if job.status == "running": return templates.TemplateResponse( request, "admin/_job_progress.html", { "container_id": _SYNC_CARD_ID, "poll_url": f"/dashboard/refresh-odata/jobs/{job_id}", "job": job, }, ) return templates.TemplateResponse( request, "_dashboard_sync_card.html", await _sync_card_ctx( db, actor, status_message=job.message if job.status == "done" else None, status_error=job.message if job.status == "error" else None, ), ) @router.post("/dashboard/sync-augmented-parts", response_class=HTMLResponse) async def dashboard_sync_augmented_parts( request: Request, db: AsyncSession = Depends(get_db), actor: User = Depends(web_require("augmented_parts:write")), ): actor_id = actor.id async def _run(job_id: str) -> None: async with AsyncSessionLocal() as db: try: result = await augmented_part_sync_service.sync(db) except Exception as exc: await jobs.mark_error(job_id, f"Sync failed: {exc}") return actor_row = await user_crud.get(db, actor_id) await audit_crud.log( db, actor=actor_row, action="update", resource_type="AugmentedPart", resource_id=None, description=( f"Sync: {result.created} created, {result.updated} updated, " f"{result.archived} archived, " f"{result.skipped} skipped (no type match)" ), ) await jobs.mark_done( job_id, message=( f"Sync complete: {result.created} created, " f"{result.updated} updated, " f"{result.archived} archived" + ( f", {result.skipped} skipped (type not recognised)" if result.skipped else "" ) + "." ), ) job = await jobs.start("augmented_part_sync", _run) return templates.TemplateResponse( request, "admin/_job_progress.html", { "container_id": _SYNC_CARD_ID, "poll_url": f"/dashboard/sync-augmented-parts/jobs/{job.id}", "job": job, }, ) @router.get( "/dashboard/sync-augmented-parts/jobs/{job_id}", response_class=HTMLResponse ) async def dashboard_sync_augmented_parts_job_status( request: Request, job_id: str, db: AsyncSession = Depends(get_db), actor: User = Depends(web_require("augmented_parts:write")), ): job = await jobs.get(db, job_id) if job is None: return templates.TemplateResponse( request, "_dashboard_sync_card.html", await _sync_card_ctx( db, actor, status_error="Sync job not found — it may have expired." ), ) if job.status == "running": return templates.TemplateResponse( request, "admin/_job_progress.html", { "container_id": _SYNC_CARD_ID, "poll_url": f"/dashboard/sync-augmented-parts/jobs/{job_id}", "job": job, }, ) return templates.TemplateResponse( request, "_dashboard_sync_card.html", await _sync_card_ctx( db, actor, status_message=job.message if job.status == "done" else None, status_error=job.message if job.status == "error" else None, ), ) @router.post("/dashboard/recompute-fits", response_class=HTMLResponse) async def dashboard_recompute_fits( request: Request, db: AsyncSession = Depends(get_db), actor: User = Depends(web_require("fits:write")), ): actor_id = actor.id async def _run(job_id: str) -> None: async with AsyncSessionLocal() as db: try: result = await fit_matching_service.recompute_all(db) except Exception as exc: await jobs.mark_error(job_id, f"Recompute failed: {exc}") return actor_row = await user_crud.get(db, actor_id) await audit_crud.log( db, actor=actor_row, action="update", resource_type="Fit", resource_id=None, description=( f"Recomputed fits for all parts: {result.created} created, " f"{result.removed} removed" ), ) await jobs.mark_done( job_id, message=( f"Recompute complete: {result.created} created, " f"{result.removed} removed." ), ) job = await jobs.start("fit_recompute_all", _run) return templates.TemplateResponse( request, "admin/_job_progress.html", { "container_id": _SYNC_CARD_ID, "poll_url": f"/dashboard/recompute-fits/jobs/{job.id}", "job": job, }, ) @router.get("/dashboard/recompute-fits/jobs/{job_id}", response_class=HTMLResponse) async def dashboard_recompute_fits_job_status( request: Request, job_id: str, db: AsyncSession = Depends(get_db), actor: User = Depends(web_require("fits:write")), ): job = await jobs.get(db, job_id) if job is None: return templates.TemplateResponse( request, "_dashboard_sync_card.html", await _sync_card_ctx( db, actor, status_error="Recompute job not found — it may have expired.", ), ) if job.status == "running": return templates.TemplateResponse( request, "admin/_job_progress.html", { "container_id": _SYNC_CARD_ID, "poll_url": f"/dashboard/recompute-fits/jobs/{job_id}", "job": job, }, ) return templates.TemplateResponse( request, "_dashboard_sync_card.html", await _sync_card_ctx( db, actor, status_message=job.message if job.status == "done" else None, status_error=job.message if job.status == "error" else None, ), ) @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request, next_url: str = Query("", alias="next")): return templates.TemplateResponse( request, "login.html", {"ldap_enabled": settings.ldap_enabled, "next_url": _safe_next(next_url)}, ) @router.post("/login") async def login_submit( request: Request, email: str = Form(...), password: str = Form(...), next_url: str = Form("", alias="next"), db: AsyncSession = Depends(get_db), ): user = await user_crud.authenticate(db, email, password) if user is None: return templates.TemplateResponse( request, "login.html", { "local_error": "Incorrect email or password", "email": email, "ldap_enabled": settings.ldap_enabled, "next_url": _safe_next(next_url), }, status_code=status.HTTP_401_UNAUTHORIZED, ) access, _ = await issue_token_pair( db, user, timedelta(hours=settings.session_timeout_hours) ) response = RedirectResponse( url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER ) _set_auth_cookie(response, access) return response @router.post("/login/ldap") async def login_ldap_submit( request: Request, username: str = Form(...), password: str = Form(...), next_url: str = Form("", alias="next"), db: AsyncSession = Depends(get_db), ): if not settings.ldap_enabled: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) user = await ldap_service.login(db, username, password) if user is None: return templates.TemplateResponse( request, "login.html", { "ldap_error": "Incorrect username or password", "username": username, "ldap_enabled": True, "next_url": _safe_next(next_url), }, status_code=status.HTTP_401_UNAUTHORIZED, ) access, _ = await issue_token_pair( db, user, timedelta(hours=settings.session_timeout_hours) ) response = RedirectResponse( url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER ) _set_auth_cookie(response, access) return response @router.post("/logout") async def logout(request: Request): response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) response.delete_cookie(settings.cookie_name, path="/") return response