"""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, # Translation (#143): the English title/description shown by default when # a translation exists; the UI toggles to the original. Source lang labels # the original. "title_translated": p.post_title_translated, "description_translated": p.description_translated, "translated_source_lang": p.translated_source_lang, } 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 _attachment_by_id(self, attachment_id: int) -> list[dict]: att = await self.session.get(PostAttachment, attachment_id) return [_attachment_dict(att)] if att is not None else [] 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] # Prefer the EXACT archive this file came out of (milestone #87): if the # originating post's provenance row records from_attachment_id, the image # was extracted from that one .zip/.rar, so show only it — not the dozens # of unrelated archives a "High Resolution Files" bundle post carries. from_att_id = next( ( ip.from_attachment_id for ip, _p, _s, _a in rows if ip.post_id == rec.primary_post_id and ip.from_attachment_id is not None ), None, ) if from_att_id is not None: attachments = await self._attachment_by_id(from_att_id) else: # No recorded containing archive (loose download, or pre-backfill): # scope to the originating post only, not every pHash-linked post. # primary_post_id is the post this file was actually captured from; # fall back to all linked posts when it's unset (older rows / # filesystem imports). attach_post_ids = ( [rec.primary_post_id] if rec.primary_post_id is not None else post_ids ) attachments = await self._attachments_for_posts(attach_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]), }