"""Read-only provenance queries. Provenance is its own system, intentionally separate from the tag/ML system (see project_provenance_separation). This service joins ImageProvenance -> Post/Source/Artist and returns plain dicts. It never mutates and never imports tag/ML modules. """ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( Artist, ImageProvenance, ImageRecord, Post, PostAttachment, Source, ) from ..utils.html_sanitize import sanitize_post_html def _post_dict(p: Post) -> dict: return { "id": p.id, "external_post_id": p.external_post_id, "url": p.post_url, "title": p.post_title, "date": p.post_date.isoformat() if p.post_date else None, "description_html": sanitize_post_html(p.description), "attachment_count": p.attachment_count, } def _source_dict(s: Source) -> dict: return {"id": s.id, "platform": s.platform, "url": s.url} def _artist_dict(a: Artist) -> dict: return {"id": a.id, "name": a.name, "slug": a.slug} def _attachment_dict(a: PostAttachment) -> dict: return { "id": a.id, "original_filename": a.original_filename, "size_bytes": a.size_bytes, "ext": a.ext, "download_url": f"/api/attachments/{a.id}/download", } class ProvenanceService: def __init__(self, session: AsyncSession): self.session = session async def _attachments_for_posts(self, post_ids: list[int]) -> list[dict]: if not post_ids: return [] rows = ( await self.session.execute( select(PostAttachment) .where(PostAttachment.post_id.in_(post_ids)) .order_by(PostAttachment.id.asc()) ) ).scalars().all() return [_attachment_dict(a) for a in rows] async def for_image(self, image_id: int) -> dict | None: rec = await self.session.get(ImageRecord, image_id) if rec is None: return None # Artist via Post.artist_id (alembic 0030); Source via LEFT JOIN # since both Post.source_id and ImageProvenance.source_id can be # NULL for filesystem-imported content. Frontend renders source= # null as "filesystem import." stmt = ( select(ImageProvenance, Post, Source, Artist) .join(Post, Post.id == ImageProvenance.post_id) .join(Artist, Artist.id == Post.artist_id) .outerjoin(Source, Source.id == ImageProvenance.source_id) .where(ImageProvenance.image_record_id == image_id) .order_by(ImageProvenance.captured_at.asc(), ImageProvenance.id.asc()) ) rows = (await self.session.execute(stmt)).all() post_ids = [ip.post_id for ip, _p, _s, _a in rows] attachments = await self._attachments_for_posts(post_ids) return { "image_id": image_id, "provenance": [ { "provenance_id": ip.id, "captured_at": ip.captured_at.isoformat() if ip.captured_at else None, "post": _post_dict(post), "source": _source_dict(src) if src is not None else None, "artist": _artist_dict(art), } for ip, post, src, art in rows ], "attachments": attachments, } async def for_post(self, post_id: int) -> dict | None: # Same LEFT JOIN to Source — get_post must succeed for a # NULL-source post. stmt = ( select(Post, Source, Artist) .join(Artist, Artist.id == Post.artist_id) .outerjoin(Source, Source.id == Post.source_id) .where(Post.id == post_id) ) row = (await self.session.execute(stmt)).first() if row is None: return None post, src, art = row return { "post": _post_dict(post), "source": _source_dict(src) if src is not None else None, "artist": _artist_dict(art), "attachments": await self._attachments_for_posts([post.id]), }