472 lines
15 KiB
Python
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
|