Add media (object storage) and the background worker (Phase 1)
Media model + migration; an ObjectStore interface with an S3/MinIO (boto3) implementation behind the service layer. Upload (multipart) stores bytes in object storage + a metadata row (checksum, size, content-type, optional attach to person/event/source); list returns presigned URLs; delete is soft. Editor-gated, privacy-filtered, audited. 24 tests pass (object store faked). Introduces the worker container (same image, 'python -m app.worker'): its first job is the scheduled 30-day soft-delete purge across tables + media object cleanup. Compose gains worker + S3 env on backend/worker; dev override builds the worker too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile, status
|
||||
|
||||
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||
from app.schemas.media import MediaRead
|
||||
from app.services import media_service, tree_service
|
||||
|
||||
router = APIRouter(prefix="/trees", tags=["media"])
|
||||
|
||||
|
||||
def _with_url(media, url: str) -> MediaRead:
|
||||
out = MediaRead.model_validate(media)
|
||||
out.url = url
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_media(
|
||||
tree_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
current: CurrentUser,
|
||||
store: ObjectStoreDep,
|
||||
file: UploadFile = File(...),
|
||||
title: str | None = Form(None),
|
||||
person_id: uuid.UUID | None = Form(None),
|
||||
event_id: uuid.UUID | None = Form(None),
|
||||
source_id: uuid.UUID | None = Form(None),
|
||||
) -> MediaRead:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
data = await file.read()
|
||||
media = await media_service.upload_media(
|
||||
session,
|
||||
store,
|
||||
actor=current,
|
||||
tree=tree,
|
||||
data=data,
|
||||
filename=file.filename or "upload",
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
title=title,
|
||||
person_id=person_id,
|
||||
event_id=event_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
return _with_url(media, await store.presigned_get_url(key=media.storage_key))
|
||||
|
||||
|
||||
@router.get("/{tree_id}/media", response_model=list[MediaRead])
|
||||
async def list_media(
|
||||
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, store: ObjectStoreDep
|
||||
) -> list[MediaRead]:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
items = await media_service.list_media(session, viewer_id=current.id, tree=tree)
|
||||
return [_with_url(m, await store.presigned_get_url(key=m.storage_key)) for m in items]
|
||||
|
||||
|
||||
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_media(
|
||||
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||
) -> None:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
await media_service.delete_media(session, actor=current, tree=tree, media_id=media_id)
|
||||
Reference in New Issue
Block a user