Build fixes:
- ui/Dockerfile: npm install instead of npm ci (no lockfile)
- api/pyproject.toml: setuptools.build_meta instead of setuptools.backends.legacy:build
- api/Dockerfile: install curl so the Docker healthcheck doesn't always fail
- docker-compose.yml: add start_period: 30s to API healthcheck
Test fixes:
- pyproject.toml: asyncio_default_fixture_loop_scope/test_loop_scope = session to
prevent asyncpg connections being used across different event loops
- conftest.py: loop_scope="session" on session-scoped engine fixture
- main.py: custom HTTPException handler to flatten dict details to top level
(FastAPI wraps dict details as {"detail": {...}} by default)
- test_upload.py: use env var + cache_clear() to override max_upload_bytes since
monkeypatch can't reach past @lru_cache and already-imported references
- image_repo.py: add reload_with_tags() with populate_existing=True to force
SQLAlchemy to repopulate the identity-map object after tag mutations
- images.py: use reload_with_tags() instead of db.refresh(image, ["image_tags"])
which only loaded ImageTag rows without their .tag sub-relationship, causing
MissingGreenlet on any access to image.tags after attach/replace operations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
94 lines
3.0 KiB
Python
94 lines
3.0 KiB
Python
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 reload_with_tags(self, image_id: uuid.UUID) -> Image:
|
|
result = await self._session.execute(
|
|
select(Image)
|
|
.where(Image.id == image_id)
|
|
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
|
.execution_options(populate_existing=True)
|
|
)
|
|
return result.scalar_one()
|
|
|
|
async def delete(self, image: Image) -> None:
|
|
await self._session.delete(image)
|
|
await self._session.flush()
|