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
20 changes: 19 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.routers import health, news, senators
from app.routers import (
budget,
carousel,
districts,
events,
finance,
health,
news,
pages,
senators,
staff,
)

app = FastAPI(
title="Senate API",
Expand All @@ -24,6 +35,13 @@
app.include_router(health.router)
app.include_router(news.router)
app.include_router(senators.router)
app.include_router(carousel.router)
app.include_router(districts.router)
app.include_router(staff.router)
app.include_router(finance.router)
app.include_router(budget.router)
app.include_router(pages.router)
app.include_router(events.router)


@app.get("/")
Expand Down
73 changes: 73 additions & 0 deletions backend/app/routers/budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Budget public API routes (TDD Section 4.5.2).

GET /api/budget — hierarchical BudgetData; filterable by fiscal_year; defaults to most recent
"""

import re
from typing import Optional

from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session

from app.database import get_db
from app.models.BudgetData import BudgetData
from app.schemas.budget import BudgetDataDTO

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


def _fiscal_year_sort_key(fiscal_year: str) -> tuple[int, str]:
"""Sort fiscal year labels by numeric suffix first, then raw value."""
matches = re.findall(r"\d+", fiscal_year)
numeric_year = int(matches[-1]) if matches else -1
return numeric_year, fiscal_year


def _build_tree(
row_id: int,
rows_by_id: dict[int, BudgetData],
children_map: dict[int, list[BudgetData]],
) -> BudgetDataDTO:
row = rows_by_id[row_id]
children = [
_build_tree(child.id, rows_by_id, children_map) for child in children_map.get(row_id, [])
]
return BudgetDataDTO(
id=row.id,
fiscal_year=row.fiscal_year,
category=row.category,
amount=float(row.amount),
description=row.description,
children=children,
)


@router.get("", response_model=list[BudgetDataDTO])
def list_budget(
fiscal_year: Optional[str] = Query(default=None, description="Fiscal year filter"),
db: Session = Depends(get_db),
):
if fiscal_year is None:
fiscal_years = [row[0] for row in db.query(BudgetData.fiscal_year).distinct().all()]
if not fiscal_years:
return []
fiscal_year = max(fiscal_years, key=_fiscal_year_sort_key)

rows = (
db.query(BudgetData)
.filter(BudgetData.fiscal_year == fiscal_year)
.order_by(BudgetData.display_order)
.all()
)

rows_by_id = {row.id: row for row in rows}
children_map: dict[int, list[BudgetData]] = {row.id: [] for row in rows}
roots: list[BudgetData] = []

for row in rows:
if row.parent_category_id is None:
roots.append(row)
elif row.parent_category_id in children_map:
children_map[row.parent_category_id].append(row)

return [_build_tree(r.id, rows_by_id, children_map) for r in roots]
24 changes: 24 additions & 0 deletions backend/app/routers/carousel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Carousel public API routes (TDD Section 4.5.2).

GET /api/carousel — active slides only, ordered by display_order
"""

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.database import get_db
from app.models.CarouselSlide import CarouselSlide
from app.schemas.carousel import CarouselSlideDTO

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


@router.get("", response_model=list[CarouselSlideDTO])
def list_carousel(db: Session = Depends(get_db)):
slides = (
db.query(CarouselSlide)
.filter(CarouselSlide.is_active.is_(True))
.order_by(CarouselSlide.display_order)
.all()
)
return [CarouselSlideDTO.model_validate(s) for s in slides]
124 changes: 124 additions & 0 deletions backend/app/routers/districts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Districts public API routes (TDD Section 4.5.2).

GET /api/districts — all districts with nested active senators
GET /api/districts/lookup — case-insensitive partial match on DistrictMapping.mapping_value
"""

from typing import Any

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

from app.database import get_db
from app.models.cms import Committee, CommitteeMembership
from app.models.District import District, DistrictMapping
from app.models.Senator import Senator
from app.schemas.district import DistrictDTO

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


def _senator_to_dict(
senator: Senator,
memberships: list[CommitteeMembership],
committees_by_id: dict[int, str],
) -> dict[str, Any]:
return {
"id": senator.id,
"first_name": senator.first_name,
"last_name": senator.last_name,
"email": senator.email,
"headshot_url": senator.headshot_url,
"district_id": senator.district,
"is_active": senator.is_active,
"session_number": senator.session_number,
"committees": [
{
"committee_id": m.committee_id,
"committee_name": committees_by_id.get(m.committee_id, ""),
"role": m.role,
}
for m in memberships
],
}


def _districts_to_dto(districts: list[District], db: Session) -> list[DistrictDTO]:
if not districts:
return []

district_ids = [district.id for district in districts]
senators = (
db.query(Senator)
.filter(Senator.district.in_(district_ids), Senator.is_active.is_(True))
.all()
)

senators_by_district: dict[int, list[Senator]] = {district.id: [] for district in districts}
for senator in senators:
senators_by_district.setdefault(senator.district, []).append(senator)

senator_ids = [senator.id for senator in senators]
memberships: list[CommitteeMembership] = []
if senator_ids:
memberships = (
db.query(CommitteeMembership)
.filter(CommitteeMembership.senator_id.in_(senator_ids))
.all()
)

memberships_by_senator: dict[int, list[CommitteeMembership]] = {senator_id: [] for senator_id in senator_ids}
for membership in memberships:
memberships_by_senator.setdefault(membership.senator_id, []).append(membership)

committee_ids = list({membership.committee_id for membership in memberships})
committees_by_id: dict[int, str] = {}
if committee_ids:
committees_by_id = {
committee.id: committee.name
for committee in db.query(Committee).filter(Committee.id.in_(committee_ids)).all()
}

return [
DistrictDTO.model_validate(
{
"id": district.id,
"district_name": district.district_name,
"description": district.description,
"senator": [
_senator_to_dict(
senator,
memberships_by_senator.get(senator.id, []),
committees_by_id,
)
for senator in senators_by_district.get(district.id, [])
],
}
)
for district in districts
]


@router.get("/lookup", response_model=list[DistrictDTO])
def lookup_district(
query: str = Query(..., description="Case-insensitive partial match on mapping value"),
db: Session = Depends(get_db),
):
pattern = f"%{query.lower()}%"
mappings = (
db.query(DistrictMapping)
.filter(func.lower(DistrictMapping.mapping_value).like(pattern))
.all()
)
district_ids = list({m.district_id for m in mappings})
if not district_ids:
return []
districts = db.query(District).filter(District.id.in_(district_ids)).all()
return _districts_to_dto(districts, db)


@router.get("", response_model=list[DistrictDTO])
def list_districts(db: Session = Depends(get_db)):
districts = db.query(District).all()
return _districts_to_dto(districts, db)
36 changes: 36 additions & 0 deletions backend/app/routers/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Calendar events public API routes (TDD Section 4.5.2).

GET /api/events — published events only; filterable by start_date, end_date, event_type
"""

from datetime import date, timedelta
from typing import Optional

from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session

from app.database import get_db
from app.models.CalendarEvent import CalendarEvent
from app.schemas.calendar_event import CalendarEventDTO

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


@router.get("", response_model=list[CalendarEventDTO])
def list_events(
start_date: Optional[date] = Query(default=None, description="Include events on or after this date"),
end_date: Optional[date] = Query(default=None, description="Include events on or before this date"),
event_type: Optional[str] = Query(default=None, description="Filter by event type"),
db: Session = Depends(get_db),
):
query = db.query(CalendarEvent).filter(CalendarEvent.is_published.is_(True))

if start_date is not None:
query = query.filter(CalendarEvent.start_datetime >= start_date)
if end_date is not None:
query = query.filter(CalendarEvent.start_datetime < end_date + timedelta(days=1))
if event_type is not None:
query = query.filter(CalendarEvent.event_type == event_type)

events = query.order_by(CalendarEvent.start_datetime).all()
return [CalendarEventDTO.model_validate(e) for e in events]
34 changes: 34 additions & 0 deletions backend/app/routers/finance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Finance hearings public API routes (TDD Section 4.5.2).

GET /api/finance-hearings — FinanceHearingConfig with nested dates when active
"""

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from app.database import get_db
from app.models.FinanceHearingConfig import FinanceHearingConfig
from app.models.FinanceHearingDate import FinanceHearingDate
from app.schemas.finance import FinanceHearingConfigDTO, FinanceHearingDateDTO

router = APIRouter(prefix="/api/finance-hearings", tags=["finance"])


@router.get("", response_model=FinanceHearingConfigDTO)
def get_finance_hearings(db: Session = Depends(get_db)):
config = db.query(FinanceHearingConfig).first()
if config is None:
raise HTTPException(status_code=404, detail="Finance hearing configuration not found")

dates: list[FinanceHearingDateDTO] = []
if config.is_active:
dates = [
FinanceHearingDateDTO.model_validate(d) for d in db.query(FinanceHearingDate).all()
]

return FinanceHearingConfigDTO(
is_active=config.is_active,
season_start=config.season_start,
season_end=config.season_end,
dates=dates,
)
21 changes: 21 additions & 0 deletions backend/app/routers/pages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Static pages public API routes (TDD Section 4.5.2).

GET /api/pages/{slug} — static page content by slug, 404 if not found
"""

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from app.database import get_db
from app.models.cms import StaticPageContent
from app.schemas.static_page import StaticPageDTO

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


@router.get("/{slug}", response_model=StaticPageDTO)
def get_page(slug: str, db: Session = Depends(get_db)):
page = db.query(StaticPageContent).filter(StaticPageContent.page_slug == slug).first()
if page is None:
raise HTTPException(status_code=404, detail="Page not found")
return StaticPageDTO.model_validate(page)
19 changes: 19 additions & 0 deletions backend/app/routers/staff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Staff public API routes (TDD Section 4.5.2).

GET /api/staff — active staff ordered by display_order
"""

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.database import get_db
from app.models.cms import Staff
from app.schemas.staff import StaffDTO

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


@router.get("", response_model=list[StaffDTO])
def list_staff(db: Session = Depends(get_db)):
staff = db.query(Staff).filter(Staff.is_active.is_(True)).order_by(Staff.display_order).all()
return [StaffDTO.model_validate(s) for s in staff]
3 changes: 2 additions & 1 deletion backend/app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from .account import AccountDTO, CreateAccountDTO
from .budget import BudgetDataDTO, CreateBudgetDataDTO
from .calendar_event import CreateCalendarEventDTO
from .calendar_event import CalendarEventDTO, CreateCalendarEventDTO
from .carousel import CarouselSlideDTO, CreateCarouselSlideDTO
from .committee import (
AssignCommitteeMemberDTO,
Expand Down Expand Up @@ -41,6 +41,7 @@
"BudgetDataDTO",
"CreateBudgetDataDTO",
# Calendar Event
"CalendarEventDTO",
"CreateCalendarEventDTO",
# Carousel
"CarouselSlideDTO",
Expand Down
Loading
Loading