First product
This commit is contained in:
parent
778da9e2fc
commit
4433d7d2c4
157 changed files with 23975 additions and 0 deletions
2
app/web/routers/__init__.py
Normal file
2
app/web/routers/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"""Application routers grouped by concern."""
|
||||
|
||||
209
app/web/routers/admin.py
Normal file
209
app/web/routers/admin.py
Normal 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
214
app/web/routers/auth.py
Normal 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.",
|
||||
)
|
||||
|
||||
167
app/web/routers/dashboard.py
Normal file
167
app/web/routers/dashboard.py
Normal 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
15
app/web/routers/health.py
Normal 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")
|
||||
|
||||
165
app/web/routers/publications.py
Normal file
165
app/web/routers/publications.py
Normal 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
274
app/web/routers/runs.py
Normal 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
212
app/web/routers/scholars.py
Normal 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}")
|
||||
94
app/web/routers/settings.py
Normal file
94
app/web/routers/settings.py
Normal 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.")
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue