""" Collections API — named, curated groups of bills with share links. """ import re import unicodedata from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.dependencies import get_current_user from app.database import get_db from app.models.bill import Bill, BillDocument from app.models.collection import Collection, CollectionBill from app.models.user import User from app.schemas.schemas import ( BillSchema, CollectionCreate, CollectionDetailSchema, CollectionSchema, CollectionUpdate, ) router = APIRouter() def _slugify(text: str) -> str: text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() text = re.sub(r"[^\w\s-]", "", text.lower()) return re.sub(r"[-\s]+", "-", text).strip("-") async def _unique_slug(db: AsyncSession, user_id: int, name: str, exclude_id: int | None = None) -> str: base = _slugify(name) or "collection" slug = base counter = 2 while True: q = select(Collection).where(Collection.user_id == user_id, Collection.slug == slug) if exclude_id is not None: q = q.where(Collection.id != exclude_id) existing = (await db.execute(q)).scalar_one_or_none() if not existing: return slug slug = f"{base}-{counter}" counter += 1 def _to_schema(collection: Collection) -> CollectionSchema: return CollectionSchema( id=collection.id, name=collection.name, slug=collection.slug, is_public=collection.is_public, share_token=collection.share_token, bill_count=len(collection.collection_bills), created_at=collection.created_at, ) async def _detail_schema(db: AsyncSession, collection: Collection) -> CollectionDetailSchema: """Build CollectionDetailSchema with bills (including has_document).""" cb_list = collection.collection_bills bills = [cb.bill for cb in cb_list] bill_ids = [b.bill_id for b in bills] if bill_ids: doc_result = await db.execute( select(BillDocument.bill_id).where(BillDocument.bill_id.in_(bill_ids)).distinct() ) bills_with_docs = {row[0] for row in doc_result} else: bills_with_docs = set() bill_schemas = [] for bill in bills: bs = BillSchema.model_validate(bill) if bill.briefs: bs.latest_brief = bill.briefs[0] if bill.trend_scores: bs.latest_trend = bill.trend_scores[0] bs.has_document = bill.bill_id in bills_with_docs bill_schemas.append(bs) return CollectionDetailSchema( id=collection.id, name=collection.name, slug=collection.slug, is_public=collection.is_public, share_token=collection.share_token, bill_count=len(cb_list), created_at=collection.created_at, bills=bill_schemas, ) async def _load_collection(db: AsyncSession, collection_id: int) -> Collection: result = await db.execute( select(Collection) .options( selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.briefs), selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.trend_scores), selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.sponsor), ) .where(Collection.id == collection_id) ) return result.scalar_one_or_none() # ── List ────────────────────────────────────────────────────────────────────── @router.get("", response_model=list[CollectionSchema]) async def list_collections( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(Collection) .options(selectinload(Collection.collection_bills)) .where(Collection.user_id == current_user.id) .order_by(Collection.created_at.desc()) ) collections = result.scalars().unique().all() return [_to_schema(c) for c in collections] # ── Create ──────────────────────────────────────────────────────────────────── @router.post("", response_model=CollectionSchema, status_code=201) async def create_collection( body: CollectionCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): name = body.name.strip() if not 1 <= len(name) <= 100: raise HTTPException(status_code=422, detail="name must be 1–100 characters") slug = await _unique_slug(db, current_user.id, name) collection = Collection( user_id=current_user.id, name=name, slug=slug, is_public=body.is_public, ) db.add(collection) await db.flush() await db.execute(select(Collection).where(Collection.id == collection.id)) # ensure loaded await db.commit() await db.refresh(collection) # Load collection_bills for bill_count result = await db.execute( select(Collection) .options(selectinload(Collection.collection_bills)) .where(Collection.id == collection.id) ) collection = result.scalar_one() return _to_schema(collection) # ── Share (public — no auth) ────────────────────────────────────────────────── @router.get("/share/{share_token}", response_model=CollectionDetailSchema) async def get_collection_by_share_token( share_token: str, db: AsyncSession = Depends(get_db), ): result = await db.execute( select(Collection) .options( selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.briefs), selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.trend_scores), selectinload(Collection.collection_bills).selectinload(CollectionBill.bill).selectinload(Bill.sponsor), ) .where(Collection.share_token == share_token) ) collection = result.scalar_one_or_none() if not collection: raise HTTPException(status_code=404, detail="Collection not found") return await _detail_schema(db, collection) # ── Get (owner) ─────────────────────────────────────────────────────────────── @router.get("/{collection_id}", response_model=CollectionDetailSchema) async def get_collection( collection_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): collection = await _load_collection(db, collection_id) if not collection: raise HTTPException(status_code=404, detail="Collection not found") if collection.user_id != current_user.id: raise HTTPException(status_code=403, detail="Access denied") return await _detail_schema(db, collection) # ── Update ──────────────────────────────────────────────────────────────────── @router.patch("/{collection_id}", response_model=CollectionSchema) async def update_collection( collection_id: int, body: CollectionUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(Collection) .options(selectinload(Collection.collection_bills)) .where(Collection.id == collection_id) ) collection = result.scalar_one_or_none() if not collection: raise HTTPException(status_code=404, detail="Collection not found") if collection.user_id != current_user.id: raise HTTPException(status_code=403, detail="Access denied") if body.name is not None: name = body.name.strip() if not 1 <= len(name) <= 100: raise HTTPException(status_code=422, detail="name must be 1–100 characters") collection.name = name collection.slug = await _unique_slug(db, current_user.id, name, exclude_id=collection_id) if body.is_public is not None: collection.is_public = body.is_public await db.commit() await db.refresh(collection) result = await db.execute( select(Collection) .options(selectinload(Collection.collection_bills)) .where(Collection.id == collection_id) ) collection = result.scalar_one() return _to_schema(collection) # ── Delete ──────────────────────────────────────────────────────────────────── @router.delete("/{collection_id}", status_code=204) async def delete_collection( collection_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Collection).where(Collection.id == collection_id)) collection = result.scalar_one_or_none() if not collection: raise HTTPException(status_code=404, detail="Collection not found") if collection.user_id != current_user.id: raise HTTPException(status_code=403, detail="Access denied") await db.delete(collection) await db.commit() # ── Add bill ────────────────────────────────────────────────────────────────── @router.post("/{collection_id}/bills/{bill_id}", status_code=204) async def add_bill_to_collection( collection_id: int, bill_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Collection).where(Collection.id == collection_id)) collection = result.scalar_one_or_none() if not collection: raise HTTPException(status_code=404, detail="Collection not found") if collection.user_id != current_user.id: raise HTTPException(status_code=403, detail="Access denied") bill = await db.get(Bill, bill_id) if not bill: raise HTTPException(status_code=404, detail="Bill not found") existing = await db.execute( select(CollectionBill).where( CollectionBill.collection_id == collection_id, CollectionBill.bill_id == bill_id, ) ) if existing.scalar_one_or_none(): return # idempotent db.add(CollectionBill(collection_id=collection_id, bill_id=bill_id)) await db.commit() # ── Remove bill ─────────────────────────────────────────────────────────────── @router.delete("/{collection_id}/bills/{bill_id}", status_code=204) async def remove_bill_from_collection( collection_id: int, bill_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Collection).where(Collection.id == collection_id)) collection = result.scalar_one_or_none() if not collection: raise HTTPException(status_code=404, detail="Collection not found") if collection.user_id != current_user.id: raise HTTPException(status_code=403, detail="Access denied") cb_result = await db.execute( select(CollectionBill).where( CollectionBill.collection_id == collection_id, CollectionBill.bill_id == bill_id, ) ) cb = cb_result.scalar_one_or_none() if not cb: raise HTTPException(status_code=404, detail="Bill not in collection") await db.delete(cb) await db.commit()