Feat: Add tag browser page at /tags with count-sorted tag list and library deep-link
- Extends GET /api/v1/tags with sort=count_desc and min_count query params - New TagsComponent at /tags (public, no auth guard) shows all tags sorted by image count - Clicking a tag navigates to /?tags=<name> for a pre-filtered library view - LibraryComponent reads ?tags= query param on init to support deep-linking from tag browser - Library header gains a "Browse tags" link to /tags for discoverability - All 15 TDD tasks complete; ruff, ng lint, and ng build clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import String, Integer, BigInteger, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.database import Base
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Image(Base):
|
||||
@@ -24,9 +24,13 @@ class Image(Base):
|
||||
height: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
storage_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
thumbnail_key: Mapped[str | None] = mapped_column(String(70), nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, nullable=False
|
||||
)
|
||||
|
||||
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="image", cascade="all, delete-orphan")
|
||||
image_tags: Mapped[list["ImageTag"]] = relationship(
|
||||
back_populates="image", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@property
|
||||
def tags(self) -> list[str]:
|
||||
@@ -38,7 +42,9 @@ class Tag(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, nullable=False
|
||||
)
|
||||
|
||||
image_tags: Mapped[list["ImageTag"]] = relationship(back_populates="tag")
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -12,15 +11,19 @@ class ImageRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get_by_hash(self, hash_hex: str) -> Optional[Image]:
|
||||
async def get_by_hash(self, hash_hex: str) -> Image | None:
|
||||
result = await self._session.execute(
|
||||
select(Image).where(Image.hash == hash_hex).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
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]:
|
||||
async def get_by_id(self, image_id: uuid.UUID) -> Image | None:
|
||||
result = await self._session.execute(
|
||||
select(Image).where(Image.id == image_id).options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
select(Image)
|
||||
.where(Image.id == image_id)
|
||||
.options(selectinload(Image.image_tags).selectinload(ImageTag.tag))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -57,7 +60,7 @@ class ImageRepository:
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Image], int]:
|
||||
from sqlalchemy import func, and_
|
||||
from sqlalchemy import func
|
||||
|
||||
base_query = select(Image).options(
|
||||
selectinload(Image.image_tags).selectinload(ImageTag.tag)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Image, ImageTag, Tag
|
||||
@@ -76,6 +76,8 @@ class TagRepository:
|
||||
prefix: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
sort: str = "name",
|
||||
min_count: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
count_subq = (
|
||||
select(func.count(ImageTag.image_id))
|
||||
@@ -87,12 +89,16 @@ class TagRepository:
|
||||
query = select(Tag, count_subq.label("image_count"))
|
||||
if prefix:
|
||||
query = query.where(Tag.name.like(f"{prefix}%"))
|
||||
if min_count > 0:
|
||||
query = query.having(count_subq >= min_count)
|
||||
|
||||
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)
|
||||
order = [count_subq.desc(), Tag.name.asc()] if sort == "count_desc" else [Tag.name.asc()]
|
||||
|
||||
paginated = query.order_by(*order).limit(limit).offset(offset)
|
||||
rows = await self._session.execute(paginated)
|
||||
|
||||
items = [
|
||||
|
||||
@@ -12,9 +12,13 @@ async def list_tags(
|
||||
q: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
sort: str = "name",
|
||||
min_count: int = 0,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
limit = min(limit, 200)
|
||||
limit = min(limit, 500)
|
||||
tag_repo = TagRepository(db)
|
||||
items, total = await tag_repo.list_tags(prefix=q, limit=limit, offset=offset)
|
||||
items, total = await tag_repo.list_tags(
|
||||
prefix=q, limit=limit, offset=offset, sort=sort, min_count=min_count
|
||||
)
|
||||
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import os
|
||||
import pytest
|
||||
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
# Provide required settings for the test environment before any app imports resolve them
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-testing-only")
|
||||
os.environ.setdefault("OWNER_USERNAME", "testowner")
|
||||
os.environ.setdefault("OWNER_PASSWORD", "testpassword")
|
||||
|
||||
from app.main import app
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
from app.config import get_settings
|
||||
from app.database import Base
|
||||
from app.dependencies import get_db, get_storage, get_auth
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
from app.dependencies import get_auth, get_db, get_storage
|
||||
from app.main import app
|
||||
|
||||
# Bust the LRU cache so get_settings() picks up the env vars set above
|
||||
get_settings.cache_clear()
|
||||
@@ -48,8 +48,8 @@ async def db_session(engine):
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session):
|
||||
from app.storage.s3_backend import S3StorageBackend
|
||||
from app.auth.noop import NoOpAuthProvider
|
||||
from app.storage.s3_backend import S3StorageBackend
|
||||
|
||||
storage = S3StorageBackend()
|
||||
auth = NoOpAuthProvider()
|
||||
|
||||
@@ -44,7 +44,6 @@ async def test_delete_removes_storage_object(client):
|
||||
)
|
||||
assert upload.status_code in (200, 201)
|
||||
image_id = upload.json()["id"]
|
||||
storage_key = upload.json()["hash"]
|
||||
|
||||
delete_resp = await client.delete(f"/api/v1/images/{image_id}")
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
@@ -3,7 +3,6 @@ US3 regression tests: all read endpoints must remain accessible without a token
|
||||
even after require_auth is applied to write endpoints.
|
||||
"""
|
||||
import io
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ T041 — GET /api/v1/images?tags=cat,funny → only images with both tags
|
||||
T042 — same query excludes images with only one matching tag
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
|
||||
@@ -5,13 +5,17 @@ T057 — PATCH replaces tags, old tags unlinked, new tags upserted
|
||||
T058 — PATCH with invalid tag → 422 invalid_tag
|
||||
T073 — GET /api/v1/tags returns all tags alphabetically with correct image_count
|
||||
T074 — GET /api/v1/tags?q=ca returns only tags prefixed "ca"
|
||||
T001 — GET /api/v1/tags?sort=count_desc returns tags ordered highest-count-first
|
||||
T002 — GET /api/v1/tags?min_count=N excludes tags with image_count < N
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _minimal_png() -> bytes:
|
||||
import struct, zlib
|
||||
import struct
|
||||
import zlib
|
||||
def chunk(name: bytes, data: bytes) -> bytes:
|
||||
c = name + data
|
||||
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
|
||||
@@ -130,3 +134,64 @@ async def test_list_tags_prefix_filter(client):
|
||||
for item in body["items"]:
|
||||
assert item["name"].startswith("cat")
|
||||
assert not any(item["name"] == "dog" for item in body["items"])
|
||||
|
||||
|
||||
def _unique_png(seed: int) -> bytes:
|
||||
"""Generate a 1x1 PNG with a seed-determined pixel so each seed produces a distinct hash."""
|
||||
import struct
|
||||
import zlib
|
||||
def chunk(name: bytes, data: bytes) -> bytes:
|
||||
c = name + data
|
||||
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
|
||||
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
|
||||
r, g, b = (seed * 37) % 256, (seed * 53) % 256, (seed * 71) % 256
|
||||
idat_data = zlib.compress(bytes([0, r, g, b]))
|
||||
return (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", ihdr)
|
||||
+ chunk(b"IDAT", idat_data)
|
||||
+ chunk(b"IEND", b"")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_sort_count_desc(client):
|
||||
# popular-sort-tag appears on 2 images, rare-sort-tag on 1 — verify count_desc ordering
|
||||
for seed in (100, 101):
|
||||
await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": (f"sort-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
|
||||
data={"tags": "popular-sort-tag,rare-sort-tag" if seed == 100 else "popular-sort-tag"},
|
||||
)
|
||||
response = await client.get("/api/v1/tags?sort=count_desc")
|
||||
assert response.status_code == 200
|
||||
items = response.json()["items"]
|
||||
sort_items = [i for i in items if i["name"] in ("popular-sort-tag", "rare-sort-tag")]
|
||||
assert len(sort_items) == 2
|
||||
# popular-sort-tag (count=2) must come before rare-sort-tag (count=1)
|
||||
names = [i["name"] for i in sort_items]
|
||||
assert names.index("popular-sort-tag") < names.index("rare-sort-tag")
|
||||
# Counts must be non-increasing
|
||||
counts = [i["image_count"] for i in items]
|
||||
assert counts == sorted(counts, reverse=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_min_count_excludes_below_threshold(client):
|
||||
# common-min-tag appears on 2 images, uncommon-min-tag on 1
|
||||
for seed in (200, 201):
|
||||
await client.post(
|
||||
"/api/v1/images",
|
||||
files={"file": (f"min-{seed}.png", io.BytesIO(_unique_png(seed)), "image/png")},
|
||||
data={"tags": "common-min-tag,uncommon-min-tag" if seed == 200 else "common-min-tag"},
|
||||
)
|
||||
# min_count=2 should exclude uncommon-min-tag (count=1) but keep common-min-tag (count=2)
|
||||
response = await client.get("/api/v1/tags?min_count=2")
|
||||
assert response.status_code == 200
|
||||
items = response.json()["items"]
|
||||
names = [i["name"] for i in items]
|
||||
assert "common-min-tag" in names
|
||||
assert "uncommon-min-tag" not in names
|
||||
# All returned tags must have image_count >= 2
|
||||
for item in items:
|
||||
assert item["image_count"] >= 2
|
||||
|
||||
@@ -79,6 +79,7 @@ async def test_upload_invalid_mime_type_returns_422(client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_oversized_file_returns_422(client):
|
||||
import os
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
os.environ["MAX_UPLOAD_BYTES"] = "10"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
_BASE_ENV = {
|
||||
@@ -26,6 +24,7 @@ def test_settings_load_from_env(monkeypatch):
|
||||
|
||||
# Import inside test to pick up monkeypatched env
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
importlib.reload(config_module)
|
||||
|
||||
@@ -42,6 +41,7 @@ def test_settings_max_upload_bytes_override(monkeypatch):
|
||||
_apply_env(monkeypatch, {"MAX_UPLOAD_BYTES": "10485760"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
importlib.reload(config_module)
|
||||
|
||||
@@ -53,6 +53,7 @@ def test_settings_jwt_expiry_override(monkeypatch):
|
||||
_apply_env(monkeypatch, {"JWT_EXPIRY_SECONDS": "3600"})
|
||||
|
||||
import importlib
|
||||
|
||||
import app.config as config_module
|
||||
importlib.reload(config_module)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
|
||||
from app.utils import compute_sha256
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import time
|
||||
import pytest
|
||||
import jwt as pyjwt
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.auth.jwt_provider import JWTAuthProvider
|
||||
|
||||
@@ -3,6 +3,7 @@ T037 — tag normalisation: uppercase → lowercase, whitespace stripped
|
||||
T038 — tag validation: rejects names > 64 chars, invalid chars
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.repositories.tag_repo import TagRepository
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from app.validation import validate_mime_type, validate_file_size, MimeTypeError, FileSizeError
|
||||
|
||||
from app.validation import FileSizeError, MimeTypeError, validate_file_size, validate_mime_type
|
||||
|
||||
ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]
|
||||
REJECTED_TYPES = ["application/pdf", "video/mp4", "text/plain", "application/octet-stream"]
|
||||
|
||||
Reference in New Issue
Block a user