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.
144 lines
4.3 KiB
Python
144 lines
4.3 KiB
Python
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)}
|