Files
fitguide/app/web/pages.py
T

472 lines
15 KiB
Python

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.archived} archived, "
f"{result.skipped} skipped (no type match)"
),
)
await jobs.mark_done(
job_id,
message=(
f"Sync complete: {result.created} created, "
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