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.
79 lines
2.9 KiB
Python
79 lines
2.9 KiB
Python
from sqlalchemy import BigInteger, Boolean, Index, Integer, Text, text
|
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
|
|
|
|
class Base(DeclarativeBase):
|
|
pass
|
|
|
|
|
|
_NOW_MS_DEFAULT = text("(EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT")
|
|
|
|
|
|
class EventConfig(Base):
|
|
__tablename__ = "event_config"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default=text("1"))
|
|
couple_names: Mapped[str] = mapped_column(Text, nullable=False)
|
|
event_date: Mapped[str | None] = mapped_column(Text)
|
|
cover_key: Mapped[str | None] = mapped_column(Text)
|
|
gallery_visibility: Mapped[str] = mapped_column(
|
|
Text, nullable=False, server_default=text("'public'")
|
|
)
|
|
moderation: Mapped[str] = mapped_column(
|
|
Text, nullable=False, server_default=text("'post'")
|
|
)
|
|
max_file_mb: Mapped[int] = mapped_column(
|
|
Integer, nullable=False, server_default=text("500")
|
|
)
|
|
allow_video: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, server_default=text("TRUE")
|
|
)
|
|
max_video_seconds: Mapped[int] = mapped_column(
|
|
Integer, nullable=False, server_default=text("300")
|
|
)
|
|
welcome_message: Mapped[str | None] = mapped_column(Text)
|
|
updated_at: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, server_default=_NOW_MS_DEFAULT
|
|
)
|
|
|
|
|
|
class Upload(Base):
|
|
__tablename__ = "uploads"
|
|
__table_args__ = (
|
|
Index("idx_uploads_status_created", "status", "created_at"),
|
|
Index("idx_uploads_source", "source"),
|
|
)
|
|
|
|
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
|
storage_key: Mapped[str] = mapped_column(Text, nullable=False)
|
|
thumbnail_key: Mapped[str | None] = mapped_column(Text)
|
|
mime_type: Mapped[str] = mapped_column(Text, nullable=False)
|
|
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
|
author_name: Mapped[str | None] = mapped_column(Text)
|
|
message: Mapped[str | None] = mapped_column(Text)
|
|
status: Mapped[str] = mapped_column(
|
|
Text, nullable=False, server_default=text("'approved'")
|
|
)
|
|
source: Mapped[str] = mapped_column(
|
|
Text, nullable=False, server_default=text("'guest'")
|
|
)
|
|
ip_hash: Mapped[str | None] = mapped_column(Text)
|
|
provider_upload_id: Mapped[str | None] = mapped_column(Text)
|
|
created_at: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, server_default=_NOW_MS_DEFAULT
|
|
)
|
|
approved_at: Mapped[int | None] = mapped_column(BigInteger)
|
|
|
|
|
|
class AuditLog(Base):
|
|
__tablename__ = "audit_log"
|
|
|
|
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
|
action: Mapped[str] = mapped_column(Text, nullable=False)
|
|
actor: Mapped[str | None] = mapped_column(Text)
|
|
payload: Mapped[str | None] = mapped_column(Text)
|
|
created_at: Mapped[int] = mapped_column(
|
|
BigInteger, nullable=False, server_default=_NOW_MS_DEFAULT
|
|
)
|