from datetime import datetime, timezone from typing import Literal from urllib.parse import urlsplit from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import BaseModel, ConfigDict, Field, field_validator from ..auth import get_current_user, require_admin from ..services import email_recaps as recaps, recap_store as store def no_cache(response: Response): response.headers["Cache-Control"] = "no-store" router = APIRouter(tags=["email-recaps"], dependencies=[Depends(no_cache)]) class StrictPayload(BaseModel): model_config = ConfigDict(extra="forbid") class Preference(StrictPayload): enabled: bool automatic_monthly: bool | None = Field(default=None, strict=True) class RecapSettings(StrictPayload): enabled: bool day: int = Field(ge=1, le=28) hour: int = Field(ge=0, le=23) public_url: str = Field(max_length=500) @field_validator("public_url") @classmethod def origin_only(cls, value: str) -> str: value = value.strip().rstrip('/') if not value: return value try: url = urlsplit(value) port = url.port except ValueError as exc: raise ValueError("Enter the public Magent address, such as https://magent.example.com.") from exc if (url.scheme not in {"http", "https"} or not url.hostname or url.username or url.password or url.path or url.query or url.fragment or any(char.isspace() or ord(char) < 33 for char in value) or any(char in value for char in '<>"\\') or (port is not None and port < 1)): raise ValueError("Enter a http(s) Magent address without a path, credentials or query.") return value class TestEmail(StrictPayload): month: str | None = Field(default=None, pattern=r"^[0-9]{4}-[0-9]{2}$") request_id: UUID class TokenAction(StrictPayload): token: str = Field(min_length=40, max_length=100, pattern=r"^[A-Za-z0-9_-]+$") action: Literal["confirm", "unsubscribe"] def error(exc: recaps.RecapError): raise HTTPException(exc.status, exc.detail) from exc @router.get("/profile/email-recaps") def preferences(user: dict = Depends(get_current_user)) -> dict: try: return recaps.preferences(user) except recaps.RecapError as exc: error(exc) @router.put("/profile/email-recaps") async def preference(payload: Preference, user: dict = Depends(get_current_user)) -> dict: try: if payload.enabled: return await recaps.subscribe(user, payload.automatic_monthly) store.disable(recaps.current_account(user)["id"]) return recaps.preferences(user) except recaps.RecapError as exc: error(exc) @router.post("/email-recaps/check") def check_token(payload: TokenAction) -> dict: try: return recaps.token_action(payload.token, payload.action) except recaps.RecapError as exc: error(exc) @router.post("/email-recaps/confirm") def apply_token(payload: TokenAction) -> dict: try: return recaps.token_action(payload.token, payload.action, apply=True) except recaps.RecapError as exc: error(exc) @router.get("/admin/email-recaps") def overview(offset: int = Query(default=0, ge=0), user: dict = Depends(require_admin)) -> dict: ready, detail = recaps.delivery_ready() months = recaps.month_periods(None, datetime.now(timezone.utc))["available_months"][1:] return {"settings": store.settings(), "ready": ready, "detail": detail, "months": months, "worker_enabled": recaps.worker_enabled(), **store.history(offset=offset)} @router.put("/admin/email-recaps") def settings(payload: RecapSettings, user: dict = Depends(require_admin)) -> dict: if payload.enabled: # Validate against the proposed URL without writing any partial settings. ready, detail = recaps.smtp_email_config_ready() runtime = recaps.get_runtime_settings() if not payload.public_url or not ready or not recaps.worker_enabled() or not runtime.jellystat_base_url or not runtime.jellystat_api_key: raise HTTPException(409, "Set the public address, enable SMTP email and connect Jellystat before starting the schedule." if ready else detail) return store.save_settings(payload.model_dump(), datetime.now(timezone.utc)) @router.get("/admin/email-recaps/preview") async def preview(month: str | None = Query(default=None, max_length=7, pattern=r"^[0-9]{4}-[0-9]{2}$"), user: dict = Depends(require_admin)) -> dict: try: return await recaps.preview(user, month) except recaps.RecapError as exc: error(exc) @router.post("/admin/email-recaps/test", status_code=202) def test_email(payload: TestEmail, user: dict = Depends(require_admin)) -> dict: try: return recaps.queue_test(user, payload.month, str(payload.request_id)) except recaps.RecapError as exc: error(exc) @router.post('/profile/email-recaps/send', status_code=202) def email_personal_report(payload: TestEmail, user: dict = Depends(get_current_user)) -> dict: try: return recaps.queue_personal(user, payload.month, str(payload.request_id)) except recaps.RecapError as exc: error(exc)