diff --git a/backend/app/main.py b/backend/app/main.py index 238e622..ecce005 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ events, finance, health, + legislation, news, pages, senators, @@ -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("/") diff --git a/backend/app/routers/legislation.py b/backend/app/routers/legislation.py new file mode 100644 index 0000000..40ae534 --- /dev/null +++ b/backend/app/routers/legislation.py @@ -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)) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 37c3a63..63d6477 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -21,7 +21,9 @@ CreateLegislationActionDTO, CreateLegislationDTO, LegislationActionDTO, + LegislationDetailDTO, LegislationDTO, + LegislationListDTO, ) from .news import CreateNewsDTO, NewsDTO, UpdateNewsDTO from .senator import ( @@ -64,7 +66,9 @@ "CreateLegislationActionDTO", "CreateLegislationDTO", "LegislationActionDTO", + "LegislationDetailDTO", "LegislationDTO", + "LegislationListDTO", # News "CreateNewsDTO", "NewsDTO", diff --git a/backend/app/schemas/legislation.py b/backend/app/schemas/legislation.py index fe388e1..7b0c6e5 100644 --- a/backend/app/schemas/legislation.py +++ b/backend/app/schemas/legislation.py @@ -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 @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..3e8dc13 --- /dev/null +++ b/backend/tests/conftest.py @@ -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) diff --git a/backend/tests/test_legislation.py b/backend/tests/test_legislation.py new file mode 100644 index 0000000..7d91858 --- /dev/null +++ b/backend/tests/test_legislation.py @@ -0,0 +1,339 @@ +"""Integration tests for GET /api/legislation endpoints (ticket #35).""" + +from datetime import date + +from app.models.Legislation import Legislation +from app.models.LegislationAction import LegislationAction + + +def make_legislation( + session: int = 1, + title: str = "Test Bill", + bill_number: str = "SB-001", + summary: str = "A test summary", + status: str = "Introduced", + type: str = "Bill", + sponsor_name: str = "Jane Doe", + date_introduced: date = date(2025, 1, 1), + date_last_action: date = date(2025, 1, 2), + full_text: str = "Full text here.", +): + return Legislation( + session_number=session, + title=title, + bill_number=bill_number, + summary=summary, + status=status, + type=type, + sponsor_name=sponsor_name, + date_introduced=date_introduced, + date_last_action=date_last_action, + full_text=full_text, + ) + + +# --------------------------------------------------------------------------- +# GET /api/legislation +# --------------------------------------------------------------------------- + + +def test_list_legislation_empty(integration_client): + resp = integration_client.get("/api/legislation") + assert resp.status_code == 200 + data = resp.json() + assert data["items"] == [] + assert data["total"] == 0 + + +def test_list_legislation_defaults_to_current_session(integration_client, db_session): + db_session.add(make_legislation(session=1, title="Old Bill")) + db_session.add(make_legislation(session=2, title="New Bill")) + db_session.commit() + + resp = integration_client.get("/api/legislation") + assert resp.status_code == 200 + titles = [i["title"] for i in resp.json()["items"]] + assert "New Bill" in titles + assert "Old Bill" not in titles + + +def test_list_legislation_filter_by_session(integration_client, db_session): + db_session.add(make_legislation(session=1, title="Old Bill")) + db_session.add(make_legislation(session=2, title="New Bill")) + db_session.commit() + + resp = integration_client.get("/api/legislation?session=1") + titles = [i["title"] for i in resp.json()["items"]] + assert titles == ["Old Bill"] + + +def test_list_legislation_search_by_title(integration_client, db_session): + db_session.add(make_legislation(title="Budget Reform Act")) + db_session.add(make_legislation(title="Campus Safety Bill", bill_number="SB-002")) + db_session.commit() + + resp = integration_client.get("/api/legislation?search=budget") + assert resp.status_code == 200 + items = resp.json()["items"] + assert len(items) == 1 + assert items[0]["title"] == "Budget Reform Act" + assert "actions" not in items[0] + + +def test_list_legislation_search_by_bill_number(integration_client, db_session): + db_session.add(make_legislation(bill_number="SR-099")) + db_session.add(make_legislation(bill_number="SB-002", title="Other")) + db_session.commit() + + resp = integration_client.get("/api/legislation?search=SR-099") + items = resp.json()["items"] + assert len(items) == 1 + assert items[0]["bill_number"] == "SR-099" + + +def test_list_legislation_search_by_full_text(integration_client, db_session): + db_session.add(make_legislation(full_text="Whereas the senate hereby resolves to fund clubs")) + db_session.add( + make_legislation(full_text="Unrelated content", bill_number="SB-002", title="Other") + ) + db_session.commit() + + resp = integration_client.get("/api/legislation?search=fund+clubs") + items = resp.json()["items"] + assert len(items) == 1 + + +def test_list_legislation_search_by_summary(integration_client, db_session): + db_session.add(make_legislation(summary="Addresses parking violations on campus")) + db_session.add(make_legislation(summary="Unrelated topic", bill_number="SB-002", title="Other")) + db_session.commit() + + resp = integration_client.get("/api/legislation?search=parking") + items = resp.json()["items"] + assert len(items) == 1 + + +def test_list_legislation_filter_by_status(integration_client, db_session): + db_session.add(make_legislation(status="Introduced")) + db_session.add(make_legislation(status="Passed", bill_number="SB-002", title="Passed Bill")) + db_session.commit() + + resp = integration_client.get("/api/legislation?status=Passed") + items = resp.json()["items"] + assert len(items) == 1 + assert items[0]["status"] == "Passed" + + +def test_list_legislation_filter_by_type(integration_client, db_session): + db_session.add(make_legislation(type="Bill")) + db_session.add(make_legislation(type="Nomination", bill_number="SB-002", title="Nomination")) + db_session.commit() + + resp = integration_client.get("/api/legislation?type=Nomination") + items = resp.json()["items"] + assert len(items) == 1 + assert items[0]["type"] == "Nomination" + + +def test_list_legislation_filter_by_sponsor(integration_client, db_session): + db_session.add(make_legislation(sponsor_name="Alice Smith")) + db_session.add(make_legislation(sponsor_name="Bob Jones", bill_number="SB-002", title="Other")) + db_session.commit() + + resp = integration_client.get("/api/legislation?sponsor=alice") + items = resp.json()["items"] + assert len(items) == 1 + assert items[0]["sponsor_name"] == "Alice Smith" + + +def test_list_legislation_combines_multiple_filters(integration_client, db_session): + db_session.add( + make_legislation( + session=2, + title="Campus Housing Funding Act", + summary="Allocates new housing funds", + status="Passed", + type="Bill", + sponsor_name="Alice Johnson", + ) + ) + db_session.add( + make_legislation( + session=2, + title="Campus Housing Funding Act", + summary="Allocates new housing funds", + status="Introduced", + type="Bill", + sponsor_name="Alice Johnson", + bill_number="SB-002", + ) + ) + db_session.add( + make_legislation( + session=1, + title="Campus Housing Funding Act", + summary="Allocates new housing funds", + status="Passed", + type="Bill", + sponsor_name="Alice Johnson", + bill_number="SB-003", + ) + ) + db_session.commit() + + resp = integration_client.get( + "/api/legislation?session=2&search=housing&status=Passed&type=Bill&sponsor=alice" + ) + assert resp.status_code == 200 + items = resp.json()["items"] + assert len(items) == 1 + assert items[0]["status"] == "Passed" + assert items[0]["session_number"] == 2 + + +def test_list_legislation_pagination(integration_client, db_session): + for i in range(5): + db_session.add(make_legislation(bill_number=f"SB-{i:03}", title=f"Bill {i}")) + db_session.commit() + + resp = integration_client.get("/api/legislation?page=1&limit=2") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 5 + assert len(data["items"]) == 2 + assert data["page"] == 1 + assert data["limit"] == 2 + + resp2 = integration_client.get("/api/legislation?page=3&limit=2") + assert len(resp2.json()["items"]) == 1 + + +# --------------------------------------------------------------------------- +# GET /api/legislation/{id} +# --------------------------------------------------------------------------- + + +def test_get_legislation_detail(integration_client, db_session): + leg = make_legislation(title="Detail Bill") + db_session.add(leg) + db_session.flush() + + db_session.add( + LegislationAction( + legislation_id=leg.id, + action_date=date(2025, 2, 1), + description="Referred to committee", + action_type="Referral", + display_order=1, + ) + ) + db_session.add( + LegislationAction( + legislation_id=leg.id, + action_date=date(2025, 3, 1), + description="Committee vote", + action_type="Vote", + display_order=2, + ) + ) + db_session.commit() + + resp = integration_client.get(f"/api/legislation/{leg.id}") + assert resp.status_code == 200 + data = resp.json() + assert data["title"] == "Detail Bill" + assert len(data["actions"]) == 2 + assert data["actions"][0]["action_type"] == "Referral" + assert data["actions"][1]["action_type"] == "Vote" + + +def test_get_legislation_actions_ordered_by_display_order(integration_client, db_session): + leg = make_legislation() + db_session.add(leg) + db_session.flush() + + # Insert in reverse display order to confirm sorting + db_session.add( + LegislationAction( + legislation_id=leg.id, + action_date=date(2025, 1, 1), + description="Second", + action_type="Vote", + display_order=2, + ) + ) + db_session.add( + LegislationAction( + legislation_id=leg.id, + action_date=date(2025, 1, 1), + description="First", + action_type="Referral", + display_order=1, + ) + ) + db_session.commit() + + resp = integration_client.get(f"/api/legislation/{leg.id}") + actions = resp.json()["actions"] + assert actions[0]["description"] == "First" + assert actions[1]["description"] == "Second" + + +def test_get_legislation_not_found(integration_client): + resp = integration_client.get("/api/legislation/99999") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# GET /api/legislation/recent +# --------------------------------------------------------------------------- + + +def test_get_recent_legislation_default_limit(integration_client, db_session): + for i in range(15): + db_session.add( + make_legislation( + bill_number=f"SB-{i:03}", + title=f"Bill {i}", + date_introduced=date(2025, 1, i + 1), + ) + ) + db_session.commit() + + resp = integration_client.get("/api/legislation/recent") + assert resp.status_code == 200 + assert len(resp.json()) == 10 + assert "actions" not in resp.json()[0] + + +def test_get_recent_legislation_custom_limit(integration_client, db_session): + for i in range(5): + db_session.add(make_legislation(bill_number=f"SB-{i:03}", title=f"Bill {i}")) + db_session.commit() + + resp = integration_client.get("/api/legislation/recent?limit=3") + assert len(resp.json()) == 3 + + +def test_get_recent_legislation_filter_by_type(integration_client, db_session): + db_session.add(make_legislation(type="Bill", title="A Bill")) + db_session.add(make_legislation(type="Nomination", title="A Nomination", bill_number="SB-002")) + db_session.commit() + + resp = integration_client.get("/api/legislation/recent?type=Nomination") + items = resp.json() + assert len(items) == 1 + assert items[0]["type"] == "Nomination" + + +def test_get_recent_legislation_ordered_most_recent_first(integration_client, db_session): + db_session.add(make_legislation(title="Older", date_introduced=date(2025, 1, 1))) + db_session.add( + make_legislation(title="Newer", bill_number="SB-002", date_introduced=date(2025, 6, 1)) + ) + db_session.commit() + + resp = integration_client.get("/api/legislation/recent") + items = resp.json() + assert items[0]["title"] == "Newer" + assert items[1]["title"] == "Older"