feat(fc2c-i): tag directory service and /api/tags/directory
This commit is contained in:
@@ -4,6 +4,7 @@ from quart import Blueprint, jsonify, request
|
|||||||
|
|
||||||
from ..extensions import get_session
|
from ..extensions import get_session
|
||||||
from ..models import TagKind
|
from ..models import TagKind
|
||||||
|
from ..services.tag_directory_service import TagDirectoryService
|
||||||
from ..services.tag_service import TagService, TagValidationError
|
from ..services.tag_service import TagService, TagValidationError
|
||||||
|
|
||||||
tags_bp = Blueprint("tags", __name__, url_prefix="/api")
|
tags_bp = Blueprint("tags", __name__, url_prefix="/api")
|
||||||
@@ -46,6 +47,24 @@ async def autocomplete():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tags_bp.route("/tags/directory", methods=["GET"])
|
||||||
|
async def directory():
|
||||||
|
kind = request.args.get("kind") or None
|
||||||
|
q = request.args.get("q") or None
|
||||||
|
cursor = request.args.get("cursor") or None
|
||||||
|
try:
|
||||||
|
limit = int(request.args.get("limit", "60"))
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "limit must be an integer"}), 400
|
||||||
|
async with get_session() as session:
|
||||||
|
svc = TagDirectoryService(session)
|
||||||
|
try:
|
||||||
|
page = await svc.list_tags(kind=kind, q=q, cursor=cursor, limit=limit)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
return jsonify({"cards": page.cards, "next_cursor": page.next_cursor})
|
||||||
|
|
||||||
|
|
||||||
@tags_bp.route("/tags", methods=["POST"])
|
@tags_bp.route("/tags", methods=["POST"])
|
||||||
async def create_tag():
|
async def create_tag():
|
||||||
body = await request.get_json()
|
body = await request.get_json()
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Paged tag-directory cards with window-function preview thumbnails.
|
||||||
|
|
||||||
|
Cursor orders by (Tag.name ASC, Tag.id ASC). Preview thumbnails for the
|
||||||
|
page's tags are fetched in ONE windowed query (ROW_NUMBER <= 3) to avoid
|
||||||
|
an N+1. fandom_name is resolved via a self-join on Tag (fandom_id -> Tag).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
from ..models import ImageRecord, Tag
|
||||||
|
from ..models.tag import image_tag
|
||||||
|
from .gallery_service import thumbnail_url
|
||||||
|
|
||||||
|
_SEP = "|"
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(name: str, tag_id: int) -> str:
|
||||||
|
return base64.urlsafe_b64encode(f"{name}{_SEP}{tag_id}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(cursor: str) -> tuple[str, int]:
|
||||||
|
try:
|
||||||
|
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
|
||||||
|
name, tid = raw.rsplit(_SEP, 1)
|
||||||
|
return name, int(tid)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"invalid cursor: {cursor!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DirectoryPage:
|
||||||
|
cards: list[dict]
|
||||||
|
next_cursor: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class TagDirectoryService:
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def list_tags(
|
||||||
|
self,
|
||||||
|
kind: str | None,
|
||||||
|
q: str | None,
|
||||||
|
cursor: str | None,
|
||||||
|
limit: int = 60,
|
||||||
|
) -> DirectoryPage:
|
||||||
|
if limit < 1 or limit > 200:
|
||||||
|
raise ValueError("limit must be between 1 and 200")
|
||||||
|
|
||||||
|
fandom = aliased(Tag)
|
||||||
|
count_col = func.count(image_tag.c.image_record_id).label("image_count")
|
||||||
|
stmt = (
|
||||||
|
select(Tag, fandom.name.label("fandom_name"), count_col)
|
||||||
|
.outerjoin(fandom, Tag.fandom_id == fandom.id)
|
||||||
|
.outerjoin(image_tag, image_tag.c.tag_id == Tag.id)
|
||||||
|
.group_by(Tag.id, fandom.name)
|
||||||
|
)
|
||||||
|
if kind is not None:
|
||||||
|
stmt = stmt.where(Tag.kind == kind)
|
||||||
|
if q:
|
||||||
|
stmt = stmt.where(Tag.name.ilike(f"%{q}%"))
|
||||||
|
if cursor:
|
||||||
|
c_name, c_id = _decode(cursor)
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Tag.name > c_name,
|
||||||
|
and_(Tag.name == c_name, Tag.id > c_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(Tag.name.asc(), Tag.id.asc()).limit(limit + 1)
|
||||||
|
rows = (await self.session.execute(stmt)).all()
|
||||||
|
|
||||||
|
next_cursor = None
|
||||||
|
if len(rows) > limit:
|
||||||
|
last_tag = rows[limit - 1][0]
|
||||||
|
next_cursor = _encode(last_tag.name, last_tag.id)
|
||||||
|
rows = rows[:limit]
|
||||||
|
|
||||||
|
tag_ids = [r[0].id for r in rows]
|
||||||
|
previews = await self._previews(tag_ids)
|
||||||
|
|
||||||
|
cards = [
|
||||||
|
{
|
||||||
|
"id": tag.id,
|
||||||
|
"name": tag.name,
|
||||||
|
"kind": tag.kind.value if hasattr(tag.kind, "value") else tag.kind,
|
||||||
|
"fandom_id": tag.fandom_id,
|
||||||
|
"fandom_name": fandom_name,
|
||||||
|
"image_count": int(image_count),
|
||||||
|
"preview_thumbnails": previews.get(tag.id, []),
|
||||||
|
}
|
||||||
|
for tag, fandom_name, image_count in rows
|
||||||
|
]
|
||||||
|
return DirectoryPage(cards=cards, next_cursor=next_cursor)
|
||||||
|
|
||||||
|
async def _previews(self, tag_ids: list[int]) -> dict[int, list[str]]:
|
||||||
|
if not tag_ids:
|
||||||
|
return {}
|
||||||
|
rn = func.row_number().over(
|
||||||
|
partition_by=image_tag.c.tag_id,
|
||||||
|
order_by=image_tag.c.image_record_id.desc(),
|
||||||
|
).label("rn")
|
||||||
|
sub = (
|
||||||
|
select(
|
||||||
|
image_tag.c.tag_id.label("tag_id"),
|
||||||
|
image_tag.c.image_record_id.label("image_record_id"),
|
||||||
|
rn,
|
||||||
|
)
|
||||||
|
.where(image_tag.c.tag_id.in_(tag_ids))
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(sub.c.tag_id, ImageRecord.sha256, ImageRecord.mime)
|
||||||
|
.join(ImageRecord, ImageRecord.id == sub.c.image_record_id)
|
||||||
|
.where(sub.c.rn <= 3)
|
||||||
|
.order_by(sub.c.tag_id, sub.c.rn)
|
||||||
|
)
|
||||||
|
out: dict[int, list[str]] = {}
|
||||||
|
for tag_id, sha, mime in (await self.session.execute(stmt)).all():
|
||||||
|
out.setdefault(tag_id, []).append(thumbnail_url(sha, mime))
|
||||||
|
return out
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app import create_app
|
||||||
|
from backend.app.models import Tag, TagKind
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
app = create_app()
|
||||||
|
async with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_directory_endpoint(client, db):
|
||||||
|
db.add(Tag(name="api_tag", kind=TagKind.general))
|
||||||
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
|
resp = await client.get("/api/tags/directory?limit=10")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert "cards" in body and "next_cursor" in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_directory_bad_limit(client):
|
||||||
|
resp = await client.get("/api/tags/directory?limit=nan")
|
||||||
|
assert resp.status_code == 400
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app.models import ImageRecord, Tag, TagKind
|
||||||
|
from backend.app.models.tag import image_tag
|
||||||
|
from backend.app.services.tag_directory_service import TagDirectoryService
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
async def _img(db, i):
|
||||||
|
r = ImageRecord(
|
||||||
|
path=f"/images/td/{i}.jpg", sha256=f"d{i:063d}",
|
||||||
|
size_bytes=1, mime="image/jpeg", width=1, height=1,
|
||||||
|
origin="imported_filesystem", integrity_status="unknown",
|
||||||
|
)
|
||||||
|
db.add(r)
|
||||||
|
await db.flush()
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_directory_lists_tags_with_counts_and_previews(db):
|
||||||
|
t = Tag(name="sunset", kind=TagKind.general)
|
||||||
|
db.add(t)
|
||||||
|
await db.flush()
|
||||||
|
for i in range(4):
|
||||||
|
img = await _img(db, i)
|
||||||
|
await db.execute(image_tag.insert().values(
|
||||||
|
image_record_id=img.id, tag_id=t.id, source="manual"))
|
||||||
|
await db.flush()
|
||||||
|
svc = TagDirectoryService(db)
|
||||||
|
page = await svc.list_tags(kind=None, q=None, cursor=None, limit=10)
|
||||||
|
card = next(c for c in page.cards if c["name"] == "sunset")
|
||||||
|
assert card["image_count"] == 4
|
||||||
|
assert len(card["preview_thumbnails"]) == 3
|
||||||
|
assert card["preview_thumbnails"][0].startswith("/images/thumbs/")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_directory_kind_filter_and_search(db):
|
||||||
|
db.add_all([
|
||||||
|
Tag(name="artist_a", kind=TagKind.artist),
|
||||||
|
Tag(name="char_b", kind=TagKind.character),
|
||||||
|
Tag(name="char_zoom", kind=TagKind.character),
|
||||||
|
])
|
||||||
|
await db.flush()
|
||||||
|
svc = TagDirectoryService(db)
|
||||||
|
only_char = await svc.list_tags(kind="character", q=None, cursor=None, limit=10)
|
||||||
|
assert {c["name"] for c in only_char.cards} == {"char_b", "char_zoom"}
|
||||||
|
searched = await svc.list_tags(kind=None, q="zoom", cursor=None, limit=10)
|
||||||
|
assert {c["name"] for c in searched.cards} == {"char_zoom"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_directory_cursor_pagination(db):
|
||||||
|
for i in range(5):
|
||||||
|
db.add(Tag(name=f"t{i:02d}", kind=TagKind.general))
|
||||||
|
await db.flush()
|
||||||
|
svc = TagDirectoryService(db)
|
||||||
|
first = await svc.list_tags(kind=None, q=None, cursor=None, limit=2)
|
||||||
|
assert len(first.cards) == 2
|
||||||
|
assert first.next_cursor is not None
|
||||||
|
second = await svc.list_tags(kind=None, q=None, cursor=first.next_cursor, limit=2)
|
||||||
|
assert len(second.cards) == 2
|
||||||
|
assert {c["id"] for c in first.cards}.isdisjoint({c["id"] for c in second.cards})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_directory_rejects_bad_limit(db):
|
||||||
|
svc = TagDirectoryService(db)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await svc.list_tags(kind=None, q=None, cursor=None, limit=0)
|
||||||
Reference in New Issue
Block a user