Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
events,
finance,
health,
legislation,
news,
pages,
senators,
Expand Down Expand Up @@ -42,6 +43,7 @@
app.include_router(budget.router)
app.include_router(pages.router)
app.include_router(events.router)
app.include_router(legislation.router)


@app.get("/")
Expand Down
123 changes: 123 additions & 0 deletions backend/app/routers/legislation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Legislation public API routes (ticket #35).

GET /api/legislation — paginated list, filterable
GET /api/legislation/recent — most recent N items, optionally filtered by type
GET /api/legislation/{id} — single item with ordered actions list

NOTE: /recent MUST be registered before /{id} to avoid route conflict.
"""

from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_
from sqlalchemy.orm import Session

from app.database import get_db
from app.models.Legislation import Legislation
from app.models.LegislationAction import LegislationAction
from app.schemas.legislation import LegislationActionDTO, LegislationDetailDTO, LegislationListDTO
from app.schemas.pagination import PaginatedResponse
from app.utils.pagination import paginate

router = APIRouter(prefix="/api/legislation", tags=["legislation"])


def _current_session(db: Session) -> int:
result = db.query(func.max(Legislation.session_number)).scalar()
return result or 1


def _legislation_base_dict(leg: Legislation) -> dict:
return {
"id": leg.id,
"title": leg.title,
"bill_number": leg.bill_number,
"session_number": leg.session_number,
"sponsor_name": leg.sponsor_name,
"summary": leg.summary,
"full_text": leg.full_text,
"status": leg.status,
"type": leg.type,
"date_introduced": leg.date_introduced,
"date_last_action": leg.date_last_action,
}


def _legislation_detail_dict(leg: Legislation, db: Session) -> dict:
actions = (
db.query(LegislationAction)
.filter(LegislationAction.legislation_id == leg.id)
.order_by(LegislationAction.display_order)
.all()
)
data = _legislation_base_dict(leg)
data["actions"] = [LegislationActionDTO.model_validate(a, from_attributes=True) for a in actions]
return data


# /recent BEFORE /{id}
@router.get("/recent")
def get_recent_legislation(
limit: int = Query(default=10, ge=1, le=100, description="Max items to return"),
type: Optional[str] = Query(default=None, description="Filter by legislation type"),
db: Session = Depends(get_db),
):
"""Return the most recently introduced legislation, optionally filtered by type."""
query = db.query(Legislation).order_by(Legislation.date_introduced.desc())
if type is not None:
query = query.filter(Legislation.type == type)
items = query.limit(limit).all()
return [LegislationListDTO.model_validate(_legislation_base_dict(leg)) for leg in items]


@router.get("")
def list_legislation(
search: Optional[str] = Query(
default=None, description="Keyword search across title, bill_number, summary, full_text"
),
status: Optional[str] = Query(default=None, description="Filter by status"),
type: Optional[str] = Query(default=None, description="Filter by type"),
session: Optional[int] = Query(default=None, description="Session number; defaults to current"),
sponsor: Optional[str] = Query(default=None, description="Partial match on sponsor name"),
page: int = Query(default=1, ge=1, description="1-based page number"),
limit: int = Query(default=20, ge=1, le=100, description="Items per page"),
db: Session = Depends(get_db),
):
"""Return a paginated, filterable list of legislation for a given session."""
target_session = session if session is not None else _current_session(db)
query = db.query(Legislation).filter(Legislation.session_number == target_session)

if search:
pattern = f"%{search}%"
query = query.filter(
or_(
Legislation.title.ilike(pattern),
Legislation.bill_number.ilike(pattern),
Legislation.summary.ilike(pattern),
Legislation.full_text.ilike(pattern),
)
)

if status:
query = query.filter(Legislation.status == status)

if type:
query = query.filter(Legislation.type == type)

if sponsor:
query = query.filter(Legislation.sponsor_name.ilike(f"%{sponsor}%"))

query = query.order_by(Legislation.date_introduced.desc())
items, total = paginate(query, page=page, limit=limit)
validated = [LegislationListDTO.model_validate(_legislation_base_dict(leg)) for leg in items]
return PaginatedResponse(items=validated, total=total, page=page, limit=limit)


@router.get("/{legislation_id}")
def get_legislation(legislation_id: int, db: Session = Depends(get_db)):
"""Return a single legislation item with its ordered actions list, or 404."""
leg = db.query(Legislation).filter(Legislation.id == legislation_id).first()
if leg is None:
raise HTTPException(status_code=404, detail="Legislation not found")
return LegislationDetailDTO.model_validate(_legislation_detail_dict(leg, db))
4 changes: 4 additions & 0 deletions backend/app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
CreateLegislationActionDTO,
CreateLegislationDTO,
LegislationActionDTO,
LegislationDetailDTO,
LegislationDTO,
LegislationListDTO,
)
from .news import CreateNewsDTO, NewsDTO, UpdateNewsDTO
from .senator import (
Expand Down Expand Up @@ -64,7 +66,9 @@
"CreateLegislationActionDTO",
"CreateLegislationDTO",
"LegislationActionDTO",
"LegislationDetailDTO",
"LegislationDTO",
"LegislationListDTO",
# News
"CreateNewsDTO",
"NewsDTO",
Expand Down
11 changes: 9 additions & 2 deletions backend/app/schemas/legislation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class LegislationActionDTO(BaseModel):
model_config = ConfigDict(from_attributes=True)


class LegislationDTO(BaseModel):
class LegislationListDTO(BaseModel):
id: int
title: str
bill_number: str
Expand All @@ -26,11 +26,18 @@ class LegislationDTO(BaseModel):
type: str
date_introduced: date
date_last_action: date
actions: list[LegislationActionDTO]

model_config = ConfigDict(from_attributes=True)


class LegislationDetailDTO(LegislationListDTO):
actions: list[LegislationActionDTO]


class LegislationDTO(LegislationDetailDTO):
"""Backward-compatible alias for detailed legislation responses."""


class CreateLegislationDTO(BaseModel):
title: str
bill_number: str
Expand Down
41 changes: 41 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Test fixtures for integration tests using an in-memory SQLite database."""

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool

import app.models # noqa: F401 — ensures all models register with Base.metadata
from app.database import Base, get_db
from app.main import app

SQLITE_URL = "sqlite:///:memory:"


@pytest.fixture()
def db_session():
"""Provide a transactional SQLite in-memory session, rolled back after each test."""
engine = create_engine(
SQLITE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
Base.metadata.drop_all(engine)


@pytest.fixture()
def integration_client(db_session):
"""FastAPI test client with the DB dependency overridden to use SQLite."""

def override_get_db():
yield db_session

app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
app.dependency_overrides.pop(get_db, None)
Loading
Loading