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

View file

@ -0,0 +1,2 @@
"""Application routers grouped by concern."""

209
app/web/routers/admin.py Normal file
View file

@ -0,0 +1,209 @@
from __future__ import annotations
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.auth.deps import get_auth_service
from app.auth.service import AuthService
from app.db.session import get_db_session
from app.services import users as user_service
from app.theme import resolve_theme
from app.web import common
logger = logging.getLogger(__name__)
router = APIRouter()
async def _render_users_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_email: str = "",
form_is_admin: bool = False,
) -> HTMLResponse:
users = await user_service.list_users(db_session)
context = common.build_template_context(
request,
page_title="Users",
active_nav="users",
theme_name=theme_name,
session_user=common.to_session_user(current_user),
notice=notice,
page_error=page_error,
)
context["users"] = users
context["current_user_id"] = current_user.id
context["form_email"] = form_email
context["form_is_admin"] = form_is_admin
return common.templates.TemplateResponse(
request=request,
name="users.html",
context=context,
status_code=status_code,
)
@router.get("/users", response_class=HTMLResponse)
async def users_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()
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required.")
return await _render_users_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("/users")
async def create_user(
request: Request,
email: Annotated[str, Form()],
password: Annotated[str, Form()],
is_admin: Annotated[str | None, Form()] = None,
db_session: AsyncSession = Depends(get_db_session),
auth_service: AuthService = Depends(get_auth_service),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required.")
active_theme = resolve_theme(None)
try:
validated_email = user_service.validate_email(email)
validated_password = user_service.validate_password(password)
created_user = await user_service.create_user(
db_session,
email=validated_email,
password_hash=auth_service.hash_password(validated_password),
is_admin=is_admin == "on",
)
except user_service.UserServiceError as exc:
logger.info(
"admin.user_create_failed",
extra={
"event": "admin.user_create_failed",
"admin_user_id": current_user.id,
"reason": "validation_or_conflict",
},
)
return await _render_users_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_email=email,
form_is_admin=is_admin == "on",
)
logger.info(
"admin.user_created",
extra={
"event": "admin.user_created",
"admin_user_id": current_user.id,
"target_user_id": created_user.id,
"target_is_admin": created_user.is_admin,
},
)
return common.redirect_with_message(
"/users",
notice=f"User created: {created_user.email}",
)
@router.post("/users/{user_id}/toggle-active")
async def toggle_user_active(
request: Request,
user_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()
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required.")
target_user = await user_service.get_user_by_id(db_session, user_id)
if target_user is None:
return common.redirect_with_message("/users", error="User not found.")
if target_user.id == current_user.id and target_user.is_active:
return common.redirect_with_message("/users", error="You cannot deactivate your own account.")
updated_user = await user_service.set_user_active(
db_session,
user=target_user,
is_active=not target_user.is_active,
)
status_label = "activated" if updated_user.is_active else "deactivated"
logger.info(
"admin.user_active_toggled",
extra={
"event": "admin.user_active_toggled",
"admin_user_id": current_user.id,
"target_user_id": updated_user.id,
"is_active": updated_user.is_active,
},
)
return common.redirect_with_message("/users", notice=f"User {status_label}: {updated_user.email}")
@router.post("/users/{user_id}/reset-password")
async def reset_user_password(
request: Request,
user_id: int,
new_password: Annotated[str, Form()],
db_session: AsyncSession = Depends(get_db_session),
auth_service: AuthService = Depends(get_auth_service),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required.")
target_user = await user_service.get_user_by_id(db_session, user_id)
if target_user is None:
return common.redirect_with_message("/users", error="User not found.")
try:
validated_password = user_service.validate_password(new_password)
except user_service.UserServiceError as exc:
return common.redirect_with_message("/users", error=str(exc))
await user_service.set_user_password_hash(
db_session,
user=target_user,
password_hash=auth_service.hash_password(validated_password),
)
logger.info(
"admin.user_password_reset",
extra={
"event": "admin.user_password_reset",
"admin_user_id": current_user.id,
"target_user_id": target_user.id,
},
)
return common.redirect_with_message("/users", notice=f"Password reset: {target_user.email}")

214
app/web/routers/auth.py Normal file
View file

@ -0,0 +1,214 @@
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import get_auth_service, get_login_rate_limiter
from app.auth.rate_limit import SlidingWindowRateLimiter
from app.auth.service import AuthService
from app.auth.session import get_session_user, set_session_user
from app.db.session import get_db_session
from app.security.csrf import ensure_csrf_token
from app.services import users as user_service
from app.theme import resolve_theme
from app.web import common
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, theme: str | None = None) -> HTMLResponse:
active_theme = resolve_theme(theme)
ensure_csrf_token(request)
if get_session_user(request) is not None:
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
return common.render_login_page(request, theme_name=active_theme)
@router.post("/login", response_class=HTMLResponse)
async def login(
request: Request,
email: Annotated[str, Form()],
password: Annotated[str, Form()],
db_session: AsyncSession = Depends(get_db_session),
auth_service: AuthService = Depends(get_auth_service),
rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter),
) -> HTMLResponse:
active_theme = resolve_theme(None)
limiter_key = common.login_rate_limit_key(request, email)
decision = rate_limiter.check(limiter_key)
normalized_email = email.strip().lower()
if not decision.allowed:
logger.warning(
"auth.login_rate_limited",
extra={
"event": "auth.login_rate_limited",
"email": normalized_email,
"retry_after_seconds": decision.retry_after_seconds,
},
)
response = common.render_login_page(
request,
theme_name=active_theme,
error_message="Too many login attempts. Please try again later.",
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
retry_after_seconds=decision.retry_after_seconds,
)
response.headers["Retry-After"] = str(decision.retry_after_seconds)
return response
user = await auth_service.authenticate_user(
db_session,
email=email,
password=password,
)
if user is None:
rate_limiter.record_failure(limiter_key)
logger.info(
"auth.login_failed",
extra={
"event": "auth.login_failed",
"email": normalized_email,
},
)
return common.render_login_page(
request,
theme_name=active_theme,
error_message="Invalid email or password.",
status_code=status.HTTP_401_UNAUTHORIZED,
)
rate_limiter.reset(limiter_key)
set_session_user(
request,
user_id=user.id,
email=user.email,
is_admin=user.is_admin,
)
ensure_csrf_token(request)
logger.info(
"auth.login_succeeded",
extra={
"event": "auth.login_succeeded",
"user_id": user.id,
"is_admin": user.is_admin,
},
)
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/logout")
async def logout(request: Request) -> RedirectResponse:
session_user = get_session_user(request)
common.invalidate_session(request)
logger.info(
"auth.logout",
extra={
"event": "auth.logout",
"user_id": session_user.id if session_user else None,
},
)
return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/account/password", response_class=HTMLResponse)
async def account_password_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 common.templates.TemplateResponse(
request=request,
name="account_password.html",
context=common.build_template_context(
request,
page_title="Change Password",
active_nav="account_password",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
),
)
@router.post("/account/password")
async def update_account_password(
request: Request,
current_password: Annotated[str, Form()],
new_password: Annotated[str, Form()],
confirm_password: Annotated[str, Form()],
db_session: AsyncSession = Depends(get_db_session),
auth_service: AuthService = Depends(get_auth_service),
) -> RedirectResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
if not auth_service.verify_password(
password_hash=current_user.password_hash,
password=current_password,
):
logger.info(
"account.password_change_failed",
extra={
"event": "account.password_change_failed",
"user_id": current_user.id,
"reason": "invalid_current_password",
},
)
return common.redirect_with_message(
"/account/password",
error="Current password is incorrect.",
)
if new_password != confirm_password:
logger.info(
"account.password_change_failed",
extra={
"event": "account.password_change_failed",
"user_id": current_user.id,
"reason": "confirmation_mismatch",
},
)
return common.redirect_with_message(
"/account/password",
error="New password and confirmation do not match.",
)
try:
validated_password = user_service.validate_password(new_password)
except user_service.UserServiceError as exc:
logger.info(
"account.password_change_failed",
extra={
"event": "account.password_change_failed",
"user_id": current_user.id,
"reason": "validation_error",
},
)
return common.redirect_with_message("/account/password", error=str(exc))
await user_service.set_user_password_hash(
db_session,
user=current_user,
password_hash=auth_service.hash_password(validated_password),
)
logger.info(
"account.password_changed",
extra={
"event": "account.password_changed",
"user_id": current_user.id,
},
)
return common.redirect_with_message(
"/account/password",
notice="Password updated successfully.",
)

View file

@ -0,0 +1,167 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import RunTriggerType
from app.db.session import get_db_session
from app.presentation import dashboard as dashboard_presenter
from app.services import ingestion as ingestion_service
from app.services import publications as publication_service
from app.services import runs as run_service
from app.services import user_settings as user_settings_service
from app.settings import settings
from app.theme import resolve_theme
from app.web import common
from app.web.deps import get_ingestion_service
logger = logging.getLogger(__name__)
router = APIRouter()
async def _render_dashboard_page(
request: Request,
*,
db_session: AsyncSession,
current_user,
theme_name: str,
notice: str | None = None,
page_error: str | None = None,
) -> HTMLResponse:
unread_publications = await publication_service.list_new_for_latest_run_for_user(
db_session,
user_id=current_user.id,
limit=50,
)
recent_runs = await run_service.list_recent_runs_for_user(
db_session,
user_id=current_user.id,
limit=20,
)
user_settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
queue_counts = await run_service.queue_status_counts_for_user(
db_session,
user_id=current_user.id,
)
context = common.build_template_context(
request,
page_title="Dashboard",
active_nav="home",
theme_name=theme_name,
session_user=common.to_session_user(current_user),
notice=notice,
page_error=page_error,
)
context["dashboard"] = dashboard_presenter.build_dashboard_view_model(
unread_publications=unread_publications,
recent_runs=recent_runs,
request_delay_seconds=user_settings.request_delay_seconds,
queue_counts=queue_counts,
)
return common.templates.TemplateResponse(
request=request,
name="index.html",
context=context,
)
@router.get("/", response_class=HTMLResponse)
async def home(
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_dashboard_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("/runs/manual")
async def run_manual_ingestion(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
ingest_service: ingestion_service.ScholarIngestionService = Depends(get_ingestion_service),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
user_settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
logger.info(
"runs.manual_started",
extra={
"event": "runs.manual_started",
"user_id": current_user.id,
"request_delay_seconds": user_settings.request_delay_seconds,
"network_error_retries": settings.ingestion_network_error_retries,
"max_pages_per_scholar": settings.ingestion_max_pages_per_scholar,
"page_size": settings.ingestion_page_size,
},
)
try:
run_summary = await ingest_service.run_for_user(
db_session,
user_id=current_user.id,
trigger_type=RunTriggerType.MANUAL,
request_delay_seconds=user_settings.request_delay_seconds,
network_error_retries=settings.ingestion_network_error_retries,
retry_backoff_seconds=settings.ingestion_retry_backoff_seconds,
max_pages_per_scholar=settings.ingestion_max_pages_per_scholar,
page_size=settings.ingestion_page_size,
auto_queue_continuations=settings.ingestion_continuation_queue_enabled,
queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds,
)
except ingestion_service.RunAlreadyInProgressError:
await db_session.rollback()
return common.redirect_with_message(
"/",
error="A run is already in progress for this account.",
)
except Exception:
await db_session.rollback()
logger.exception(
"runs.manual_failed",
extra={
"event": "runs.manual_failed",
"user_id": current_user.id,
},
)
return common.redirect_with_message("/", error="Manual run failed. Check logs for details.")
logger.info(
"runs.manual_completed",
extra={
"event": "runs.manual_completed",
"user_id": current_user.id,
"run_id": run_summary.crawl_run_id,
"status": run_summary.status.value,
"scholar_count": run_summary.scholar_count,
"new_publication_count": run_summary.new_publication_count,
},
)
return common.redirect_with_message(
"/",
notice=(
f"Run #{run_summary.crawl_run_id} complete ({run_summary.status.value}). "
f"Scholars: {run_summary.scholar_count}, "
f"new publications: {run_summary.new_publication_count}."
),
)

15
app/web/routers/health.py Normal file
View file

@ -0,0 +1,15 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from app.db.session import check_database
router = APIRouter()
@router.get("/healthz")
async def healthz() -> dict[str, str]:
if await check_database():
return {"status": "ok"}
raise HTTPException(status_code=500, detail="database unavailable")

View file

@ -0,0 +1,165 @@
from __future__ import annotations
import logging
from urllib.parse import urlencode, urlparse
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db_session
from app.services import publications as publication_service
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()
MODE_NEW = "new"
MODE_ALL = "all"
def _resolve_mode(raw_mode: str | None) -> str:
return publication_service.resolve_mode(raw_mode)
def _parse_scholar_profile_id(value: str | None) -> int | None:
if not value:
return None
try:
parsed = int(value)
except ValueError:
return None
return parsed if parsed > 0 else None
def _build_publications_url(*, mode: str, scholar_profile_id: int | None) -> str:
params: dict[str, str] = {"mode": mode}
if scholar_profile_id is not None:
params["scholar_profile_id"] = str(scholar_profile_id)
return f"/publications?{urlencode(params)}"
def _is_safe_return_to(value: str | None) -> bool:
if not value:
return False
parsed = urlparse(value)
if parsed.scheme or parsed.netloc:
return False
if not value.startswith("/"):
return False
return value == "/" or value.startswith("/publications")
@router.get("/publications", response_class=HTMLResponse)
async def publications_page(
request: Request,
mode: str | None = None,
scholar_profile_id: str | None = None,
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()
resolved_mode = _resolve_mode(mode)
scholar_id_filter = _parse_scholar_profile_id(scholar_profile_id)
scholars = await scholar_service.list_scholars_for_user(
db_session,
user_id=current_user.id,
)
scholar_lookup = {scholar.id: scholar for scholar in scholars}
if scholar_id_filter not in scholar_lookup:
scholar_id_filter = None
publications = await publication_service.list_for_user(
db_session,
user_id=current_user.id,
mode=resolved_mode,
scholar_profile_id=scholar_id_filter,
limit=500,
)
new_count = await publication_service.count_for_user(
db_session,
user_id=current_user.id,
mode=MODE_NEW,
scholar_profile_id=scholar_id_filter,
)
total_count = await publication_service.count_for_user(
db_session,
user_id=current_user.id,
mode=MODE_ALL,
scholar_profile_id=scholar_id_filter,
)
mode_new_url = _build_publications_url(
mode=MODE_NEW,
scholar_profile_id=scholar_id_filter,
)
mode_all_url = _build_publications_url(
mode=MODE_ALL,
scholar_profile_id=scholar_id_filter,
)
selected_scholar = scholar_lookup.get(scholar_id_filter) if scholar_id_filter else None
current_publications_url = _build_publications_url(
mode=resolved_mode,
scholar_profile_id=scholar_id_filter,
)
context = common.build_template_context(
request,
page_title="Publications",
active_nav="publications",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
)
context["mode"] = resolved_mode
context["publications"] = publications
context["new_count"] = new_count
context["total_count"] = total_count
context["mode_new_url"] = mode_new_url
context["mode_all_url"] = mode_all_url
context["scholars"] = scholars
context["selected_scholar_id"] = scholar_id_filter
context["selected_scholar"] = selected_scholar
context["current_publications_url"] = current_publications_url
return common.templates.TemplateResponse(
request=request,
name="publications.html",
context=context,
)
@router.post("/publications/mark-all-read")
async def mark_all_publications_read(
request: Request,
return_to: str | None = Form(default=None),
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()
updated_count = await publication_service.mark_all_unread_as_read_for_user(
db_session,
user_id=current_user.id,
)
logger.info(
"publications.mark_all_read",
extra={
"event": "publications.mark_all_read",
"user_id": current_user.id,
"updated_count": updated_count,
},
)
redirect_target = "/publications?mode=new"
if _is_safe_return_to(return_to):
redirect_target = return_to
return common.redirect_with_message(
redirect_target,
notice=f"Marked {updated_count} publication(s) as read.",
)

274
app/web/routers/runs.py Normal file
View file

@ -0,0 +1,274 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db_session
from app.services import runs as run_service
from app.theme import resolve_theme
from app.web import common
router = APIRouter()
def _as_bool(value: str | None) -> bool:
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "on"}
def _status_badge(status_value: str) -> str:
if status_value == "success":
return "ok"
if status_value == "failed":
return "danger"
return "warn"
def _queue_status_badge(status_value: str) -> str:
if status_value == "dropped":
return "danger"
if status_value == "retrying":
return "ok"
return "warn"
def _format_dt(value: datetime | None) -> str:
if value is None:
return "-"
return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def _summary_dict(error_log: object) -> dict[str, object]:
if not isinstance(error_log, dict):
return {}
summary = error_log.get("summary")
if not isinstance(summary, dict):
return {}
return summary
@router.get("/runs", response_class=HTMLResponse)
async def runs_page(
request: Request,
theme: str | None = None,
failed_only: 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()
failed_only_enabled = _as_bool(failed_only)
runs = await run_service.list_runs_for_user(
db_session,
user_id=current_user.id,
limit=200,
failed_only=failed_only_enabled,
)
run_items = []
for run in runs:
summary = _summary_dict(run.error_log)
failed_count = summary.get("failed_count", 0)
partial_count = summary.get("partial_count", 0)
run_items.append(
{
"id": run.id,
"started_at": _format_dt(run.start_dt),
"finished_at": _format_dt(run.end_dt),
"status": run.status.value,
"status_badge": _status_badge(run.status.value),
"trigger_type": run.trigger_type.value,
"scholar_count": run.scholar_count,
"new_publication_count": run.new_pub_count,
"failed_count": failed_count,
"partial_count": partial_count,
}
)
queue_entries = await run_service.list_queue_items_for_user(
db_session,
user_id=current_user.id,
limit=200,
)
queue_items = []
for item in queue_entries:
queue_items.append(
{
"id": item.id,
"scholar_profile_id": item.scholar_profile_id,
"scholar_label": item.scholar_label,
"status": item.status,
"status_badge": _queue_status_badge(item.status),
"reason": item.reason,
"dropped_reason": item.dropped_reason,
"attempt_count": item.attempt_count,
"resume_cstart": item.resume_cstart,
"next_attempt_at": _format_dt(item.next_attempt_dt),
"updated_at": _format_dt(item.updated_at),
"last_error": item.last_error,
"last_run_id": item.last_run_id,
}
)
context = common.build_template_context(
request,
page_title="Runs",
active_nav="runs",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
)
context["runs"] = run_items
context["failed_only"] = failed_only_enabled
context["queue_items"] = queue_items
return common.templates.TemplateResponse(
request=request,
name="runs.html",
context=context,
)
@router.get("/runs/{run_id}", response_class=HTMLResponse)
async def run_detail_page(
request: Request,
run_id: int,
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()
run = await run_service.get_run_for_user(
db_session,
user_id=current_user.id,
run_id=run_id,
)
if run is None:
raise HTTPException(status_code=404, detail="Run not found.")
error_log = run.error_log if isinstance(run.error_log, dict) else {}
scholar_results = error_log.get("scholar_results", [])
if not isinstance(scholar_results, list):
scholar_results = []
context = common.build_template_context(
request,
page_title=f"Run #{run.id}",
active_nav="runs",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
)
context["run"] = {
"id": run.id,
"started_at": _format_dt(run.start_dt),
"finished_at": _format_dt(run.end_dt),
"status": run.status.value,
"status_badge": _status_badge(run.status.value),
"trigger_type": run.trigger_type.value,
"scholar_count": run.scholar_count,
"new_publication_count": run.new_pub_count,
}
context["run_summary"] = _summary_dict(error_log)
context["scholar_results"] = scholar_results
return common.templates.TemplateResponse(
request=request,
name="run_detail.html",
context=context,
)
@router.post("/runs/queue/{queue_item_id}/drop")
async def drop_queue_item(
request: Request,
queue_item_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()
try:
dropped = await run_service.drop_queue_item_for_user(
db_session,
user_id=current_user.id,
queue_item_id=queue_item_id,
)
except run_service.QueueTransitionError as exc:
return common.redirect_with_message(
"/runs",
error=f"Queue item #{queue_item_id}: {exc.message}",
)
if dropped is None:
raise HTTPException(status_code=404, detail="Queue item not found.")
return common.redirect_with_message(
"/runs",
notice=f"Queue item #{queue_item_id} marked as dropped.",
)
@router.post("/runs/queue/{queue_item_id}/clear")
async def clear_queue_item(
request: Request,
queue_item_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()
try:
cleared = await run_service.clear_queue_item_for_user(
db_session,
user_id=current_user.id,
queue_item_id=queue_item_id,
)
except run_service.QueueTransitionError as exc:
return common.redirect_with_message(
"/runs",
error=f"Queue item #{queue_item_id}: {exc.message}",
)
if cleared is None:
raise HTTPException(status_code=404, detail="Queue item not found.")
return common.redirect_with_message(
"/runs",
notice=f"Queue item #{queue_item_id} cleared.",
)
@router.post("/runs/queue/{queue_item_id}/retry")
async def retry_queue_item(
request: Request,
queue_item_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()
try:
queue_item = await run_service.retry_queue_item_for_user(
db_session,
user_id=current_user.id,
queue_item_id=queue_item_id,
)
except run_service.QueueTransitionError as exc:
return common.redirect_with_message(
"/runs",
error=f"Queue item #{queue_item_id}: {exc.message}",
)
if queue_item is None:
raise HTTPException(status_code=404, detail="Queue item not found.")
return common.redirect_with_message(
"/runs",
notice=(
f"Queue item #{queue_item_id} queued for retry "
f"(scholar: {queue_item.scholar_label})."
),
)

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}")

View file

@ -0,0 +1,94 @@
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db_session
from app.services import user_settings as user_settings_service
from app.theme import resolve_theme
from app.web import common
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/settings", response_class=HTMLResponse)
async def settings_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()
user_settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
context = common.build_template_context(
request,
page_title="Settings",
active_nav="settings",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
)
context["user_settings"] = user_settings
return common.templates.TemplateResponse(
request=request,
name="settings.html",
context=context,
)
@router.post("/settings")
async def update_settings(
request: Request,
run_interval_minutes: Annotated[str, Form()],
request_delay_seconds: Annotated[str, Form()],
auto_run_enabled: Annotated[str | None, Form()] = None,
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()
try:
parsed_interval = user_settings_service.parse_run_interval_minutes(
run_interval_minutes
)
parsed_delay = user_settings_service.parse_request_delay_seconds(
request_delay_seconds
)
except user_settings_service.UserSettingsServiceError as exc:
return common.redirect_with_message("/settings", error=str(exc))
user_settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
await user_settings_service.update_settings(
db_session,
settings=user_settings,
auto_run_enabled=auto_run_enabled == "on",
run_interval_minutes=parsed_interval,
request_delay_seconds=parsed_delay,
)
logger.info(
"settings.updated",
extra={
"event": "settings.updated",
"user_id": current_user.id,
"auto_run_enabled": auto_run_enabled == "on",
"run_interval_minutes": parsed_interval,
"request_delay_seconds": parsed_delay,
},
)
return common.redirect_with_message("/settings", notice="Settings updated.")