feat: PocketVeto v1.0.0 — initial public release

Self-hosted US Congress monitoring platform with AI policy briefs,
bill/member/topic follows, ntfy + RSS + email notifications,
alignment scoring, collections, and draft-letter generator.

Authored by: Jack Levy
This commit is contained in:
Jack Levy
2026-03-15 01:35:01 -04:00
commit 4c86a5b9ca
150 changed files with 19859 additions and 0 deletions

View File

@@ -0,0 +1,319 @@
"""
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 1100 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 1100 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()