feat: collections, watchlists, and shareable links (v0.9.0)
Phase 3 completion — Personal Workflow feature set is now complete.
Collections / Watchlists:
- New tables: collections (UUID share_token, slug, public/private) and
collection_bills (unique bill-per-collection constraint)
- Full CRUD API at /api/collections with bill add/remove endpoints
- Public share endpoint /api/collections/share/{token} (no auth)
- /collections list page with inline create form and delete
- /collections/[id] detail page: inline rename, public toggle,
copy-share-link, bill search/add/remove
- CollectionPicker bookmark-icon popover on bill detail pages
- Collections nav link in sidebar (auth-required)
Shareable Brief Links:
- share_token UUID column on bill_briefs (backfilled on migration)
- Unified public share router at /api/share (brief + collection)
- /share/brief/[token] — minimal layout, full AIBriefCard, CTAs
- /share/collection/[token] — minimal layout, bill list, CTA
- Share2 button in BriefPanel header row, "Link copied!" flash
AuthGuard: /collections → AUTH_REQUIRED; /share prefix → NO_SHELL_PATHS
Authored-By: Jack Levy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
319
backend/app/api/collections.py
Normal file
319
backend/app/api/collections.py
Normal 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 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()
|
||||
113
backend/app/api/share.py
Normal file
113
backend/app/api/share.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Public share router — no authentication required.
|
||||
Serves shareable read-only views for briefs and collections.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.bill import Bill, BillDocument
|
||||
from app.models.brief import BillBrief
|
||||
from app.models.collection import Collection, CollectionBill
|
||||
from app.schemas.schemas import (
|
||||
BillSchema,
|
||||
BriefSchema,
|
||||
BriefShareResponse,
|
||||
CollectionDetailSchema,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Brief share ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/brief/{token}", response_model=BriefShareResponse)
|
||||
async def get_shared_brief(
|
||||
token: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(BillBrief)
|
||||
.options(
|
||||
selectinload(BillBrief.bill).selectinload(Bill.sponsor),
|
||||
selectinload(BillBrief.bill).selectinload(Bill.briefs),
|
||||
selectinload(BillBrief.bill).selectinload(Bill.trend_scores),
|
||||
)
|
||||
.where(BillBrief.share_token == token)
|
||||
)
|
||||
brief = result.scalar_one_or_none()
|
||||
if not brief:
|
||||
raise HTTPException(status_code=404, detail="Brief not found")
|
||||
|
||||
bill = brief.bill
|
||||
bill_schema = BillSchema.model_validate(bill)
|
||||
if bill.briefs:
|
||||
bill_schema.latest_brief = bill.briefs[0]
|
||||
if bill.trend_scores:
|
||||
bill_schema.latest_trend = bill.trend_scores[0]
|
||||
|
||||
doc_result = await db.execute(
|
||||
select(BillDocument.bill_id).where(BillDocument.bill_id == bill.bill_id).limit(1)
|
||||
)
|
||||
bill_schema.has_document = doc_result.scalar_one_or_none() is not None
|
||||
|
||||
return BriefShareResponse(
|
||||
brief=BriefSchema.model_validate(brief),
|
||||
bill=bill_schema,
|
||||
)
|
||||
|
||||
|
||||
# ── Collection share ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/collection/{token}", response_model=CollectionDetailSchema)
|
||||
async def get_shared_collection(
|
||||
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 == token)
|
||||
)
|
||||
collection = result.scalar_one_or_none()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth, notifications, notes
|
||||
from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth, notifications, notes, collections, share
|
||||
from app.config import settings as config
|
||||
|
||||
app = FastAPI(
|
||||
@@ -29,3 +29,5 @@ app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||
app.include_router(health.router, prefix="/api/health", tags=["health"])
|
||||
app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"])
|
||||
app.include_router(notes.router, prefix="/api/notes", tags=["notes"])
|
||||
app.include_router(collections.router, prefix="/api/collections", tags=["collections"])
|
||||
app.include_router(share.router, prefix="/api/share", tags=["share"])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from app.models.bill import Bill, BillAction, BillDocument
|
||||
from app.models.brief import BillBrief
|
||||
from app.models.collection import Collection, CollectionBill
|
||||
from app.models.follow import Follow
|
||||
from app.models.member import Member
|
||||
from app.models.member_interest import MemberTrendScore, MemberNewsArticle
|
||||
@@ -17,6 +18,8 @@ __all__ = [
|
||||
"BillDocument",
|
||||
"BillBrief",
|
||||
"BillNote",
|
||||
"Collection",
|
||||
"CollectionBill",
|
||||
"Follow",
|
||||
"Member",
|
||||
"MemberTrendScore",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Index
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
@@ -21,6 +22,7 @@ class BillBrief(Base):
|
||||
llm_provider = Column(String(50))
|
||||
llm_model = Column(String(100))
|
||||
govinfo_url = Column(String, nullable=True)
|
||||
share_token = Column(postgresql.UUID(as_uuid=False), nullable=True, server_default=func.gen_random_uuid())
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
bill = relationship("Bill", back_populates="briefs")
|
||||
|
||||
51
backend/app/models/collection.py
Normal file
51
backend/app/models/collection.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Collection(Base):
|
||||
__tablename__ = "collections"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
slug = Column(String(120), nullable=False)
|
||||
is_public = Column(Boolean, nullable=False, default=False, server_default="false")
|
||||
share_token = Column(UUID(as_uuid=False), nullable=False, server_default=func.gen_random_uuid())
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", back_populates="collections")
|
||||
collection_bills = relationship(
|
||||
"CollectionBill",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="CollectionBill.added_at.desc()",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "slug", name="uq_collections_user_slug"),
|
||||
UniqueConstraint("share_token", name="uq_collections_share_token"),
|
||||
Index("ix_collections_user_id", "user_id"),
|
||||
Index("ix_collections_share_token", "share_token"),
|
||||
)
|
||||
|
||||
|
||||
class CollectionBill(Base):
|
||||
__tablename__ = "collection_bills"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
collection_id = Column(Integer, ForeignKey("collections.id", ondelete="CASCADE"), nullable=False)
|
||||
bill_id = Column(String, ForeignKey("bills.bill_id", ondelete="CASCADE"), nullable=False)
|
||||
added_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
collection = relationship("Collection", back_populates="collection_bills")
|
||||
bill = relationship("Bill")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("collection_id", "bill_id", name="uq_collection_bills_collection_bill"),
|
||||
Index("ix_collection_bills_collection_id", "collection_id"),
|
||||
Index("ix_collection_bills_bill_id", "bill_id"),
|
||||
)
|
||||
@@ -20,3 +20,4 @@ class User(Base):
|
||||
follows = relationship("Follow", back_populates="user", cascade="all, delete-orphan")
|
||||
notification_events = relationship("NotificationEvent", back_populates="user", cascade="all, delete-orphan")
|
||||
bill_notes = relationship("BillNote", back_populates="user", cascade="all, delete-orphan")
|
||||
collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
@@ -140,6 +140,7 @@ class BriefSchema(BaseModel):
|
||||
llm_provider: Optional[str] = None
|
||||
llm_model: Optional[str] = None
|
||||
govinfo_url: Optional[str] = None
|
||||
share_token: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -295,3 +296,42 @@ class SettingsResponse(BaseModel):
|
||||
congress_poll_interval_minutes: int
|
||||
newsapi_enabled: bool
|
||||
pytrends_enabled: bool
|
||||
|
||||
|
||||
# ── Collections ────────────────────────────────────────────────────────────────
|
||||
|
||||
class CollectionCreate(BaseModel):
|
||||
name: str
|
||||
is_public: bool = False
|
||||
|
||||
def validate_name(self) -> str:
|
||||
name = self.name.strip()
|
||||
if not 1 <= len(name) <= 100:
|
||||
raise ValueError("name must be 1–100 characters")
|
||||
return name
|
||||
|
||||
|
||||
class CollectionUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
|
||||
|
||||
class CollectionSchema(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
is_public: bool
|
||||
share_token: str
|
||||
bill_count: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CollectionDetailSchema(CollectionSchema):
|
||||
bills: list[BillSchema]
|
||||
|
||||
|
||||
class BriefShareResponse(BaseModel):
|
||||
brief: BriefSchema
|
||||
bill: BillSchema
|
||||
|
||||
Reference in New Issue
Block a user