[Spec Kit] Implementation progress

Implements all 88 tasks for the Reaction Image Board (specs/001-reaction-image-board):

- docker-compose.yml: postgres, minio, minio-init, api, ui services with healthchecks
- api/: FastAPI app with SQLAlchemy 2.x async, Alembic migrations, S3/MinIO storage,
  full integration + unit test suite (pytest + pytest-asyncio)
- ui/: Angular 19 standalone app (Library, Upload, Detail, NotFound components)
- .env.example: all required environment variables
- .gitignore: Python, Node, Docker, IDE, .env patterns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:13:23 +00:00
parent 691f7570fe
commit 8bf6ef443a
74 changed files with 3005 additions and 88 deletions

View File

View File

@@ -0,0 +1,84 @@
import uuid
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Image, ImageTag, Tag
class ImageRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_hash(self, hash_hex: str) -> Optional[Image]:
result = await self._session.execute(
select(Image).where(Image.hash == hash_hex).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
)
return result.scalar_one_or_none()
async def get_by_id(self, image_id: uuid.UUID) -> Optional[Image]:
result = await self._session.execute(
select(Image).where(Image.id == image_id).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
)
return result.scalar_one_or_none()
async def create(
self,
*,
hash_hex: str,
filename: str,
mime_type: str,
size_bytes: int,
width: int,
height: int,
storage_key: str,
) -> Image:
image = Image(
hash=hash_hex,
filename=filename,
mime_type=mime_type,
size_bytes=size_bytes,
width=width,
height=height,
storage_key=storage_key,
)
self._session.add(image)
await self._session.flush()
await self._session.refresh(image, ["image_tags"])
return image
async def list_images(
self,
tag_names: list[str] | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Image], int]:
from sqlalchemy import func, and_
base_query = select(Image).options(
selectinload(Image.image_tags).selectinload(ImageTag.tag)
)
if tag_names:
for tag_name in tag_names:
subq = (
select(ImageTag.image_id)
.join(Tag, ImageTag.tag_id == Tag.id)
.where(Tag.name == tag_name)
.scalar_subquery()
)
base_query = base_query.where(Image.id.in_(subq))
count_query = select(func.count()).select_from(base_query.subquery())
total_result = await self._session.execute(count_query)
total = total_result.scalar_one()
paginated = base_query.order_by(Image.created_at.desc()).limit(limit).offset(offset)
result = await self._session.execute(paginated)
return result.scalars().all(), total
async def delete(self, image: Image) -> None:
await self._session.delete(image)
await self._session.flush()

View File

@@ -0,0 +1,102 @@
import re
import uuid
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Image, ImageTag, Tag
_TAG_PATTERN = re.compile(r"^[a-z0-9_-]{1,64}$")
class TagRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
@staticmethod
def normalise(name: str) -> str:
return name.strip().lower()
@staticmethod
def normalise_and_validate(name: str) -> str:
normalised = name.strip().lower()
if not _TAG_PATTERN.match(normalised):
raise ValueError(
f"Invalid tag '{name}': must match ^[a-z0-9_-]{{1,64}}$ after normalisation"
)
return normalised
async def upsert_by_name(self, name: str) -> Tag:
result = await self._session.execute(select(Tag).where(Tag.name == name))
tag = result.scalar_one_or_none()
if tag is None:
tag = Tag(name=name)
self._session.add(tag)
await self._session.flush()
return tag
async def get_by_image_id(self, image_id: uuid.UUID) -> list[Tag]:
result = await self._session.execute(
select(Tag)
.join(ImageTag, ImageTag.tag_id == Tag.id)
.where(ImageTag.image_id == image_id)
.order_by(Tag.name)
)
return result.scalars().all()
async def attach_tags(self, image: Image, tag_names: list[str]) -> None:
for name in tag_names:
tag = await self.upsert_by_name(name)
existing = await self._session.execute(
select(ImageTag).where(
ImageTag.image_id == image.id, ImageTag.tag_id == tag.id
)
)
if existing.scalar_one_or_none() is None:
self._session.add(ImageTag(image_id=image.id, tag_id=tag.id))
await self._session.flush()
async def replace_tags_on_image(self, image: Image, tag_names: list[str]) -> None:
# Remove all existing associations
existing_links = await self._session.execute(
select(ImageTag).where(ImageTag.image_id == image.id)
)
for link in existing_links.scalars().all():
await self._session.delete(link)
await self._session.flush()
# Add new associations
for name in tag_names:
tag = await self.upsert_by_name(name)
self._session.add(ImageTag(image_id=image.id, tag_id=tag.id))
await self._session.flush()
async def list_tags(
self,
prefix: str | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
count_subq = (
select(func.count(ImageTag.image_id))
.where(ImageTag.tag_id == Tag.id)
.correlate(Tag)
.scalar_subquery()
)
query = select(Tag, count_subq.label("image_count"))
if prefix:
query = query.where(Tag.name.like(f"{prefix}%"))
total_query = select(func.count()).select_from(query.subquery())
total_result = await self._session.execute(total_query)
total = total_result.scalar_one()
paginated = query.order_by(Tag.name).limit(limit).offset(offset)
rows = await self._session.execute(paginated)
items = [
{"id": str(tag.id), "name": tag.name, "image_count": count}
for tag, count in rows.all()
]
return items, total