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:
2026-06-06 21:46:09 -04:00
parent 049545fcc8
commit 34d30e3134
19 changed files with 697 additions and 1 deletions
+22 -1
View File
@@ -14,9 +14,10 @@ from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.models # noqa: F401 — register all models on Base.metadata
from app.api.deps import get_mailer
from app.api.deps import get_mailer, get_objectstore
from app.core.db import get_session
from app.integrations.mailer.base import Mailer
from app.integrations.objectstore.base import ObjectStore
from app.main import app
from app.models import Base
@@ -35,7 +36,25 @@ class CapturingMailer(Mailer):
self.resets.append((to, link))
class FakeObjectStore(ObjectStore):
def __init__(self) -> None:
self.objects: dict[str, tuple[bytes, str]] = {}
async def ensure_bucket(self) -> None:
pass
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
self.objects[key] = (data, content_type)
async def presigned_get_url(self, *, key: str) -> str:
return f"https://objects.test/{key}"
async def delete_object(self, *, key: str) -> None:
self.objects.pop(key, None)
_mailer = CapturingMailer()
_store = FakeObjectStore()
@pytest.fixture
@@ -61,8 +80,10 @@ async def client():
_mailer.verifications.clear()
_mailer.resets.clear()
_store.objects.clear()
app.dependency_overrides[get_session] = _override_session
app.dependency_overrides[get_mailer] = lambda: _mailer
app.dependency_overrides[get_objectstore] = lambda: _store
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
+45
View File
@@ -0,0 +1,45 @@
"""Media upload/list/delete through the API (object store faked in conftest)."""
from tests.conftest import auth, register
async def _tree(client, email):
h = auth(await register(client, email))
tree_id = (await client.post("/api/v1/trees", json={"name": "M"}, headers=h)).json()["id"]
return h, tree_id
async def test_media_upload_list_delete(client):
h, tree_id = await _tree(client, "media1@example.com")
resp = await client.post(
f"/api/v1/trees/{tree_id}/media",
files={"file": ("scan.txt", b"hello world", "text/plain")},
data={"title": "A scan"},
headers=h,
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["original_filename"] == "scan.txt"
assert body["byte_size"] == 11
assert body["url"].startswith("https://objects.test/")
media_id = body["id"]
listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)
assert listed.status_code == 200
assert len(listed.json()) == 1
resp = await client.delete(f"/api/v1/trees/{tree_id}/media/{media_id}", headers=h)
assert resp.status_code == 204
assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0
async def test_non_member_cannot_upload(client):
h, tree_id = await _tree(client, "media2@example.com")
other = auth(await register(client, "media-intruder@example.com"))
resp = await client.post(
f"/api/v1/trees/{tree_id}/media",
files={"file": ("x.txt", b"x", "text/plain")},
headers=other,
)
assert resp.status_code == 403