192 lines
6.7 KiB
Python
192 lines
6.7 KiB
Python
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Literal
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
|
from pydantic import Field, field_validator
|
|
|
|
from ..auth import get_current_user, require_admin
|
|
from ..runtime import get_runtime_settings
|
|
from ..services import newsletters as service, newsletter_store as store, newsletter_catalog as catalog
|
|
from .recaps import StrictPayload, Preference, RecapSettings, TokenAction, no_cache
|
|
|
|
router = APIRouter(tags=['newsletters'], dependencies=[Depends(no_cache)])
|
|
|
|
|
|
class Settings(StrictPayload):
|
|
enabled: bool
|
|
weekday: int = Field(ge=0, le=6)
|
|
hour: int = Field(ge=0, le=23)
|
|
limit_titles: int = Field(ge=1, le=24)
|
|
public_url: str = Field(max_length=500)
|
|
intro: str = Field(default='', max_length=2000)
|
|
revision: int = Field(ge=1)
|
|
_url = field_validator('public_url')(RecapSettings.origin_only.__func__)
|
|
|
|
|
|
class NewDraft(StrictPayload):
|
|
days: Literal[7, 14, 30] = 7
|
|
|
|
|
|
class Selection(StrictPayload):
|
|
id: str = Field(pattern=r'^[a-f0-9]{32}$')
|
|
selected: bool
|
|
featured: bool
|
|
|
|
|
|
class Version(StrictPayload):
|
|
revision: int = Field(ge=1)
|
|
|
|
|
|
class EditionUpdate(Version):
|
|
subject: str = Field(min_length=1, max_length=150)
|
|
intro: str = Field(default='', max_length=2000)
|
|
titles: list[Selection] = Field(max_length=60)
|
|
|
|
@field_validator('subject')
|
|
@classmethod
|
|
def subject_line(cls, value):
|
|
value = value.strip()
|
|
if not value or any(ord(char) < 32 or ord(char) == 127 for char in value):
|
|
raise ValueError('Use a single, non-empty subject line.')
|
|
return value
|
|
|
|
|
|
class Test(Version):
|
|
request_id: UUID
|
|
|
|
|
|
class Publish(Version):
|
|
send_at: datetime | None = None
|
|
|
|
|
|
def fail(exc):
|
|
if isinstance(exc, service.NewsletterError):
|
|
raise HTTPException(exc.status, exc.detail) from exc
|
|
if isinstance(exc, store.Conflict):
|
|
raise HTTPException(429 if 'five minutes' in str(exc) else 409, str(exc)) from exc
|
|
raise HTTPException(502, str(exc) if isinstance(exc, catalog.CatalogError) else 'Jellyfin took too long to prepare this edition. Please try again.') from exc
|
|
|
|
|
|
@router.get('/profile/newsletters')
|
|
def preference(user: dict = Depends(get_current_user)):
|
|
try:
|
|
return service.preferences(user)
|
|
except service.NewsletterError as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.put('/profile/newsletters')
|
|
async def set_preference(payload: Preference, user: dict = Depends(get_current_user)):
|
|
try:
|
|
if payload.enabled:
|
|
return await service.subscribe(user)
|
|
store.disable(service.account_for(user)['id'])
|
|
return service.preferences(user)
|
|
except service.NewsletterError as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.post('/newsletter-subscription/check')
|
|
def check_token(payload: TokenAction):
|
|
try:
|
|
return service.token_action(payload.token, payload.action)
|
|
except service.NewsletterError as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.post('/newsletter-subscription/confirm')
|
|
def confirm_token(payload: TokenAction):
|
|
try:
|
|
return service.token_action(payload.token, payload.action, apply=True)
|
|
except service.NewsletterError as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.get('/admin/newsletters')
|
|
def overview(offset: int = Query(default=0, ge=0, le=1_000_000), user: dict = Depends(require_admin)):
|
|
ready, detail = service.delivery_ready()
|
|
return {'settings': store.public_settings(), 'ready': ready, 'detail': detail,
|
|
'playback_url': service.playback_url(get_runtime_settings()), **store.overview(offset)}
|
|
|
|
|
|
@router.put('/admin/newsletters')
|
|
def settings(payload: Settings, user: dict = Depends(require_admin)):
|
|
try:
|
|
ready, detail = service.delivery_ready(payload.public_url)
|
|
if payload.enabled and not ready:
|
|
raise service.NewsletterError(detail)
|
|
return store.save_settings(payload.model_dump(), datetime.now(timezone.utc))
|
|
except (service.NewsletterError, store.Conflict) as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.post('/admin/newsletters/drafts', status_code=201)
|
|
async def create_draft(payload: NewDraft, user: dict = Depends(require_admin)):
|
|
try:
|
|
return await service.create_draft(user, payload.days)
|
|
except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.get('/admin/newsletters/editions/{identity}')
|
|
def edition(identity: UUID, user: dict = Depends(require_admin)):
|
|
try:
|
|
return service.require_edition(identity.hex)
|
|
except service.NewsletterError as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.put('/admin/newsletters/editions/{identity}')
|
|
def update_edition(identity: UUID, payload: EditionUpdate, user: dict = Depends(require_admin)):
|
|
try:
|
|
return store.update_edition(identity.hex, payload.revision, payload.subject, payload.intro,
|
|
[entry.model_dump() for entry in payload.titles], time.time())
|
|
except store.Conflict as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.post('/admin/newsletters/editions/{identity}/preview')
|
|
async def preview(identity: UUID, payload: Version, user: dict = Depends(require_admin)):
|
|
try:
|
|
return await service.preview(identity.hex, payload.revision)
|
|
except (service.NewsletterError, catalog.CatalogError, TimeoutError) as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.post('/admin/newsletters/editions/{identity}/test', status_code=202)
|
|
def send_test(identity: UUID, payload: Test, user: dict = Depends(require_admin)):
|
|
try:
|
|
return service.queue_test(user, identity.hex, payload.revision, str(payload.request_id))
|
|
except (service.NewsletterError, store.Conflict) as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.post('/admin/newsletters/editions/{identity}/publish', status_code=202)
|
|
def publish(identity: UUID, payload: Publish, user: dict = Depends(require_admin)):
|
|
try:
|
|
return service.publish(identity.hex, payload.revision, payload.send_at)
|
|
except (service.NewsletterError, store.Conflict) as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.post('/admin/newsletters/editions/{identity}/cancel')
|
|
def cancel(identity: UUID, user: dict = Depends(require_admin)):
|
|
try:
|
|
service.require_edition(identity.hex)
|
|
return store.cancel(identity.hex, time.time())
|
|
except service.NewsletterError as exc:
|
|
fail(exc)
|
|
|
|
|
|
@router.get('/admin/newsletters/artwork/{identity}')
|
|
async def artwork(identity: UUID, user: dict = Depends(require_admin)):
|
|
runtime = get_runtime_settings()
|
|
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
|
|
raise HTTPException(404, 'Artwork unavailable')
|
|
content = await catalog.poster(runtime, identity.hex)
|
|
if not content:
|
|
raise HTTPException(404, 'Artwork unavailable')
|
|
return Response(content=content, media_type='image/jpeg', headers={'Cache-Control': 'private, max-age=600'})
|