145 lines
5.4 KiB
Python
145 lines
5.4 KiB
Python
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 ..services.public_urls import magent_public_url
|
|
from ..auth import require_admin
|
|
from ..feature_guards import require_stats
|
|
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(default="", 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(require_stats)) -> 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(require_stats)) -> 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 magent_public_url(payload.public_url or store.settings()["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(), "public_url": magent_public_url(payload.public_url or store.settings()["public_url"])}, 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(require_stats)) -> dict:
|
|
try:
|
|
return recaps.queue_personal(user, payload.month, str(payload.request_id))
|
|
except recaps.RecapError as exc:
|
|
error(exc)
|