[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:
0
api/app/repositories/__init__.py
Normal file
0
api/app/repositories/__init__.py
Normal file
84
api/app/repositories/image_repo.py
Normal file
84
api/app/repositories/image_repo.py
Normal 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()
|
||||
102
api/app/repositories/tag_repo.py
Normal file
102
api/app/repositories/tag_repo.py
Normal 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
|
||||
Reference in New Issue
Block a user