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
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Add pending agencies tables

Revision ID: 30ee666f15d1
Revises: 9292faed37fd
Create Date: 2025-12-21 19:57:58.199838

Design notes:

After debating it internally, I elected to have a separate pending agencies table,
rather than adding an `approval status` column to the agencies table.

This is for a few reasons:
1. Many existing queries and models rely on the current agency setup,
and would need to be retrofitted in order to filter
approved and unapproved agencies.
2. Some existing links, such as between agencies and batches, between agencies and URLs,
or agency annotations for URLs, would not make sense for pending agencies,
and would be difficult to prevent in the database.

This setup does, however, make it more difficult to check for duplicates between
existing agencies and pending agencies. However, I concluded it was better for
pending agencies to be negatively affected by these design choices than
for existing agencies to be affected by the above design choices.

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

from src.util.alembic_helpers import id_column, created_at_column, enum_column, agency_id_column

Check warning on line 31 in alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py#L31 <401>

'src.util.alembic_helpers.agency_id_column' imported but unused
Raw output
./alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py:31:1: F401 'src.util.alembic_helpers.agency_id_column' imported but unused

# revision identifiers, used by Alembic.
revision: str = '30ee666f15d1'
down_revision: Union[str, None] = '9292faed37fd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None




def upgrade() -> None:

Check warning on line 42 in alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py#L42 <103>

Missing docstring in public function
Raw output
./alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py:42:1: D103 Missing docstring in public function

Check failure on line 42 in alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py#L42 <303>

too many blank lines (4)
Raw output
./alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py:42:1: E303 too many blank lines (4)
_create_proposed_agency_table()
_create_proposed_agency_location_table()
_create_proposed_agency_decision_info_table()

def _create_proposed_agency_decision_info_table():
op.create_table(
"proposal__agencies__decision_info",
sa.Column("proposal_agency_id", sa.Integer(), sa.ForeignKey("proposal__agencies.id"), nullable=False),
sa.Column("deciding_user_id", sa.Integer),
sa.Column("rejection_reason", sa.String(), nullable=True),
created_at_column(),
sa.PrimaryKeyConstraint("proposal_agency_id")
)


def _create_proposed_agency_table():
op.execute("CREATE TYPE proposal_status_enum AS ENUM ('pending', 'approved', 'rejected');")

op.create_table(
"proposal__agencies",
id_column(),
sa.Column("name", sa.String(), nullable=False),
enum_column(
column_name="agency_type",
enum_name="agency_type_enum",
),
enum_column(
column_name="jurisdiction_type",
enum_name="jurisdiction_type_enum"
),
sa.Column("proposing_user_id", sa.Integer(), nullable=True),
sa.Column(
"promoted_agency_id",
sa.Integer(),
sa.ForeignKey(
"agencies.id"
)
),
enum_column(
column_name="proposal_status",
enum_name="proposal_status_enum",
),
created_at_column(),
sa.CheckConstraint(
"promoted_agency_id IS NULL OR proposal_status = 'pending'",
name="ck_agency_id_or_proposal_status"
)
)

def _create_proposed_agency_location_table():
op.create_table(
"proposal__link__agencies__locations",
sa.Column(
"proposal_agency_id",
sa.Integer(),
sa.ForeignKey("proposal__agencies.id"),
nullable=False,
),
sa.Column(
"location_id",
sa.Integer(),
sa.ForeignKey("locations.id"),
nullable=False
),
created_at_column(),
sa.PrimaryKeyConstraint("proposal_agency_id", "location_id")
)

def downgrade() -> None:

Check warning on line 111 in alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py#L111 <103>

Missing docstring in public function
Raw output
./alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py:111:1: D103 Missing docstring in public function
pass
152 changes: 152 additions & 0 deletions src/api/endpoints/proposals/agencies/approve/query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
from pydantic import BaseModel

Check warning on line 1 in src/api/endpoints/proposals/agencies/approve/query.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/approve/query.py#L1 <100>

Missing docstring in public module
Raw output
./src/api/endpoints/proposals/agencies/approve/query.py:1:1: D100 Missing docstring in public module
from sqlalchemy import select, func, RowMapping, update
from sqlalchemy.exc import NoResultFound
from sqlalchemy.ext.asyncio import AsyncSession

from src.api.endpoints.proposals.agencies.approve.response import ProposalAgencyApproveResponse
from src.db.models.impl.agency.enums import JurisdictionType, AgencyType
from src.db.models.impl.agency.sqlalchemy import Agency
from src.db.models.impl.link.agency_location.sqlalchemy import LinkAgencyLocation
from src.db.models.impl.proposals.agency_.core import ProposalAgency
from src.db.models.impl.proposals.agency_.decision_info import ProposalAgencyDecisionInfo
from src.db.models.impl.proposals.agency_.link__location import ProposalLinkAgencyLocation
from src.db.models.impl.proposals.enums import ProposalStatus
from src.db.queries.base.builder import QueryBuilderBase

class _ProposalAgencyIntermediateModel(BaseModel):
proposal_id: int
name: str
agency_type: AgencyType
jurisdiction_type: JurisdictionType | None
proposal_status: ProposalStatus
location_ids: list[int]

class ProposalAgencyApproveQueryBuilder(QueryBuilderBase):

Check warning on line 24 in src/api/endpoints/proposals/agencies/approve/query.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/approve/query.py#L24 <101>

Missing docstring in public class
Raw output
./src/api/endpoints/proposals/agencies/approve/query.py:24:1: D101 Missing docstring in public class

def __init__(

Check warning on line 26 in src/api/endpoints/proposals/agencies/approve/query.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/approve/query.py#L26 <107>

Missing docstring in __init__
Raw output
./src/api/endpoints/proposals/agencies/approve/query.py:26:1: D107 Missing docstring in __init__
self,
proposed_agency_id: int,
deciding_user_id: int
):
super().__init__()
self.proposed_agency_id = proposed_agency_id
self.deciding_user_id = deciding_user_id

async def run(self, session: AsyncSession) -> ProposalAgencyApproveResponse:

Check warning on line 35 in src/api/endpoints/proposals/agencies/approve/query.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/approve/query.py#L35 <102>

Missing docstring in public method
Raw output
./src/api/endpoints/proposals/agencies/approve/query.py:35:1: D102 Missing docstring in public method

# Get proposed agency
proposed_agency: _ProposalAgencyIntermediateModel | None = await self._get_proposed_agency(session=session)
if proposed_agency is None:
return ProposalAgencyApproveResponse(
message="Proposed agency not found.",
success=False
)

# Confirm proposed agency is pending. Otherwise, fail early
if proposed_agency.proposal_status != ProposalStatus.PENDING:
return ProposalAgencyApproveResponse(
message="Proposed agency is not pending.",
success=False
)

await self._add_decision_info(session=session)

promoted_agency_id: int = await self._add_promoted_agency(
session=session,
proposed_agency=proposed_agency
)

await self._add_location_links(
session=session,
promoted_agency_id=promoted_agency_id,
location_ids=proposed_agency.location_ids
)

await self._update_proposed_agency_status(session=session)

return ProposalAgencyApproveResponse(
message="Proposed agency approved.",
success=True,
agency_id=promoted_agency_id
)

async def _get_proposed_agency(self, session: AsyncSession) -> _ProposalAgencyIntermediateModel | None:
query = (
select(
ProposalAgency.id,
ProposalAgency.name,
ProposalAgency.agency_type,
ProposalAgency.jurisdiction_type,
ProposalAgency.proposal_status,
func.array_agg(ProposalLinkAgencyLocation.location_id).label("location_ids")
)
.outerjoin(
ProposalLinkAgencyLocation,
ProposalLinkAgencyLocation.proposal_agency_id == ProposalAgency.id
)
.where(
ProposalAgency.id == self.proposed_agency_id
)
.group_by(
ProposalAgency.id,
ProposalAgency.name,
ProposalAgency.agency_type,
ProposalAgency.jurisdiction_type
)
)
try:
mapping: RowMapping | None = await self.sh.mapping(session, query)
except NoResultFound:
return None
return _ProposalAgencyIntermediateModel(
proposal_id=mapping[ProposalAgency.id],
name=mapping[ProposalAgency.name],
agency_type=mapping[ProposalAgency.agency_type],
jurisdiction_type=mapping[ProposalAgency.jurisdiction_type],
proposal_status=mapping[ProposalAgency.proposal_status],
location_ids=mapping["location_ids"] if mapping["location_ids"] != [None] else []
)

async def _add_decision_info(self, session: AsyncSession) -> None:
decision_info = ProposalAgencyDecisionInfo(
deciding_user_id=self.deciding_user_id,
proposal_agency_id=self.proposed_agency_id,
)
session.add(decision_info)

@staticmethod
async def _add_promoted_agency(
session: AsyncSession,
proposed_agency: _ProposalAgencyIntermediateModel
) -> int:
agency = Agency(
name=proposed_agency.name,
agency_type=proposed_agency.agency_type,
jurisdiction_type=proposed_agency.jurisdiction_type,
)
session.add(agency)
await session.flush()
return agency.id

@staticmethod
async def _add_location_links(
session: AsyncSession,
promoted_agency_id: int,
location_ids: list[int]
):
links: list[LinkAgencyLocation] = []
for location_id in location_ids:
link = LinkAgencyLocation(
agency_id=promoted_agency_id,
location_id=location_id
)
links.append(link)
session.add_all(links)

async def _update_proposed_agency_status(self, session: AsyncSession) -> None:
query = update(ProposalAgency).where(
ProposalAgency.id == self.proposed_agency_id
).values(
proposal_status=ProposalStatus.APPROVED
)
await session.execute(query)
7 changes: 7 additions & 0 deletions src/api/endpoints/proposals/agencies/approve/response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from pydantic import BaseModel

Check warning on line 1 in src/api/endpoints/proposals/agencies/approve/response.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/approve/response.py#L1 <100>

Missing docstring in public module
Raw output
./src/api/endpoints/proposals/agencies/approve/response.py:1:1: D100 Missing docstring in public module


class ProposalAgencyApproveResponse(BaseModel):

Check warning on line 4 in src/api/endpoints/proposals/agencies/approve/response.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/approve/response.py#L4 <101>

Missing docstring in public class
Raw output
./src/api/endpoints/proposals/agencies/approve/response.py:4:1: D101 Missing docstring in public class
message: str
success: bool
agency_id: int | None = None

Check warning on line 7 in src/api/endpoints/proposals/agencies/approve/response.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/approve/response.py#L7 <292>

no newline at end of file
Raw output
./src/api/endpoints/proposals/agencies/approve/response.py:7:33: W292 no newline at end of file
56 changes: 56 additions & 0 deletions src/api/endpoints/proposals/agencies/get/query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from typing import Sequence

Check warning on line 1 in src/api/endpoints/proposals/agencies/get/query.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/get/query.py#L1 <100>

Missing docstring in public module
Raw output
./src/api/endpoints/proposals/agencies/get/query.py:1:1: D100 Missing docstring in public module

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload

from src.api.endpoints.agencies.by_id.locations.get.response import AgencyGetLocationsResponse
from src.api.endpoints.proposals.agencies.get.response import ProposalAgencyGetOuterResponse, ProposalAgencyGetResponse
from src.db.models.impl.proposals.agency_.core import ProposalAgency
from src.db.models.impl.proposals.enums import ProposalStatus
from src.db.queries.base.builder import QueryBuilderBase


class ProposalAgencyGetQueryBuilder(QueryBuilderBase):

Check warning on line 14 in src/api/endpoints/proposals/agencies/get/query.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/get/query.py#L14 <101>

Missing docstring in public class
Raw output
./src/api/endpoints/proposals/agencies/get/query.py:14:1: D101 Missing docstring in public class

async def run(self, session: AsyncSession) -> ProposalAgencyGetOuterResponse:

Check warning on line 16 in src/api/endpoints/proposals/agencies/get/query.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/get/query.py#L16 <102>

Missing docstring in public method
Raw output
./src/api/endpoints/proposals/agencies/get/query.py:16:1: D102 Missing docstring in public method
query = (
select(
ProposalAgency
).where(
ProposalAgency.proposal_status == ProposalStatus.PENDING
).options(
joinedload(ProposalAgency.locations)
)
)
proposal_agencies: Sequence[ProposalAgency] = (
await session.execute(query)
).unique().scalars().all()
if len(proposal_agencies) == 0:
return ProposalAgencyGetOuterResponse(
results=[]
)
responses: list[ProposalAgencyGetResponse] = []
for proposal_agency in proposal_agencies:
locations: list[AgencyGetLocationsResponse] = []
for location in proposal_agency.locations:
location = AgencyGetLocationsResponse(
location_id=location.id,
full_display_name=location.full_display_name,
)
locations.append(location)

response = ProposalAgencyGetResponse(
id=proposal_agency.id,
name=proposal_agency.name,
proposing_user_id=proposal_agency.proposing_user_id,
agency_type=proposal_agency.agency_type,
jurisdiction_type=proposal_agency.jurisdiction_type,
created_at=proposal_agency.created_at,
locations=locations
)
responses.append(response)

return ProposalAgencyGetOuterResponse(
results=responses
)
18 changes: 18 additions & 0 deletions src/api/endpoints/proposals/agencies/get/response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from datetime import datetime

Check warning on line 1 in src/api/endpoints/proposals/agencies/get/response.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/get/response.py#L1 <100>

Missing docstring in public module
Raw output
./src/api/endpoints/proposals/agencies/get/response.py:1:1: D100 Missing docstring in public module

from pydantic import BaseModel

from src.api.endpoints.agencies.by_id.locations.get.response import AgencyGetLocationsResponse
from src.db.models.impl.agency.enums import AgencyType, JurisdictionType

class ProposalAgencyGetResponse(BaseModel):

Check warning on line 8 in src/api/endpoints/proposals/agencies/get/response.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/get/response.py#L8 <101>

Missing docstring in public class
Raw output
./src/api/endpoints/proposals/agencies/get/response.py:8:1: D101 Missing docstring in public class
id: int
name: str
proposing_user_id: int | None
agency_type: AgencyType
jurisdiction_type: JurisdictionType
locations: list[AgencyGetLocationsResponse]
created_at: datetime

class ProposalAgencyGetOuterResponse(BaseModel):

Check warning on line 17 in src/api/endpoints/proposals/agencies/get/response.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/get/response.py#L17 <101>

Missing docstring in public class
Raw output
./src/api/endpoints/proposals/agencies/get/response.py:17:1: D101 Missing docstring in public class
results: list[ProposalAgencyGetResponse]

Check warning on line 18 in src/api/endpoints/proposals/agencies/get/response.py

View workflow job for this annotation

GitHub Actions / flake8

[flake8] src/api/endpoints/proposals/agencies/get/response.py#L18 <292>

no newline at end of file
Raw output
./src/api/endpoints/proposals/agencies/get/response.py:18:45: W292 no newline at end of file
Loading