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/infra/wedding_photo/apps/api/app/routes/admin.py
Claude 5a19753013
feat: split into 3 docker-compose stacks (main / gitea / wedding_photo)
Repo passa a viver dentro de infra/, com três stacks isoladas que
compartilham uma network Docker externa ('infra-net'):

main/        infra base: postgres + redis + minio + minio-init + pgadmin
             + caddy. Postgres roda em modo multi-banco; o init script
             cria os DBs 'wedding' e 'gitea' com roles dedicadas. MinIO
             tem um bucket inicial criado pelo minio-init com anonymous
             download + CORS. Caddy é o único container expondo 80/443
             e roteia por hostname: gitea.{DOMAIN_BASE} / wedding.* /
             pgadmin.* / minio.* / media.* (rewrite de bucket).

gitea/       gitea + act_runner. Gitea liga no postgres compartilhado e
             usa redis pra cache+sessões. O runner ganha um Dockerfile
             pequeno que adiciona docker CLI por cima do
             gitea/act_runner pra workflows poderem chamar 'docker
             build'. Bootstrap do token de runner documentado no
             .env.example.

wedding_photo/  Só a aplicação: 'wedding_app' (FastAPI + SPA) +
             postgres-backup + media-backup. Os bancos e o MinIO vêm
             da stack main/. A app usa extra_hosts: host-gateway pra
             alcançar media.{DOMAIN_BASE} via Caddy mesmo em dev local
             — assim a assinatura S3 fecha com o host que o browser
             usa pra fazer PUT.

Orquestração:
- Makefile no root: 'make up' sobe tudo na ordem (main -> gitea ->
  wedding_photo). 'make up-{main,gitea,wedding}' pra controle
  granular. 'make logs-*', 'make down', 'make status', 'make pull-*'.
- network.sh cria a 'infra-net' antes de qualquer up; idempotente.
- Cada stack tem seu próprio .env.example. As creds compartilhadas
  (DOMAIN_BASE, MINIO_ROOT_*, WEDDING_DB_*) precisam casar entre
  main/.env e o consumidor (gitea/.env ou wedding_photo/.env).
- .gitignore ignora todas as pastas data/ dos volumes.
2026-06-08 23:34:14 +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")