Add opt-in monthly email recaps with scheduling and delivery history
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
Reference in New Issue
Block a user