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/public.py

144 lines
4.3 KiB
Python
Raw Normal View History

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 19:45:20 -03:00
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..db.base import get_db
from ..db.models import EventConfig, Upload
from ..lib.pdf import generate_qr_pdf
from ..lib.qrcode_gen import generate_qr_png, generate_qr_svg
from ..lib.storage import storage
router = APIRouter()
GALLERY_PAGE_SIZE = 24
Db = Annotated[AsyncSession, Depends(get_db)]
@router.get("/event")
async def get_event(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,
"welcomeMessage": cfg.welcome_message,
"galleryVisibility": cfg.gallery_visibility,
"allowVideo": cfg.allow_video,
"maxFileMb": cfg.max_file_mb,
"maxVideoSeconds": cfg.max_video_seconds,
"coverUrl": storage.public_url(cfg.cover_key) if cfg.cover_key else None,
}
@router.get("/gallery")
async def get_gallery(
db: Db,
cursor: str | None = None,
limit: Annotated[int, Query(le=60, ge=1)] = GALLERY_PAGE_SIZE,
):
cfg = (
await db.execute(select(EventConfig).where(EventConfig.id == 1))
).scalar_one_or_none()
if not cfg or cfg.gallery_visibility != "public":
return {"items": [], "nextCursor": None}
cursor_val: int | None = None
if cursor and cursor.isdigit():
cursor_val = int(cursor)
stmt = select(Upload).where(Upload.status == "approved")
if cursor_val is not None:
stmt = stmt.where(Upload.created_at < cursor_val)
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
items = [
{
"id": r.id,
"url": storage.public_url(r.storage_key),
"thumbnailUrl": storage.public_url(r.thumbnail_key)
if r.thumbnail_key
else None,
"mimeType": r.mime_type,
"isVideo": r.mime_type.startswith("video/"),
"authorName": r.author_name,
"message": r.message,
"createdAt": r.created_at,
}
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.get("/qrcode")
async def get_qrcode(
request: Request,
db: Db,
format: str = "png",
url: str | None = None,
):
fmt = format.lower()
if url:
base = url
elif settings.PUBLIC_BASE_URL:
base = settings.PUBLIC_BASE_URL
else:
base = str(request.base_url).rstrip("/")
target = base.rstrip("/") + "/enviar"
if fmt == "svg":
svg = generate_qr_svg(target)
return Response(
content=svg,
media_type="image/svg+xml; charset=utf-8",
headers={"cache-control": "public, max-age=300"},
)
if fmt == "pdf":
cfg = (
await db.execute(select(EventConfig).where(EventConfig.id == 1))
).scalar_one_or_none()
pdf_bytes = generate_qr_pdf(
target,
cfg.couple_names if cfg else settings.COUPLE_NAMES,
cfg.event_date if cfg else settings.EVENT_DATE,
)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"content-disposition": 'inline; filename="qr-mesa.pdf"',
"cache-control": "no-store",
},
)
png = generate_qr_png(target, 800)
return Response(
content=png,
media_type="image/png",
headers={"cache-control": "public, max-age=300"},
)
@router.get("/stats")
async def get_stats(db: Db):
rows = (
await db.execute(
select(Upload.id, Upload.mime_type).where(Upload.status == "approved")
)
).all()
photos = sum(1 for r in rows if r.mime_type.startswith("image/"))
videos = sum(1 for r in rows if r.mime_type.startswith("video/"))
return {"photos": photos, "videos": videos, "total": len(rows)}