First product

This commit is contained in:
Justin Visser 2026-02-17 14:51:25 +01:00
parent 778da9e2fc
commit 4433d7d2c4
157 changed files with 23975 additions and 0 deletions

212
app/web/routers/scholars.py Normal file
View file

@ -0,0 +1,212 @@
from __future__ import annotations
from datetime import datetime, timezone
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db_session
from app.services import scholars as scholar_service
from app.theme import resolve_theme
from app.web import common
logger = logging.getLogger(__name__)
router = APIRouter()
def _format_dt(value: datetime | None) -> str:
if value is None:
return "-"
return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
async def _render_scholars_page(
request: Request,
*,
db_session: AsyncSession,
current_user,
theme_name: str,
notice: str | None = None,
page_error: str | None = None,
status_code: int = status.HTTP_200_OK,
form_scholar_id: str = "",
form_display_name: str = "",
) -> HTMLResponse:
scholars = await scholar_service.list_scholars_for_user(
db_session,
user_id=current_user.id,
)
scholar_items = []
for scholar in scholars:
scholar_items.append(
{
"id": scholar.id,
"scholar_id": scholar.scholar_id,
"display_name": scholar.display_name or "Unnamed",
"is_enabled": scholar.is_enabled,
"baseline_completed": scholar.baseline_completed,
"last_run_status": (
scholar.last_run_status.value
if scholar.last_run_status is not None
else "never"
),
"last_run_dt": _format_dt(scholar.last_run_dt),
}
)
context = common.build_template_context(
request,
page_title="Scholars",
active_nav="scholars",
theme_name=theme_name,
session_user=common.to_session_user(current_user),
notice=notice,
page_error=page_error,
)
context["scholars"] = scholar_items
context["form_scholar_id"] = form_scholar_id
context["form_display_name"] = form_display_name
return common.templates.TemplateResponse(
request=request,
name="scholars.html",
context=context,
status_code=status_code,
)
@router.get("/scholars", response_class=HTMLResponse)
async def scholars_page(
request: Request,
theme: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
return await _render_scholars_page(
request,
db_session=db_session,
current_user=current_user,
theme_name=resolve_theme(theme),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
)
@router.post("/scholars")
async def create_scholar(
request: Request,
scholar_id: Annotated[str, Form()],
display_name: Annotated[str, Form()] = "",
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
active_theme = resolve_theme(None)
try:
created_profile = await scholar_service.create_scholar_for_user(
db_session,
user_id=current_user.id,
scholar_id=scholar_id,
display_name=display_name,
)
except scholar_service.ScholarServiceError as exc:
logger.info(
"scholars.create_failed",
extra={
"event": "scholars.create_failed",
"user_id": current_user.id,
"scholar_id": scholar_id.strip(),
},
)
return await _render_scholars_page(
request,
db_session=db_session,
current_user=current_user,
theme_name=active_theme,
page_error=str(exc),
status_code=status.HTTP_400_BAD_REQUEST,
form_scholar_id=scholar_id,
form_display_name=display_name,
)
label = created_profile.display_name or created_profile.scholar_id
logger.info(
"scholars.created",
extra={
"event": "scholars.created",
"user_id": current_user.id,
"scholar_profile_id": created_profile.id,
},
)
return common.redirect_with_message("/scholars", notice=f"Scholar added: {label}")
@router.post("/scholars/{scholar_profile_id}/toggle")
async def toggle_scholar(
request: Request,
scholar_profile_id: int,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
profile = await scholar_service.get_user_scholar_by_id(
db_session,
user_id=current_user.id,
scholar_profile_id=scholar_profile_id,
)
if profile is None:
raise HTTPException(status_code=404, detail="Scholar not found.")
updated_profile = await scholar_service.toggle_scholar_enabled(
db_session,
profile=profile,
)
status_label = "enabled" if updated_profile.is_enabled else "disabled"
logger.info(
"scholars.toggled",
extra={
"event": "scholars.toggled",
"user_id": current_user.id,
"scholar_profile_id": updated_profile.id,
"is_enabled": updated_profile.is_enabled,
},
)
return common.redirect_with_message(
"/scholars",
notice=f"Scholar {status_label}: {updated_profile.scholar_id}",
)
@router.post("/scholars/{scholar_profile_id}/delete")
async def delete_scholar(
request: Request,
scholar_profile_id: int,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
profile = await scholar_service.get_user_scholar_by_id(
db_session,
user_id=current_user.id,
scholar_profile_id=scholar_profile_id,
)
if profile is None:
raise HTTPException(status_code=404, detail="Scholar not found.")
deleted_label = profile.display_name or profile.scholar_id
await scholar_service.delete_scholar(db_session, profile=profile)
logger.info(
"scholars.deleted",
extra={
"event": "scholars.deleted",
"user_id": current_user.id,
"scholar_profile_id": scholar_profile_id,
},
)
return common.redirect_with_message("/scholars", notice=f"Scholar removed: {deleted_label}")