This repository has been archived on 2026-06-09. You can view files and clone it, but cannot push or open issues or pull requests.
wedding-app/apps/api/app/routes/admin.py
Claude 2fa17e5feb
feat: rewrite backend in Python (FastAPI + SQLAlchemy + boto3)
Same routes, same Docker topology, same env vars. Frontend untouched.

Backend swap:
- Hono on Node -> FastAPI on Python 3.12, served by uvicorn behind Caddy.
- Drizzle on Node -> SQLAlchemy 2.0 async (asyncpg driver) with declarative
  models that mirror the previous schema 1:1. Migrations are still plain
  SQL files (apps/api/app/migrations/) run idempotently at startup by
  app/db/migrate.py via asyncpg, tracking each file's sha256 in a
  schema_migrations table.
- aws4fetch -> boto3 wrapped in asyncio.to_thread so the async routes
  do not block on MinIO/S3 calls. The presign_put / init_multipart /
  complete_multipart / head / delete contract is unchanged so the React
  client sees the same /api/uploads/* payloads.
- Cookie+JWT admin auth -> pyjwt + FastAPI Cookie() dependency.
  constant-time password compare. Same 7-day HttpOnly cookie shape.
- QR code: qrcode lib + reportlab. /api/qrcode keeps format=png|svg|pdf;
  the PDF is still A6 with the couple's names + 'Aponte a câmera' CTA.
- Pydantic-settings replaces the zod env loader and still fails fast
  on missing required vars.

Routes ported with identical paths and JSON shapes:
- public: /api/event, /api/gallery, /api/qrcode, /api/stats
- uploads: /api/uploads/init, /{id}/confirm, /{id}/abort
- admin: /login, /logout, /me, /event (GET+PATCH), /uploads (GET with
  ?status, ?kind=photo|video, ?q= for search), /uploads/{id} (PATCH
  for author/message edit), /uploads/{id}/{approve,reject,cover},
  /uploads/{id} (DELETE), /event/cover (DELETE), /uploads/bulk, /stats.

Build pipeline:
- Dockerfile: stage 1 builds the React/Vite SPA with pnpm; stage 2 is
  python:3.12-slim that installs deps via uv (fast, reproducible),
  copies the app/, and copies the SPA dist from stage 1 into /app/public
  so the FastAPI process serves both /api/* and the SPA assets.
- docker-compose.yml unchanged: postgres + minio + minio-init + app +
  caddy. Same env vars (DATABASE_URL, S3_*, ADMIN_PASSWORD, SESSION_SECRET,
  ALLOWED_ADMIN_EMAILS, AUTO_MIGRATE).
- Removed apps/api from the pnpm workspace; root package.json now only
  scripts the web. Makefile centralizes dev/build/docker commands so
  the Python side has the same DX as the Node side did.
2026-06-07 22:45:20 +00:00

353 lines
10 KiB
Python

import time
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy import delete, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..db.base import get_db
from ..db.models import EventConfig, Upload
from ..lib.auth import (
clear_session_cookie,
constant_time_eq,
get_admin_email,
set_session_cookie,
)
from ..lib.storage import storage
from ..schemas.api import (
AdminBulk,
AdminUploadUpdate,
EventConfigUpdate,
LoginIn,
)
router = APIRouter()
ADMIN_PAGE_SIZE = 30
Db = Annotated[AsyncSession, Depends(get_db)]
Admin = Annotated[str, Depends(get_admin_email)]
def _now_ms() -> int:
return int(time.time() * 1000)
# ---------- Auth (unauthenticated) ----------
@router.post("/login")
async def login(body: LoginIn, response: Response):
email = body.email.lower()
if email not in settings.admin_emails_list:
raise HTTPException(401, detail="unauthorized")
if not constant_time_eq(body.password, settings.ADMIN_PASSWORD):
raise HTTPException(401, detail="unauthorized")
set_session_cookie(response, email)
return {"ok": True, "email": email}
@router.post("/logout")
async def logout(response: Response):
clear_session_cookie(response)
return {"ok": True}
# ---------- Authenticated ----------
@router.get("/me")
async def me(email: Admin):
return {"email": email}
@router.get("/event")
async def get_event(_: Admin, db: Db):
cfg = (
await db.execute(select(EventConfig).where(EventConfig.id == 1))
).scalar_one_or_none()
if not cfg:
raise HTTPException(404, detail="not_configured")
return {
"coupleNames": cfg.couple_names,
"eventDate": cfg.event_date,
"coverKey": cfg.cover_key,
"coverUrl": storage.public_url(cfg.cover_key) if cfg.cover_key else None,
"galleryVisibility": cfg.gallery_visibility,
"moderation": cfg.moderation,
"maxFileMb": cfg.max_file_mb,
"allowVideo": cfg.allow_video,
"maxVideoSeconds": cfg.max_video_seconds,
"welcomeMessage": cfg.welcome_message,
"updatedAt": cfg.updated_at,
}
_EVENT_COL_MAP = {
"coupleNames": "couple_names",
"eventDate": "event_date",
"coverKey": "cover_key",
"galleryVisibility": "gallery_visibility",
"moderation": "moderation",
"maxFileMb": "max_file_mb",
"allowVideo": "allow_video",
"maxVideoSeconds": "max_video_seconds",
"welcomeMessage": "welcome_message",
}
@router.patch("/event")
async def patch_event(body: EventConfigUpdate, _: Admin, db: Db):
incoming = body.model_dump(exclude_unset=True)
updates: dict[str, object] = {
_EVENT_COL_MAP[k]: v for k, v in incoming.items() if k in _EVENT_COL_MAP
}
if not updates:
return {"ok": True, "noop": True}
updates["updated_at"] = _now_ms()
await db.execute(
update(EventConfig).where(EventConfig.id == 1).values(**updates)
)
await db.commit()
return {"ok": True}
@router.get("/uploads")
async def list_uploads(
_: Admin,
db: Db,
status: str | None = None,
kind: str | None = None,
q: str | None = None,
cursor: str | None = None,
limit: Annotated[int, Query(ge=1, le=100)] = ADMIN_PAGE_SIZE,
):
stmt = select(Upload)
if status in {"pending", "approved", "rejected"}:
stmt = stmt.where(Upload.status == status)
if cursor and cursor.isdigit():
stmt = stmt.where(Upload.created_at < int(cursor))
if kind == "photo":
stmt = stmt.where(Upload.mime_type.like("image/%"))
elif kind == "video":
stmt = stmt.where(Upload.mime_type.like("video/%"))
if q and q.strip():
pattern = f"%{q.strip()}%"
stmt = stmt.where(
or_(
Upload.author_name.ilike(pattern),
Upload.message.ilike(pattern),
)
)
stmt = stmt.order_by(Upload.created_at.desc()).limit(limit + 1)
rows = list((await db.execute(stmt)).scalars().all())
has_more = len(rows) > limit
slice_rows = rows[:limit] if has_more else rows
cfg = (
await db.execute(select(EventConfig).where(EventConfig.id == 1))
).scalar_one_or_none()
cover_key = cfg.cover_key if cfg else None
items = [
{
"id": r.id,
"url": storage.public_url(r.storage_key),
"thumbnailUrl": storage.public_url(r.thumbnail_key)
if r.thumbnail_key
else None,
"storageKey": r.storage_key,
"mimeType": r.mime_type,
"isVideo": r.mime_type.startswith("video/"),
"sizeBytes": r.size_bytes,
"durationSeconds": r.duration_seconds,
"authorName": r.author_name,
"message": r.message,
"status": r.status,
"source": r.source,
"createdAt": r.created_at,
"approvedAt": r.approved_at,
"isCover": cover_key == r.storage_key,
}
for r in slice_rows
]
next_cursor = str(slice_rows[-1].created_at) if has_more and slice_rows else None
return {"items": items, "nextCursor": next_cursor}
@router.patch("/uploads/{upload_id}")
async def patch_upload(
upload_id: str, body: AdminUploadUpdate, _: Admin, db: Db
):
incoming = body.model_dump(exclude_unset=True)
if not incoming:
return {"ok": True, "noop": True}
snake = {"authorName": "author_name", "message": "message"}
updates: dict[str, object | None] = {}
for k, v in incoming.items():
if k in snake:
updates[snake[k]] = v.strip() or None if isinstance(v, str) else v
if not updates:
return {"ok": True, "noop": True}
res = await db.execute(
update(Upload)
.where(Upload.id == upload_id)
.values(**updates)
.returning(Upload.id)
)
await db.commit()
if res.scalar_one_or_none() is None:
raise HTTPException(404, detail="not_found")
return {"ok": True}
@router.post("/uploads/{upload_id}/approve")
async def approve(upload_id: str, _: Admin, db: Db):
res = await db.execute(
update(Upload)
.where(Upload.id == upload_id)
.values(status="approved", approved_at=_now_ms())
.returning(Upload.id)
)
await db.commit()
if res.scalar_one_or_none() is None:
raise HTTPException(404, detail="not_found")
return {"ok": True}
@router.post("/uploads/{upload_id}/reject")
async def reject(upload_id: str, _: Admin, db: Db):
res = await db.execute(
update(Upload)
.where(Upload.id == upload_id)
.values(status="rejected", approved_at=None)
.returning(Upload.id)
)
await db.commit()
if res.scalar_one_or_none() is None:
raise HTTPException(404, detail="not_found")
return {"ok": True}
@router.post("/uploads/{upload_id}/cover")
async def set_cover(upload_id: str, _: Admin, db: Db):
row = (
await db.execute(select(Upload).where(Upload.id == upload_id))
).scalar_one_or_none()
if not row:
raise HTTPException(404, detail="not_found")
await db.execute(
update(EventConfig)
.where(EventConfig.id == 1)
.values(cover_key=row.storage_key, updated_at=_now_ms())
)
await db.commit()
return {"ok": True}
@router.delete("/event/cover")
async def clear_cover(_: Admin, db: Db):
await db.execute(
update(EventConfig)
.where(EventConfig.id == 1)
.values(cover_key=None, updated_at=_now_ms())
)
await db.commit()
return {"ok": True}
@router.delete("/uploads/{upload_id}")
async def delete_upload(upload_id: str, _: Admin, db: Db):
row = (
await db.execute(select(Upload).where(Upload.id == upload_id))
).scalar_one_or_none()
if not row:
raise HTTPException(404, detail="not_found")
storage_key = row.storage_key
thumb_key = row.thumbnail_key
await storage.delete(storage_key)
if thumb_key:
await storage.delete(thumb_key)
await db.delete(row)
cfg = (
await db.execute(select(EventConfig).where(EventConfig.id == 1))
).scalar_one_or_none()
if cfg and cfg.cover_key == storage_key:
await db.execute(
update(EventConfig).where(EventConfig.id == 1).values(cover_key=None)
)
await db.commit()
return {"ok": True}
@router.post("/uploads/bulk")
async def bulk(body: AdminBulk, _: Admin, db: Db):
if body.action == "delete":
rows = list(
(await db.execute(select(Upload).where(Upload.id.in_(body.ids))))
.scalars()
.all()
)
for r in rows:
await storage.delete(r.storage_key)
await db.execute(delete(Upload).where(Upload.id.in_(body.ids)))
cfg = (
await db.execute(select(EventConfig).where(EventConfig.id == 1))
).scalar_one_or_none()
if cfg and any(r.storage_key == cfg.cover_key for r in rows):
await db.execute(
update(EventConfig).where(EventConfig.id == 1).values(cover_key=None)
)
else:
new_status = "approved" if body.action == "approve" else "rejected"
approved_at = _now_ms() if new_status == "approved" else None
await db.execute(
update(Upload)
.where(Upload.id.in_(body.ids))
.values(status=new_status, approved_at=approved_at)
)
await db.commit()
return {"ok": True, "count": len(body.ids)}
@router.get("/stats")
async def stats(_: Admin, db: Db):
rows = (
await db.execute(
select(Upload.status, Upload.mime_type, Upload.size_bytes)
)
).all()
counts = {
"total": len(rows),
"approved": 0,
"pending": 0,
"rejected": 0,
"photos": 0,
"videos": 0,
"totalBytes": 0,
}
for r in rows:
counts[r.status] = counts.get(r.status, 0) + 1
if r.mime_type.startswith("image/"):
counts["photos"] += 1
elif r.mime_type.startswith("video/"):
counts["videos"] += 1
counts["totalBytes"] += r.size_bytes
return counts
@router.get("/export.zip")
async def export_zip(_: Admin):
raise HTTPException(501, detail="not_implemented_yet")
@router.post("/imports")
async def imports(_: Admin):
raise HTTPException(501, detail="not_implemented_yet")