diff --git a/backend/app/main.py b/backend/app/main.py index ecce005..79dbf7c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,10 +6,12 @@ from app.routers import ( budget, carousel, + committees, districts, events, finance, health, + leadership, legislation, news, pages, @@ -36,6 +38,8 @@ app.include_router(health.router) app.include_router(news.router) app.include_router(senators.router) +app.include_router(leadership.router) +app.include_router(committees.router) app.include_router(carousel.router) app.include_router(districts.router) app.include_router(staff.router) diff --git a/backend/app/routers/committees.py b/backend/app/routers/committees.py new file mode 100644 index 0000000..7793595 --- /dev/null +++ b/backend/app/routers/committees.py @@ -0,0 +1,104 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, selectinload + +from app.database import get_db +from app.models import Committee, CommitteeMembership +from app.schemas import CommitteeDTO + +router = APIRouter(prefix="/api/committees", tags=["committees"]) + +@router.get("/", response_model=list[CommitteeDTO]) +def get_committees(db: Session = Depends(get_db)): + committees = ( + db.query(Committee) + .options( + selectinload(Committee.memberships) + .selectinload(CommitteeMembership.senator) + ) + .filter(Committee.is_active) + .order_by(Committee.name) + .all() + ) + + result = [] + for committee in committees: + members = [] + for membership in committee.memberships: + senator = membership.senator + committees_list = [ + { + "committee_id": membership.committee.id, + "committee_name": membership.committee.name, + "role": membership.role + } + ] + members.append({ + "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": committees_list + }) + result.append({ + "id": committee.id, + "name": committee.name, + "description": committee.description, + "chair_name": committee.chair_name, + "chair_email": committee.chair_email, + "is_active": committee.is_active, + "members": members + }) + + return result + +@router.get("/{id}", response_model=CommitteeDTO) +def get_committee(id: int, db: Session = Depends(get_db)): + committee = ( + db.query(Committee) + .options( + selectinload(Committee.memberships) + .selectinload(CommitteeMembership.senator) + ) + .filter(Committee.id == id) + .first() + ) + + if not committee: + raise HTTPException(status_code=404, detail="Committee not found") + + members = [] + + for membership in committee.memberships: + senator = membership.senator + + members.append({ + "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": membership.committee.id, + "committee_name": membership.committee.name, + "role": membership.role + } + ] + }) + + return { + "id": committee.id, + "name": committee.name, + "description": committee.description, + "chair_name": committee.chair_name, + "chair_email": committee.chair_email, + "members": members, + "is_active": committee.is_active + } diff --git a/backend/app/routers/leadership.py b/backend/app/routers/leadership.py new file mode 100644 index 0000000..73b658c --- /dev/null +++ b/backend/app/routers/leadership.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import Leadership +from app.schemas import LeadershipDTO + +router = APIRouter(prefix="/api/leadership", tags=["leadership"]) + + +def _current_session(db: Session) -> int: + """Return the highest session_number in the leadership table.""" + result = db.query(func.max(Leadership.session_number)).scalar() + return result or 1 + +@router.get("/", response_model=list[LeadershipDTO]) +def get_leadership(session_number: int | None = None, db: Session = Depends(get_db)): + + target_session = session_number if session_number is not None else _current_session(db) + query = db.query(Leadership).filter(Leadership.session_number == target_session) + + leadership = query.order_by(Leadership.title).all() + + # dynamically add is_current based on is_active + for leader in leadership: + leader.is_current = leader.is_active + leader.photo_url = leader.headshot_url + + return leadership + + +@router.get("/{id}", response_model=LeadershipDTO) +def get_leadership_by_id(id: int, db: Session = Depends(get_db)): + + leadership = ( + db.query(Leadership) + .filter(Leadership.id == id) + .first() + ) + + if leadership is None: + raise HTTPException(status_code=404, detail="Leadership record not found") + + leadership.is_current = leadership.is_active + leadership.photo_url = leadership.headshot_url + + return leadership diff --git a/backend/app/routers/news.py b/backend/app/routers/news.py index 39169c4..a14a742 100644 --- a/backend/app/routers/news.py +++ b/backend/app/routers/news.py @@ -29,9 +29,9 @@ def _news_to_dict(news: News) -> dict[str, Any]: - """Convert a News ORM row to a dict compatible with PR #37's NewsDTO. + """Convert a News ORM row to a dict compatible with NewsDTO. - PR #37's NewsDTO expects a field called ``admin`` (not ``author``) so we + NewsDTO expects a field called ``admin`` (not ``author``) so we remap the relationship here rather than in the model. """ return { @@ -42,9 +42,7 @@ def _news_to_dict(news: News) -> dict[str, Any]: "image_url": news.image_url, "date_published": news.date_published, "date_last_edited": news.date_last_edited, - # PR #37's NewsDTO has a computed ``author_name`` field; provide it here - # so model_validate() can pick it up once the schema is available. - "author_name": f"{news.author.first_name} {news.author.last_name}" if news.author else "Unknown", + "admin": news.author, } diff --git a/backend/app/schemas/AccountDTO.py b/backend/app/schemas/AccountDTO.py new file mode 100644 index 0000000..51efe33 --- /dev/null +++ b/backend/app/schemas/AccountDTO.py @@ -0,0 +1,15 @@ +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class AccountDTO(BaseModel): + id: int + email: str + pid: str + first_name: str + last_name: str + role: Literal["admin", "staff"] + + model_config = ConfigDict(from_attributes=True) + diff --git a/backend/app/schemas/BudgetDataDTO.py b/backend/app/schemas/BudgetDataDTO.py new file mode 100644 index 0000000..ed69796 --- /dev/null +++ b/backend/app/schemas/BudgetDataDTO.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict + + +class BudgetDataDTO(BaseModel): + id: int + fiscal_year: str + category: str + amount: float + description: Optional[str] = None + children: List["BudgetDataDTO"] = [] + + model_config = ConfigDict(from_attributes=True) + + +BudgetDataDTO.model_rebuild() diff --git a/backend/app/schemas/CalendarEventDTO.py b/backend/app/schemas/CalendarEventDTO.py new file mode 100644 index 0000000..4a215be --- /dev/null +++ b/backend/app/schemas/CalendarEventDTO.py @@ -0,0 +1,16 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class CalendarEventDTO(BaseModel): + id: int + title: str + description: str + start_datetime: datetime + end_datetime: datetime + location: Optional[str] = None + event_type: str + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/CarouselSlideDTO.py b/backend/app/schemas/CarouselSlideDTO.py new file mode 100644 index 0000000..9b6f722 --- /dev/null +++ b/backend/app/schemas/CarouselSlideDTO.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, ConfigDict + + +class CarouselSlideDTO(BaseModel): + id: int + image_url: str + overlay_text: str + link_url: str + display_order: int + is_active: bool + + model_config = ConfigDict(from_attributes=True) + diff --git a/backend/app/schemas/CommitteeAssignmentDTO.py b/backend/app/schemas/CommitteeAssignmentDTO.py new file mode 100644 index 0000000..b72f180 --- /dev/null +++ b/backend/app/schemas/CommitteeAssignmentDTO.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class CommitteeAssignmentDTO(BaseModel): + committee_id: int + committee_name: str + role: str + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/CommitteeDTO.py b/backend/app/schemas/CommitteeDTO.py new file mode 100644 index 0000000..1ca6f13 --- /dev/null +++ b/backend/app/schemas/CommitteeDTO.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import List + +from pydantic import BaseModel, ConfigDict + +from .SenatorDTO import SenatorDTO + + +class CommitteeDTO(BaseModel): + id: int + name: str + description: str + chair_name: str + chair_email: str + members: List[SenatorDTO] + is_active: bool + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/DistrictDTO.py b/backend/app/schemas/DistrictDTO.py new file mode 100644 index 0000000..ab04342 --- /dev/null +++ b/backend/app/schemas/DistrictDTO.py @@ -0,0 +1,14 @@ +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict + +from .SenatorDTO import SenatorDTO + + +class DistrictDTO(BaseModel): + id: int + district_name: str + description: Optional[str] = None + senator: Optional[List[SenatorDTO]] = None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/FinanceHearingConfigDTO.py b/backend/app/schemas/FinanceHearingConfigDTO.py new file mode 100644 index 0000000..a68e8fc --- /dev/null +++ b/backend/app/schemas/FinanceHearingConfigDTO.py @@ -0,0 +1,15 @@ +from datetime import date +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict + +from .FinanceHearingDateDTO import FinanceHearingDateDTO + + +class FinanceHearingConfigDTO(BaseModel): + is_active: bool + season_start: Optional[date] = None + season_end: Optional[date] = None + dates: List[FinanceHearingDateDTO] + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/FinanceHearingDateDTO.py b/backend/app/schemas/FinanceHearingDateDTO.py new file mode 100644 index 0000000..b7b46e4 --- /dev/null +++ b/backend/app/schemas/FinanceHearingDateDTO.py @@ -0,0 +1,15 @@ +from datetime import date, time +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class FinanceHearingDateDTO(BaseModel): + id: int + hearing_date: date + hearing_time: time + location: Optional[str] = None + description: Optional[str] = None + is_full: bool + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/LeadershipDTO.py b/backend/app/schemas/LeadershipDTO.py new file mode 100644 index 0000000..2589a85 --- /dev/null +++ b/backend/app/schemas/LeadershipDTO.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class LeadershipDTO(BaseModel): + id: int + title: str + first_name: str + last_name: str + email: str + photo_url: Optional[str] = None + session_number: int + is_current: bool + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/LegislationActionDTO.py b/backend/app/schemas/LegislationActionDTO.py new file mode 100644 index 0000000..1dbce0d --- /dev/null +++ b/backend/app/schemas/LegislationActionDTO.py @@ -0,0 +1,12 @@ +from datetime import date + +from pydantic import BaseModel, ConfigDict + + +class LegislationActionDTO(BaseModel): + id: int + action_date: date + description: str + action_type: str + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/LegislationDTO.py b/backend/app/schemas/LegislationDTO.py new file mode 100644 index 0000000..3c850ae --- /dev/null +++ b/backend/app/schemas/LegislationDTO.py @@ -0,0 +1,23 @@ +from datetime import date +from typing import List + +from pydantic import BaseModel, ConfigDict + +from .LegislationActionDTO import LegislationActionDTO + + +class LegislationDTO(BaseModel): + id: int + title: str + bill_number: str + session_number: int + sponsor_name: str + summary: str + full_text: str + status: str + type: str + date_introduced: date + date_last_action: date + actions: List[LegislationActionDTO] + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/NewsDTO.py b/backend/app/schemas/NewsDTO.py new file mode 100644 index 0000000..a13cec2 --- /dev/null +++ b/backend/app/schemas/NewsDTO.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, computed_field + +from .AccountDTO import AccountDTO + + +class NewsDTO(BaseModel): + id: int + title: str + summary: str + body: str + image_url: Optional[str] = None + date_published: datetime + date_last_edited: datetime + + admin: Optional["AccountDTO"] = None + + model_config = ConfigDict(from_attributes=True) + + @computed_field + @property + def author_name(self) -> str: + if self.admin: + return f"{self.admin.first_name} {self.admin.last_name}" + return "Unknown" diff --git a/backend/app/schemas/SenatorDTO.py b/backend/app/schemas/SenatorDTO.py new file mode 100644 index 0000000..6b5fa3e --- /dev/null +++ b/backend/app/schemas/SenatorDTO.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict + +from .CommitteeAssignmentDTO import CommitteeAssignmentDTO + + +class SenatorDTO(BaseModel): + id: int + first_name: str + last_name: str + email: str + headshot_url: Optional[str] = None + district_id: int + is_active: bool + session_number: int + committees: List[CommitteeAssignmentDTO] + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/StaffDTO.py b/backend/app/schemas/StaffDTO.py new file mode 100644 index 0000000..b320eac --- /dev/null +++ b/backend/app/schemas/StaffDTO.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class StaffDTO(BaseModel): + id: int + first_name: str + last_name: str + title: str + email: str + photo_url: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/StaticPageDTO.py b/backend/app/schemas/StaticPageDTO.py new file mode 100644 index 0000000..a411354 --- /dev/null +++ b/backend/app/schemas/StaticPageDTO.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class StaticPageDTO(BaseModel): + id: int + page_slug: str + title: str + body: str + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/news.py b/backend/app/schemas/news.py index b2cb228..c4abc94 100644 --- a/backend/app/schemas/news.py +++ b/backend/app/schemas/news.py @@ -1,8 +1,11 @@ """News schemas — input and output DTOs.""" from datetime import datetime +from typing import Optional -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, computed_field + +from .account import AccountDTO class NewsDTO(BaseModel): @@ -11,12 +14,19 @@ class NewsDTO(BaseModel): summary: str body: str image_url: str | None - author_name: str date_published: datetime date_last_edited: datetime + admin: Optional[AccountDTO] = None model_config = ConfigDict(from_attributes=True) + @computed_field + @property + def author_name(self) -> str: + if self.admin: + return f"{self.admin.first_name} {self.admin.last_name}" + return "Unknown" + class CreateNewsDTO(BaseModel): title: str diff --git a/backend/requirements.txt b/backend/requirements.txt index 1beecc7..9829c64 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,3 +16,6 @@ httpx==0.28.1 pre-commit==3.7.1 ruff==0.9.3 junitparser==3.2.0 + +# Pydantic (ships with fastapi) +pydantic>=2.8,<3.0.0 diff --git a/backend/tests/routers/conftest.py b/backend/tests/routers/conftest.py new file mode 100644 index 0000000..32950f8 --- /dev/null +++ b/backend/tests/routers/conftest.py @@ -0,0 +1,135 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.database import Base, get_db +from app.main import app +from app.models import Committee, CommitteeMembership, Leadership, Senator + +# --- Setup shared in-memory database --- +SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# --- Override FastAPI get_db dependency --- +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + +app.dependency_overrides[get_db] = override_get_db + +# --- Fixtures --- + +@pytest.fixture(scope="module") +def test_db(): + """Create all tables once per test module.""" + Base.metadata.create_all(bind=engine) + db = TestingSessionLocal() + yield db + db.close() + Base.metadata.drop_all(bind=engine) + +@pytest.fixture +def client(test_db): + """FastAPI TestClient using the test_db.""" + return TestClient(app) + +@pytest.fixture +def seeded_committees(test_db): + """Seed test data for committees, senators, and memberships.""" + # --- Senators --- + s1 = Senator( + first_name="John", + last_name="Doe", + email="john@example.com", + headshot_url=None, + district=1, + is_active=True, + session_number=2016, + ) + s2 = Senator( + first_name="Jane", + last_name="Doe", + email="jane@example.com", + headshot_url=None, + district=2, + is_active=True, + session_number=2025, + ) + test_db.add_all([s1, s2]) + test_db.commit() + + # --- Committees --- + c1 = Committee( + name="Finance Committee", + description="Handles budget matters", + chair_name="John Doe", + chair_email="john@example.com", + chair_senator_id=s1.id, + is_active=True, + ) + c2 = Committee( + name="Education Committee", + description="Handles education policy", + chair_name="Jane Smith", + chair_email="jane@example.com", + chair_senator_id=s2.id, + is_active=False, + ) + test_db.add_all([c1, c2]) + test_db.commit() + + # --- Memberships --- + m1 = CommitteeMembership(senator_id=s1.id, committee_id=c1.id, role="Chair") + m2 = CommitteeMembership(senator_id=s2.id, committee_id=c1.id, role="Member") + test_db.add_all([m1, m2]) + test_db.commit() + + yield {"senators": [s1, s2], "committees": [c1, c2], "memberships": [m1, m2]} + + +@pytest.fixture +def seeded_leadership(test_db): + """Seed test data for leadership.""" + l1 = Leadership( + title="Speaker", + first_name="John", + last_name="Doe", + email="john@example.com", + headshot_url=None, + is_active=True, + session_number=2025, + ) + l2 = Leadership( + title="Minority Leader", + first_name="Jane", + last_name="Smith", + email="jane@example.com", + headshot_url=None, + is_active=False, + session_number=2023, + ) + l3 = Leadership( + title="Whip", + first_name="Alice", + last_name="Brown", + email="alice@example.com", + headshot_url=None, + is_active=True, + session_number=2025, + ) + + test_db.add_all([l1, l2, l3]) + test_db.commit() + + yield {"records": [l1, l2, l3]} diff --git a/backend/tests/routers/test_committees.py b/backend/tests/routers/test_committees.py new file mode 100644 index 0000000..8e4da53 --- /dev/null +++ b/backend/tests/routers/test_committees.py @@ -0,0 +1,38 @@ +import pytest +from fastapi.testclient import TestClient + +from app.main import app # your FastAPI app + + +@pytest.fixture +def client(test_db): + return TestClient(app) + +# --- Tests --- +def test_get_committee_by_id(client, seeded_committees): + committee = seeded_committees["committees"][0] + + response = client.get(f"/api/committees/{committee.id}") + assert response.status_code == 200 + + data = response.json() + assert data["id"] == committee.id + assert data["name"] == "Finance Committee" + assert len(data["members"]) == 2 + roles = {c["role"] for m in data["members"] for c in m["committees"]} + assert roles == {"Chair", "Member"} + +def test_get_committee_not_found(client, test_db): + response = client.get("/api/committees/999") + assert response.status_code == 404 + assert response.json()["detail"] == "Committee not found" + +def test_get_all_active_committees(client, seeded_committees): + response = client.get("/api/committees/") + assert response.status_code == 200 + + data = response.json() + # Only active committees + names = [c["name"] for c in data] + assert "Finance Committee" in names + assert "Education Committee" not in names diff --git a/backend/tests/routers/test_leadership.py b/backend/tests/routers/test_leadership.py new file mode 100644 index 0000000..db18eb0 --- /dev/null +++ b/backend/tests/routers/test_leadership.py @@ -0,0 +1,44 @@ +import pytest +from fastapi.testclient import TestClient + +from app.main import app # your FastAPI app + + +@pytest.fixture +def client(test_db): + return TestClient(app) + +# --- Tests --- +def test_get_all_active_leadership(client, seeded_leadership): + response = client.get("/api/leadership/") + assert response.status_code == 200 + data = response.json() + + # Only active records should be returned + titles = [leader["title"] for leader in data] + assert "Speaker" in titles + assert "Whip" in titles + assert "Minority Leader" not in titles + +def test_get_leadership_by_session_number(client, seeded_leadership): + response = client.get("/api/leadership/?session_number=2025") + assert response.status_code == 200 + data = response.json() + + # Should return only records with session_number=2025 + assert all(leader["session_number"] == 2025 for leader in data) + titles = [leader["title"] for leader in data] + assert set(titles) == {"Speaker", "Whip"} + +def test_get_leadership_by_id_success(client, seeded_leadership): + leadership = seeded_leadership["records"][0] # Speaker + response = client.get(f"/api/leadership/{leadership.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == leadership.id + assert data["title"] == leadership.title + +def test_get_leadership_by_id_not_found(client): + response = client.get("/api/leadership/999") + assert response.status_code == 404 + assert response.json()["detail"] == "Leadership record not found" diff --git a/backend/tests/schemas/test_schemas.py b/backend/tests/schemas/test_schemas.py new file mode 100644 index 0000000..88dfeeb --- /dev/null +++ b/backend/tests/schemas/test_schemas.py @@ -0,0 +1,298 @@ +from datetime import date, datetime, time + +from app.schemas import ( + AccountDTO, + BudgetDataDTO, + CalendarEventDTO, + CarouselSlideDTO, + CommitteeAssignmentDTO, + CommitteeDTO, + DistrictDTO, + FinanceHearingConfigDTO, + FinanceHearingDateDTO, + LeadershipDTO, + LegislationActionDTO, + LegislationDTO, + NewsDTO, + SenatorDTO, + StaffDTO, + StaticPageDTO, +) + + +# ------------------------ +# Test CommitteeDTO +# ------------------------ +def test_committee_dto_serialization(): + committee_assignment = CommitteeAssignmentDTO( + committee_id=1, + committee_name="Finance", + role="Member" + ) + + senator = SenatorDTO( + id=1, + first_name="John", + last_name="Doe", + email="john@example.com", + headshot_url=None, + district_id=100, + is_active=True, + session_number=2026, + committees=[committee_assignment] + ) + + committee = CommitteeDTO( + id=10, + name="Finance", + description="Finance Committee", + chair_name="Jane Chair", + chair_email="chair@example.com", + members=[senator], + is_active=True + ) + + data = committee.model_dump() + assert data["id"] == 10 + assert data["members"][0]["first_name"] == "John" + + +# ------------------------ +# Test NewsDTO with computed field +# ------------------------ +def test_news_dto_computed_author(): + admin = AccountDTO( + id=1, + email="user@example.com", + pid="pid123", + first_name="Mock", + last_name="Admin", + role="admin" + ) + + news = NewsDTO( + id=1, + title="News Title", + summary="Summary", + body="Body", + image_url=None, + date_published=datetime(2026, 3, 3, 10, 0), + date_last_edited=datetime(2026, 3, 3, 11, 0), + admin=admin + ) + + data = news.model_dump() + assert data["author_name"] == "Mock Admin" + + +# ------------------------ +# Test LeadershipDTO +# ------------------------ +def test_leadership_dto(): + leadership = LeadershipDTO( + id=1, + title="Governor", + first_name="Bob", + last_name="Leader", + email="bob@example.com", + photo_url=None, + session_number=2026, + is_current=True + ) + + data = leadership.model_dump() + assert data["title"] == "Governor" + assert data["is_current"] is True + + +# ------------------------ +# Test LegislationDTO +# ------------------------ +def test_legislation_dto(): + action = LegislationActionDTO( + id=1, + action_date=date(2026, 1, 1), + description="Introduced", + action_type="Bill Introduction" + ) + + legislation = LegislationDTO( + id=1, + title="Bill A", + bill_number="A123", + session_number=2026, + sponsor_name="John Sponsor", + summary="Summary", + full_text="Full text", + status="Active", + type="Bill", + date_introduced=date(2026, 1, 1), + date_last_action=date(2026, 1, 2), + actions=[action] + ) + + data = legislation.model_dump() + assert data["actions"][0]["description"] == "Introduced" + + +# ------------------------ +# Test CarouselSlideDTO +# ------------------------ +def test_carousel_slide_dto(): + slide = CarouselSlideDTO( + id=1, + image_url="https://example.com/image.png", + overlay_text="Overlay", + link_url="https://example.com", + display_order=1, + is_active=True + ) + + data = slide.model_dump() + assert data["overlay_text"] == "Overlay" + + +# ------------------------ +# Test Finance DTOs +# ------------------------ +def test_finance_hearing_dtos(): + hearing_date = FinanceHearingDateDTO( + id=1, + hearing_date=date(2026, 3, 3), + hearing_time=time(14, 0), + location="Room 101", + description="Budget Hearing", + is_full=False + ) + + config = FinanceHearingConfigDTO( + is_active=True, + season_start=date(2026, 1, 1), + season_end=date(2026, 12, 31), + dates=[hearing_date] + ) + + data = config.model_dump() + assert data["dates"][0]["location"] == "Room 101" + + +# ------------------------ +# Test StaffDTO +# ------------------------ +def test_staff_dto(): + staff = StaffDTO( + id=1, + first_name="Sally", + last_name="Staff", + title="Manager", + email="sally@example.com", + photo_url=None + ) + + data = staff.model_dump() + assert data["title"] == "Manager" + + +# ------------------------ +# Test DistrictDTO +# ------------------------ +def test_district_dto(): + senator = SenatorDTO( + id=1, + first_name="John", + last_name="Doe", + email="john@example.com", + headshot_url=None, + district_id=100, + is_active=True, + session_number=2026, + committees=[] + ) + + district = DistrictDTO( + id=1, + district_name="District 1", + description=None, + senator=[senator] + ) + + data = district.model_dump() + assert data["senator"][0]["last_name"] == "Doe" + + +# ------------------------ +# Test BudgetDataDTO recursive +# ------------------------ +def test_budget_dto_recursive(): + child_budget = BudgetDataDTO( + id=2, + fiscal_year="2026", + category="Subcategory", + amount=100.0, + description=None, + children=[] + ) + + parent_budget = BudgetDataDTO( + id=1, + fiscal_year="2026", + category="Main", + amount=1000.0, + description="Main budget", + children=[child_budget] + ) + + data = parent_budget.model_dump() + assert data["children"][0]["category"] == "Subcategory" + + +# ------------------------ +# Test StaticPageDTO +# ------------------------ +def test_static_page_dto(): + page = StaticPageDTO( + id=1, + page_slug="about", + title="About Us", + body="Content", + updated_at=datetime.now() + ) + + data = page.model_dump() + assert data["page_slug"] == "about" + + +# ------------------------ +# Test AccountDTO +# ------------------------ +def test_account_dto(): + account = AccountDTO( + id=1, + email="user@example.com", + pid="pid123", + first_name="User", + last_name="Example", + role="admin" + ) + + data = account.model_dump() + assert data["role"] == "admin" + +# ------------------------ +# Test CalendarEventDTO +# ------------------------ +def test_calendar_event_dto(): + event = CalendarEventDTO( + id=1, + title="Budget Meeting", + description="Discuss budget allocations", + start_datetime=datetime(2026, 3, 10, 9, 0), + end_datetime=datetime(2026, 3, 10, 11, 0), + location="Room 101", + event_type="Hearing" + ) + + data = event.model_dump() + assert data["title"] == "Budget Meeting" + assert data["start_datetime"] == datetime(2026, 3, 10, 9, 0) + assert data["event_type"] == "Hearing"