From 9ef70ce532b00442cc6a1b9bc50683bf3293894f Mon Sep 17 00:00:00 2001 From: Max Chis Date: Wed, 24 Dec 2025 18:20:53 -0500 Subject: [PATCH] Add logic for agency proposals --- ...0ee666f15d1_add_pending_agencies_tables.py | 112 ++++++++++++ .../{pending => proposals}/__init__.py | 0 .../agencies/__init__.py | 0 .../agencies/approve/__init__.py | 0 .../proposals/agencies/approve/query.py | 152 ++++++++++++++++ .../proposals/agencies/approve/response.py | 7 + .../agencies/get/__init__.py | 0 .../endpoints/proposals/agencies/get/query.py | 56 ++++++ .../proposals/agencies/get/response.py | 18 ++ .../proposals/agencies/reject}/__init__.py | 0 .../proposals/agencies/reject/query.py | 83 +++++++++ .../proposals/agencies/reject/request.py | 5 + .../proposals/agencies/reject/response.py | 6 + src/api/endpoints/proposals/routes.py | 56 ++++++ .../routes.py => submit/agency/__init__.py} | 0 src/api/endpoints/submit/agency/enums.py | 8 + src/api/endpoints/submit/agency/helpers.py | 106 +++++++++++ src/api/endpoints/submit/agency/query.py | 88 ++++++++++ src/api/endpoints/submit/agency/request.py | 11 ++ src/api/endpoints/submit/agency/response.py | 9 + src/api/endpoints/submit/routes.py | 20 ++- src/api/main.py | 4 +- src/db/client/async_.py | 2 +- src/db/models/impl/proposals/__init__.py | 0 .../models/impl/proposals/agency_/__init__.py | 0 src/db/models/impl/proposals/agency_/core.py | 38 ++++ .../impl/proposals/agency_/decision_info.py | 27 +++ .../impl/proposals/agency_/link__location.py | 22 +++ src/db/models/impl/proposals/enums.py | 7 + src/db/models/mixins.py | 3 +- .../integration/api/pending/test_agencies.py | 16 -- .../integration/api/proposals/__init__.py | 0 .../api/proposals/test_agencies.py | 164 ++++++++++++++++++ 33 files changed, 998 insertions(+), 22 deletions(-) create mode 100644 alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py rename src/api/endpoints/{pending => proposals}/__init__.py (100%) rename src/api/endpoints/{pending => proposals}/agencies/__init__.py (100%) rename src/api/endpoints/{pending => proposals}/agencies/approve/__init__.py (100%) create mode 100644 src/api/endpoints/proposals/agencies/approve/query.py create mode 100644 src/api/endpoints/proposals/agencies/approve/response.py rename src/api/endpoints/{pending => proposals}/agencies/get/__init__.py (100%) create mode 100644 src/api/endpoints/proposals/agencies/get/query.py create mode 100644 src/api/endpoints/proposals/agencies/get/response.py rename {tests/automated/integration/api/pending => src/api/endpoints/proposals/agencies/reject}/__init__.py (100%) create mode 100644 src/api/endpoints/proposals/agencies/reject/query.py create mode 100644 src/api/endpoints/proposals/agencies/reject/request.py create mode 100644 src/api/endpoints/proposals/agencies/reject/response.py create mode 100644 src/api/endpoints/proposals/routes.py rename src/api/endpoints/{pending/routes.py => submit/agency/__init__.py} (100%) create mode 100644 src/api/endpoints/submit/agency/enums.py create mode 100644 src/api/endpoints/submit/agency/helpers.py create mode 100644 src/api/endpoints/submit/agency/query.py create mode 100644 src/api/endpoints/submit/agency/request.py create mode 100644 src/api/endpoints/submit/agency/response.py create mode 100644 src/db/models/impl/proposals/__init__.py create mode 100644 src/db/models/impl/proposals/agency_/__init__.py create mode 100644 src/db/models/impl/proposals/agency_/core.py create mode 100644 src/db/models/impl/proposals/agency_/decision_info.py create mode 100644 src/db/models/impl/proposals/agency_/link__location.py create mode 100644 src/db/models/impl/proposals/enums.py delete mode 100644 tests/automated/integration/api/pending/test_agencies.py create mode 100644 tests/automated/integration/api/proposals/__init__.py create mode 100644 tests/automated/integration/api/proposals/test_agencies.py diff --git a/alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py b/alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py new file mode 100644 index 00000000..b5af2358 --- /dev/null +++ b/alembic/versions/2025_12_21_1957-30ee666f15d1_add_pending_agencies_tables.py @@ -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 + +# 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: + _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: + pass diff --git a/src/api/endpoints/pending/__init__.py b/src/api/endpoints/proposals/__init__.py similarity index 100% rename from src/api/endpoints/pending/__init__.py rename to src/api/endpoints/proposals/__init__.py diff --git a/src/api/endpoints/pending/agencies/__init__.py b/src/api/endpoints/proposals/agencies/__init__.py similarity index 100% rename from src/api/endpoints/pending/agencies/__init__.py rename to src/api/endpoints/proposals/agencies/__init__.py diff --git a/src/api/endpoints/pending/agencies/approve/__init__.py b/src/api/endpoints/proposals/agencies/approve/__init__.py similarity index 100% rename from src/api/endpoints/pending/agencies/approve/__init__.py rename to src/api/endpoints/proposals/agencies/approve/__init__.py diff --git a/src/api/endpoints/proposals/agencies/approve/query.py b/src/api/endpoints/proposals/agencies/approve/query.py new file mode 100644 index 00000000..3c08954e --- /dev/null +++ b/src/api/endpoints/proposals/agencies/approve/query.py @@ -0,0 +1,152 @@ +from pydantic import BaseModel +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): + + def __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: + + # 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) diff --git a/src/api/endpoints/proposals/agencies/approve/response.py b/src/api/endpoints/proposals/agencies/approve/response.py new file mode 100644 index 00000000..9de62d6c --- /dev/null +++ b/src/api/endpoints/proposals/agencies/approve/response.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class ProposalAgencyApproveResponse(BaseModel): + message: str + success: bool + agency_id: int | None = None \ No newline at end of file diff --git a/src/api/endpoints/pending/agencies/get/__init__.py b/src/api/endpoints/proposals/agencies/get/__init__.py similarity index 100% rename from src/api/endpoints/pending/agencies/get/__init__.py rename to src/api/endpoints/proposals/agencies/get/__init__.py diff --git a/src/api/endpoints/proposals/agencies/get/query.py b/src/api/endpoints/proposals/agencies/get/query.py new file mode 100644 index 00000000..dde61c90 --- /dev/null +++ b/src/api/endpoints/proposals/agencies/get/query.py @@ -0,0 +1,56 @@ +from typing import Sequence + +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): + + async def run(self, session: AsyncSession) -> ProposalAgencyGetOuterResponse: + 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 + ) diff --git a/src/api/endpoints/proposals/agencies/get/response.py b/src/api/endpoints/proposals/agencies/get/response.py new file mode 100644 index 00000000..e5a365c1 --- /dev/null +++ b/src/api/endpoints/proposals/agencies/get/response.py @@ -0,0 +1,18 @@ +from datetime import datetime + +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): + id: int + name: str + proposing_user_id: int | None + agency_type: AgencyType + jurisdiction_type: JurisdictionType + locations: list[AgencyGetLocationsResponse] + created_at: datetime + +class ProposalAgencyGetOuterResponse(BaseModel): + results: list[ProposalAgencyGetResponse] \ No newline at end of file diff --git a/tests/automated/integration/api/pending/__init__.py b/src/api/endpoints/proposals/agencies/reject/__init__.py similarity index 100% rename from tests/automated/integration/api/pending/__init__.py rename to src/api/endpoints/proposals/agencies/reject/__init__.py diff --git a/src/api/endpoints/proposals/agencies/reject/query.py b/src/api/endpoints/proposals/agencies/reject/query.py new file mode 100644 index 00000000..0635a58d --- /dev/null +++ b/src/api/endpoints/proposals/agencies/reject/query.py @@ -0,0 +1,83 @@ +from pydantic import BaseModel +from sqlalchemy import select, RowMapping, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.endpoints.proposals.agencies.reject.request import ProposalAgencyRejectRequestModel +from src.api.endpoints.proposals.agencies.reject.response import ProposalAgencyRejectResponse +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.enums import ProposalStatus +from src.db.queries.base.builder import QueryBuilderBase + +class _ProposalAgencyIntermediateModel(BaseModel): + proposal_id: int + proposal_status: ProposalStatus + + +class ProposalAgencyRejectQueryBuilder(QueryBuilderBase): + + def __init__( + self, + deciding_user_id: int, + proposed_agency_id: int, + request_model: ProposalAgencyRejectRequestModel + ): + super().__init__() + self.deciding_user_id = deciding_user_id + self.proposed_agency_id = proposed_agency_id + self.rejection_reason = request_model.rejection_reason + + async def run(self, session: AsyncSession) -> ProposalAgencyRejectResponse: + # Get proposed agency + proposed_agency: _ProposalAgencyIntermediateModel | None = await self._get_proposed_agency(session=session) + if proposed_agency is None: + return ProposalAgencyRejectResponse( + message="Proposed agency not found.", + success=False + ) + + # Confirm proposed agency is pending. Otherwise, fail early + if proposed_agency.proposal_status != ProposalStatus.PENDING: + return ProposalAgencyRejectResponse( + message="Proposed agency is not pending.", + success=False + ) + + await self._add_decision_info(session=session) + await self._update_proposed_agency_status(session=session) + + return ProposalAgencyRejectResponse( + message="Proposed agency rejected.", + success=True + ) + + async def _get_proposed_agency(self, session: AsyncSession) -> _ProposalAgencyIntermediateModel | None: + query = ( + select( + ProposalAgency.id.label("proposal_id"), + ProposalAgency.proposal_status + ) + .where( + ProposalAgency.id == self.proposed_agency_id + ) + ) + mapping: RowMapping | None = await self.sh.mapping(session, query) + if mapping is None: + return None + return _ProposalAgencyIntermediateModel(**mapping) + + async def _add_decision_info(self, session: AsyncSession) -> None: + decision_info = ProposalAgencyDecisionInfo( + proposal_agency_id=self.proposed_agency_id, + rejection_reason=self.rejection_reason, + deciding_user_id=self.deciding_user_id + ) + session.add(decision_info) + + async def _update_proposed_agency_status(self, session: AsyncSession) -> None: + query = update(ProposalAgency).where( + ProposalAgency.id == self.proposed_agency_id + ).values( + proposal_status=ProposalStatus.REJECTED + ) + await session.execute(query) diff --git a/src/api/endpoints/proposals/agencies/reject/request.py b/src/api/endpoints/proposals/agencies/reject/request.py new file mode 100644 index 00000000..8c3b1d1c --- /dev/null +++ b/src/api/endpoints/proposals/agencies/reject/request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class ProposalAgencyRejectRequestModel(BaseModel): + rejection_reason: str \ No newline at end of file diff --git a/src/api/endpoints/proposals/agencies/reject/response.py b/src/api/endpoints/proposals/agencies/reject/response.py new file mode 100644 index 00000000..af85550b --- /dev/null +++ b/src/api/endpoints/proposals/agencies/reject/response.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class ProposalAgencyRejectResponse(BaseModel): + success: bool + message: str \ No newline at end of file diff --git a/src/api/endpoints/proposals/routes.py b/src/api/endpoints/proposals/routes.py new file mode 100644 index 00000000..8371c604 --- /dev/null +++ b/src/api/endpoints/proposals/routes.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, Depends, Path + +from src.api.dependencies import get_async_core +from src.api.endpoints.proposals.agencies.approve.query import ProposalAgencyApproveQueryBuilder +from src.api.endpoints.proposals.agencies.approve.response import ProposalAgencyApproveResponse +from src.api.endpoints.proposals.agencies.get.query import ProposalAgencyGetQueryBuilder +from src.api.endpoints.proposals.agencies.get.response import ProposalAgencyGetOuterResponse +from src.api.endpoints.proposals.agencies.reject.query import ProposalAgencyRejectQueryBuilder +from src.api.endpoints.proposals.agencies.reject.request import ProposalAgencyRejectRequestModel +from src.api.endpoints.proposals.agencies.reject.response import ProposalAgencyRejectResponse +from src.core.core import AsyncCore +from src.security.dtos.access_info import AccessInfo +from src.security.manager import get_access_info + +proposal_router = APIRouter(prefix="/proposal", tags=["Pending"]) + +@proposal_router.get("/agencies") +async def get_pending_agencies( + async_core: AsyncCore = Depends(get_async_core), + access_info: AccessInfo = Depends(get_access_info), +) -> ProposalAgencyGetOuterResponse: + return await async_core.adb_client.run_query_builder( + ProposalAgencyGetQueryBuilder(), + ) + +@proposal_router.post("/agencies/{proposed_agency_id}/approve") +async def approve_proposed_agency( + async_core: AsyncCore = Depends(get_async_core), + proposed_agency_id: int = Path( + description="Proposed agency ID to approve" + ), + access_info: AccessInfo = Depends(get_access_info), +) -> ProposalAgencyApproveResponse: + return await async_core.adb_client.run_query_builder( + ProposalAgencyApproveQueryBuilder( + proposed_agency_id=proposed_agency_id, + deciding_user_id=access_info.user_id, + ) + ) + +@proposal_router.post("/agencies/{proposed_agency_id}/reject") +async def reject_proposed_agency( + request: ProposalAgencyRejectRequestModel, + async_core: AsyncCore = Depends(get_async_core), + proposed_agency_id: int = Path( + description="Proposed agency ID to reject" + ), + access_info: AccessInfo = Depends(get_access_info), +) -> ProposalAgencyRejectResponse: + return await async_core.adb_client.run_query_builder( + ProposalAgencyRejectQueryBuilder( + proposed_agency_id=proposed_agency_id, + deciding_user_id=access_info.user_id, + request_model=request, + ) + ) \ No newline at end of file diff --git a/src/api/endpoints/pending/routes.py b/src/api/endpoints/submit/agency/__init__.py similarity index 100% rename from src/api/endpoints/pending/routes.py rename to src/api/endpoints/submit/agency/__init__.py diff --git a/src/api/endpoints/submit/agency/enums.py b/src/api/endpoints/submit/agency/enums.py new file mode 100644 index 00000000..95e160df --- /dev/null +++ b/src/api/endpoints/submit/agency/enums.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class AgencyProposalRequestStatus(Enum): + SUCCESS = "SUCCESS" + PROPOSAL_DUPLICATE = "PROPOSAL_DUPLICATE" + ACCEPTED_DUPLICATE = "ACCEPTED_DUPLICATE" + ERROR = "ERROR" diff --git a/src/api/endpoints/submit/agency/helpers.py b/src/api/endpoints/submit/agency/helpers.py new file mode 100644 index 00000000..12abc550 --- /dev/null +++ b/src/api/endpoints/submit/agency/helpers.py @@ -0,0 +1,106 @@ +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import aggregate_order_by + +from src.api.endpoints.submit.agency.request import SubmitAgencyRequestModel +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_.link__location import ProposalLinkAgencyLocation + + +def norm_name(col): + # POSTGRES: lower(regexp_replace(trim(name), '\s+', ' ', 'g')) + return func.lower( + func.regexp_replace(func.trim(col), r"\s+", " ", "g") + ) + +def exact_duplicates_for_approved_agency_query( + request: SubmitAgencyRequestModel, +): + link = LinkAgencyLocation + agencies = Agency + + agency_locations_cte = ( + select( + link.agency_id, + # Postgres ARRAY_AGG with deterministic ordering + func.array_agg( + aggregate_order_by( + link.location_id, + link.location_id.asc() + ) + ).label("location_ids") + ) + .group_by( + link.agency_id, + ) + .cte("agency_locations") + ) + + query = ( + select( + agencies.id, + ) + .join( + agency_locations_cte, + agency_locations_cte.c.agency_id == agencies.id + ) + .where( + norm_name(agencies.name) == request.name.lower().strip(), + agencies.jurisdiction_type == request.jurisdiction_type, + agencies.agency_type == request.agency_type, + agency_locations_cte.c.location_ids == sorted(request.location_ids), + ) + .group_by( + agencies.id, + ) + ) + + return query + + +def exact_duplicates_for_proposal_agency_query( + request: SubmitAgencyRequestModel, +): + link = ProposalLinkAgencyLocation + agencies = ProposalAgency + + agency_locations_cte = ( + select( + link.proposal_agency_id, + # Postgres ARRAY_AGG with deterministic ordering + func.array_agg( + aggregate_order_by( + link.location_id, + link.location_id.asc() + ) + ).label("location_ids") + ) + .group_by( + link.proposal_agency_id, + ) + .cte("agency_locations") + ) + + query = ( + select( + agencies.id, + ) + .join( + agency_locations_cte, + agency_locations_cte.c.proposal_agency_id == agencies.id + ) + .where( + norm_name(agencies.name) == request.name.lower().strip(), + agencies.jurisdiction_type == request.jurisdiction_type, + agencies.agency_type == request.agency_type, + agency_locations_cte.c.location_ids == sorted(request.location_ids), + ) + .group_by( + agencies.id, + ) + ) + + return query + + diff --git a/src/api/endpoints/submit/agency/query.py b/src/api/endpoints/submit/agency/query.py new file mode 100644 index 00000000..a59f5f12 --- /dev/null +++ b/src/api/endpoints/submit/agency/query.py @@ -0,0 +1,88 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.endpoints.submit.agency.enums import AgencyProposalRequestStatus +from src.api.endpoints.submit.agency.helpers import \ + exact_duplicates_for_proposal_agency_query, exact_duplicates_for_approved_agency_query +from src.api.endpoints.submit.agency.request import SubmitAgencyRequestModel +from src.api.endpoints.submit.agency.response import SubmitAgencyProposalResponse +from src.db.models.impl.proposals.agency_.core import ProposalAgency +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 SubmitAgencyProposalQueryBuilder(QueryBuilderBase): + + def __init__(self, request: SubmitAgencyRequestModel, user_id: int): + super().__init__() + self.request = request + self.user_id = user_id + + async def run(self, session: AsyncSession) -> SubmitAgencyProposalResponse: + + # Check that an agency with the same name AND location IDs does not exist + # as an approved agency + if await self._approved_agency_exists(session): + return SubmitAgencyProposalResponse( + status=AgencyProposalRequestStatus.ACCEPTED_DUPLICATE, + details="An agency with the same properties is already approved." + ) + + # Check that an agency with the same name AND location IDs does not exist + # as a proposed agency + if await self._proposed_agency_exists(session): + return SubmitAgencyProposalResponse( + status=AgencyProposalRequestStatus.PROPOSAL_DUPLICATE, + details="An agency with the same properties is already in the proposal queue." + ) + + # Add proposed agency and get proposal ID + proposal_id: int = await self._add_proposed_agency(session) + + # Add proposed agency locations + await self._add_proposed_agency_locations( + session=session, + proposal_id=proposal_id, + location_ids=self.request.location_ids + ) + + # Return response + + return SubmitAgencyProposalResponse( + proposal_id=proposal_id, + status=AgencyProposalRequestStatus.SUCCESS, + details="Successfully added proposed agency." + ) + + async def _approved_agency_exists(self, session: AsyncSession) -> bool: + query = exact_duplicates_for_approved_agency_query(self.request) + return await self.sh.results_exist(session, query=query) + + async def _proposed_agency_exists(self, session: AsyncSession) -> bool: + query = exact_duplicates_for_proposal_agency_query(self.request) + return await self.sh.results_exist(session, query=query) + + async def _add_proposed_agency(self, session: AsyncSession) -> int: + proposal = ProposalAgency( + name=self.request.name, + jurisdiction_type=self.request.jurisdiction_type, + agency_type=self.request.agency_type, + proposing_user_id=self.user_id, + proposal_status=ProposalStatus.PENDING, + ) + session.add(proposal) + await session.flush() + return proposal.id + + async def _add_proposed_agency_locations( + self, + session: AsyncSession, + location_ids: list[int], + proposal_id: int + ) -> None: + for location_id in location_ids: + link = ProposalLinkAgencyLocation( + proposal_agency_id=proposal_id, + location_id=location_id + ) + session.add(link) diff --git a/src/api/endpoints/submit/agency/request.py b/src/api/endpoints/submit/agency/request.py new file mode 100644 index 00000000..8fef866a --- /dev/null +++ b/src/api/endpoints/submit/agency/request.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from src.db.models.impl.agency.enums import AgencyType, JurisdictionType + + +class SubmitAgencyRequestModel(BaseModel): + name: str + agency_type: AgencyType + jurisdiction_type: JurisdictionType + + location_ids: list[int] \ No newline at end of file diff --git a/src/api/endpoints/submit/agency/response.py b/src/api/endpoints/submit/agency/response.py new file mode 100644 index 00000000..886713a5 --- /dev/null +++ b/src/api/endpoints/submit/agency/response.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +from src.api.endpoints.submit.agency.enums import AgencyProposalRequestStatus + + +class SubmitAgencyProposalResponse(BaseModel): + proposal_id: int | None = None + status: AgencyProposalRequestStatus + details: str | None \ No newline at end of file diff --git a/src/api/endpoints/submit/routes.py b/src/api/endpoints/submit/routes.py index 2eb46c15..dec7e2aa 100644 --- a/src/api/endpoints/submit/routes.py +++ b/src/api/endpoints/submit/routes.py @@ -1,11 +1,12 @@ from fastapi import APIRouter, Depends from src.api.dependencies import get_async_core - +from src.api.endpoints.submit.agency.query import SubmitAgencyProposalQueryBuilder +from src.api.endpoints.submit.agency.request import SubmitAgencyRequestModel +from src.api.endpoints.submit.agency.response import SubmitAgencyProposalResponse from src.api.endpoints.submit.data_source.models.response.duplicate import \ SubmitDataSourceURLDuplicateSubmissionResponse from src.api.endpoints.submit.data_source.models.response.standard import SubmitDataSourceURLProposalResponse -from src.api.endpoints.submit.data_source.queries.core import SubmitDataSourceURLProposalQueryBuilder from src.api.endpoints.submit.data_source.request import DataSourceSubmissionRequest from src.api.endpoints.submit.data_source.wrapper import submit_data_source_url_proposal from src.api.endpoints.submit.url.models.request import URLSubmissionRequest @@ -13,7 +14,7 @@ from src.api.endpoints.submit.url.queries.core import SubmitURLQueryBuilder from src.core.core import AsyncCore from src.security.dtos.access_info import AccessInfo -from src.security.manager import get_access_info +from src.security.manager import get_access_info, get_standard_user_access_info submit_router = APIRouter(prefix="/submit", tags=["Submit"]) @@ -49,3 +50,16 @@ async def submit_data_source( request=request, adb_client=async_core.adb_client ) + +@submit_router.post("/agency") +async def submit_agency( + request: SubmitAgencyRequestModel, + async_core: AsyncCore = Depends(get_async_core), + access_info: AccessInfo = Depends(get_standard_user_access_info) +) -> SubmitAgencyProposalResponse: + return await async_core.adb_client.run_query_builder( + SubmitAgencyProposalQueryBuilder( + request=request, + user_id=access_info.user_id + ) + ) diff --git a/src/api/main.py b/src/api/main.py index 87fa0d3a..a62e6fdf 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -19,6 +19,7 @@ from src.api.endpoints.locations.routes import location_url_router from src.api.endpoints.meta_url.routes import meta_urls_router from src.api.endpoints.metrics.routes import metrics_router +from src.api.endpoints.proposals.routes import proposal_router from src.api.endpoints.root import root_router from src.api.endpoints.search.routes import search_router from src.api.endpoints.submit.routes import submit_router @@ -199,7 +200,8 @@ async def redirect_docs(): data_sources_router, meta_urls_router, check_router, - location_url_router + location_url_router, + proposal_router ] for router in routers: diff --git a/src/db/client/async_.py b/src/db/client/async_.py index 89187f11..e30c13bf 100644 --- a/src/db/client/async_.py +++ b/src/db/client/async_.py @@ -268,7 +268,7 @@ async def add_user_relevant_suggestion( url_id=url_id ) if prior_suggestion is not None: - prior_suggestion.type = suggested_status.value + prior_suggestion.agency_type = suggested_status.value return suggestion = AnnotationURLTypeUser( diff --git a/src/db/models/impl/proposals/__init__.py b/src/db/models/impl/proposals/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/db/models/impl/proposals/agency_/__init__.py b/src/db/models/impl/proposals/agency_/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/db/models/impl/proposals/agency_/core.py b/src/db/models/impl/proposals/agency_/core.py new file mode 100644 index 00000000..69172768 --- /dev/null +++ b/src/db/models/impl/proposals/agency_/core.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, String, Integer, ForeignKey +from sqlalchemy.orm import Mapped, relationship + +from src.db.models.helpers import enum_column +from src.db.models.impl.agency.enums import JurisdictionType, AgencyType +from src.db.models.impl.proposals.enums import ProposalStatus +from src.db.models.mixins import CreatedAtMixin +from src.db.models.templates_.with_id import WithIDBase + + +class ProposalAgency( + WithIDBase, + CreatedAtMixin +): + + __tablename__ = "proposal__agencies" + + name = Column(String, nullable=False) + agency_type: Mapped[AgencyType] = enum_column(AgencyType, name="agency_type_enum") + jurisdiction_type: Mapped[JurisdictionType] = enum_column( + JurisdictionType, + name="jurisdiction_type_enum", + nullable=False, + ) + proposing_user_id: Mapped[int | None] = Column(Integer, nullable=True) + proposal_status: Mapped[ProposalStatus] = enum_column(ProposalStatus, name="proposal_status_enum") + promoted_agency_id: Mapped[int | None] = Column( + Integer, + ForeignKey("agencies.id"), + nullable=True + ) + + locations = relationship( + "LocationExpandedView", + primaryjoin="ProposalAgency.id == ProposalLinkAgencyLocation.proposal_agency_id", + secondaryjoin="LocationExpandedView.id == ProposalLinkAgencyLocation.location_id", + secondary="proposal__link__agencies__locations", + ) diff --git a/src/db/models/impl/proposals/agency_/decision_info.py b/src/db/models/impl/proposals/agency_/decision_info.py new file mode 100644 index 00000000..5cc19dd0 --- /dev/null +++ b/src/db/models/impl/proposals/agency_/decision_info.py @@ -0,0 +1,27 @@ +""" +Provides decision information on an Agency + +""" +from sqlalchemy import Column, Integer, String, ForeignKey, PrimaryKeyConstraint +from sqlalchemy.orm import Mapped + +from src.db.models.mixins import CreatedAtMixin +from src.db.models.templates_.base import Base + + +class ProposalAgencyDecisionInfo( + Base, + CreatedAtMixin, +): + __tablename__ = "proposal__agencies__decision_info" + __table_args__ = ( + PrimaryKeyConstraint("proposal_agency_id"), + ) + + proposal_agency_id: Mapped[int] = Column( + Integer, + ForeignKey("proposal__agencies.id"), + nullable=False + ) + deciding_user_id: Mapped[int] = Column(Integer) + rejection_reason: Mapped[str | None] = Column(String, nullable=True) diff --git a/src/db/models/impl/proposals/agency_/link__location.py b/src/db/models/impl/proposals/agency_/link__location.py new file mode 100644 index 00000000..43d7c9fd --- /dev/null +++ b/src/db/models/impl/proposals/agency_/link__location.py @@ -0,0 +1,22 @@ +from sqlalchemy import PrimaryKeyConstraint, Column, ForeignKey, Integer +from sqlalchemy.orm import Mapped + +from src.db.models.mixins import LocationDependentMixin, CreatedAtMixin +from src.db.models.templates_.base import Base + + +class ProposalLinkAgencyLocation( + Base, + LocationDependentMixin, + CreatedAtMixin +): + __tablename__ = "proposal__link__agencies__locations" + __table_args__ = ( + PrimaryKeyConstraint("proposal_agency_id", "location_id"), + ) + + proposal_agency_id: Mapped[int] = Column( + Integer, + ForeignKey("proposal__agencies.id"), + nullable=False + ) \ No newline at end of file diff --git a/src/db/models/impl/proposals/enums.py b/src/db/models/impl/proposals/enums.py new file mode 100644 index 00000000..defd0d8c --- /dev/null +++ b/src/db/models/impl/proposals/enums.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class ProposalStatus(Enum): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" \ No newline at end of file diff --git a/src/db/models/mixins.py b/src/db/models/mixins.py index 640ec955..4a8ae48f 100644 --- a/src/db/models/mixins.py +++ b/src/db/models/mixins.py @@ -1,6 +1,7 @@ from typing import ClassVar from sqlalchemy import Column, Integer, ForeignKey, TIMESTAMP, event +from sqlalchemy.orm import Mapped from src.db.models.exceptions import WriteToViewError from src.db.models.helpers import get_created_at_column, CURRENT_TIME_SERVER_DEFAULT, url_id_primary_key_constraint, \ @@ -41,7 +42,7 @@ class BatchDependentMixin: ) class LocationDependentMixin: - location_id = Column( + location_id: Mapped[int] = Column( Integer, ForeignKey( 'locations.id', diff --git a/tests/automated/integration/api/pending/test_agencies.py b/tests/automated/integration/api/pending/test_agencies.py deleted file mode 100644 index 24061804..00000000 --- a/tests/automated/integration/api/pending/test_agencies.py +++ /dev/null @@ -1,16 +0,0 @@ -import pytest - -from tests.helpers.api_test_helper import APITestHelper - - -@pytest.mark.asyncio -async def test_agencies(api_test_helper: APITestHelper): - pass - - # Add pending agency - - # Call GET endpoint - - # Call APPROVE endpoint - - # Check agency is added diff --git a/tests/automated/integration/api/proposals/__init__.py b/tests/automated/integration/api/proposals/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/automated/integration/api/proposals/test_agencies.py b/tests/automated/integration/api/proposals/test_agencies.py new file mode 100644 index 00000000..70a97118 --- /dev/null +++ b/tests/automated/integration/api/proposals/test_agencies.py @@ -0,0 +1,164 @@ +import pytest + +from src.api.endpoints.proposals.agencies.approve.response import ProposalAgencyApproveResponse +from src.api.endpoints.proposals.agencies.get.response import ProposalAgencyGetOuterResponse +from src.api.endpoints.proposals.agencies.reject.request import ProposalAgencyRejectRequestModel +from src.api.endpoints.proposals.agencies.reject.response import ProposalAgencyRejectResponse +from src.api.endpoints.submit.agency.enums import AgencyProposalRequestStatus +from src.api.endpoints.submit.agency.request import SubmitAgencyRequestModel +from src.api.endpoints.submit.agency.response import SubmitAgencyProposalResponse +from src.db.client.async_ import AsyncDatabaseClient +from src.db.models.impl.agency.enums import AgencyType, JurisdictionType +from src.db.models.impl.agency.sqlalchemy import Agency +from src.db.models.impl.link.agency_location.sqlalchemy import LinkAgencyLocation +from tests.automated.integration.api._helpers.RequestValidator import RequestValidator +from tests.automated.integration.conftest import MOCK_USER_ID +from tests.helpers.api_test_helper import APITestHelper +from tests.helpers.data_creator.models.creation_info.county import CountyCreationInfo +from tests.helpers.data_creator.models.creation_info.locality import LocalityCreationInfo + + +@pytest.mark.asyncio +async def test_agencies( + api_test_helper: APITestHelper, + pittsburgh_locality: LocalityCreationInfo, + allegheny_county: CountyCreationInfo +): + request = SubmitAgencyRequestModel( + name="test_agency", + agency_type=AgencyType.LAW_ENFORCEMENT, + jurisdiction_type=JurisdictionType.LOCAL, + location_ids=[ + allegheny_county.location_id, + pittsburgh_locality.location_id + ] + ) + + rv: RequestValidator = api_test_helper.request_validator + adb_client: AsyncDatabaseClient = api_test_helper.adb_client() + # Add pending agency + submit_response_success: SubmitAgencyProposalResponse = rv.post_v3( + "/submit/agency", + expected_model=SubmitAgencyProposalResponse, + json=request.model_dump(mode="json") + ) + assert submit_response_success.status == AgencyProposalRequestStatus.SUCCESS + proposal_id: int = submit_response_success.proposal_id + + # Try to submit duplicate agency and confirm it fails + submit_response_proposal_duplicate: SubmitAgencyProposalResponse = rv.post_v3( + "/submit/agency", + expected_model=SubmitAgencyProposalResponse, + json=request.model_dump(mode="json") + ) + assert submit_response_proposal_duplicate.status == AgencyProposalRequestStatus.PROPOSAL_DUPLICATE + assert submit_response_proposal_duplicate.proposal_id is None + assert submit_response_proposal_duplicate.details == "An agency with the same properties is already in the proposal queue." + + # Call GET endpoint + get_response_1: ProposalAgencyGetOuterResponse = rv.get_v3( + "/proposal/agencies", + expected_model=ProposalAgencyGetOuterResponse + ) + # Confirm agency is in response + assert len(get_response_1.results) == 1 + proposal = get_response_1.results[0] + assert proposal.id == proposal_id + assert proposal.name == request.name + assert proposal.proposing_user_id == MOCK_USER_ID + assert proposal.agency_type == request.agency_type + assert proposal.jurisdiction_type == request.jurisdiction_type + assert [loc.location_id for loc in proposal.locations] == request.location_ids + assert proposal.created_at is not None + + # Call APPROVE endpoint + approve_response: ProposalAgencyApproveResponse = rv.post_v3( + f"/proposal/agencies/{proposal_id}/approve", + expected_model=ProposalAgencyApproveResponse + ) + assert approve_response.message == "Proposed agency approved." + assert approve_response.success + assert approve_response.agency_id is not None + agency_id: int = approve_response.agency_id + + # Check agency is added + agencies: list[Agency] = await adb_client.get_all(Agency) + assert len(agencies) == 1 + agency = agencies[0] + assert agency.name == request.name + assert agency.agency_type == request.agency_type + assert agency.jurisdiction_type == request.jurisdiction_type + + links: list[LinkAgencyLocation] = await adb_client.get_all(LinkAgencyLocation) + assert len(links) == 2 + assert {link.agency_id for link in links} == {agency.id} + assert {link.location_id for link in links} == set(request.location_ids) + + # Confirm agency is no longer in proposal queue + get_response_2: ProposalAgencyGetOuterResponse = rv.get_v3( + "/proposal/agencies", + expected_model=ProposalAgencyGetOuterResponse + ) + # Confirm agency is in response + assert len(get_response_2.results) == 0 + + # Try to submit agency again and confirm it fails + submit_response_accepted_duplicate: SubmitAgencyProposalResponse = rv.post_v3( + "/submit/agency", + expected_model=SubmitAgencyProposalResponse, + json=request.model_dump(mode="json") + ) + assert submit_response_accepted_duplicate.status == AgencyProposalRequestStatus.ACCEPTED_DUPLICATE + assert submit_response_accepted_duplicate.proposal_id is None + assert submit_response_accepted_duplicate.details == "An agency with the same properties is already approved." + + # Submit Separate Agency and Reject It + request_for_rejection = SubmitAgencyRequestModel( + name="Rejectable Agency", + agency_type=AgencyType.LAW_ENFORCEMENT, + jurisdiction_type=JurisdictionType.FEDERAL, + location_ids=[] + ) + submit_response_for_rejection: SubmitAgencyProposalResponse = rv.post_v3( + "/submit/agency", + expected_model=SubmitAgencyProposalResponse, + json=request_for_rejection.model_dump(mode="json") + ) + assert submit_response_for_rejection.status == AgencyProposalRequestStatus.SUCCESS + proposal_id_for_rejection: int = submit_response_for_rejection.proposal_id + + # Call REJECT endpoint + reject_response: ProposalAgencyRejectResponse = rv.post_v3( + f"/proposal/agencies/{proposal_id_for_rejection}/reject", + expected_model=ProposalAgencyRejectResponse, + json=ProposalAgencyRejectRequestModel( + rejection_reason="Test rejection reason" + ).model_dump(mode="json") + ) + assert reject_response.success + assert reject_response.message == "Proposed agency rejected." + + # Confirm does not appear in proposal queue OR final agency list + agencies = await adb_client.get_all(Agency) + assert len(agencies) == 1 + assert agencies[0].id == agency.id + + # Confirm cannot reject endpoint already approved + failed_reject_response: ProposalAgencyRejectResponse = rv.post_v3( + f"/proposal/agencies/{proposal_id}/reject", + expected_model=ProposalAgencyRejectResponse, + json=ProposalAgencyRejectRequestModel( + rejection_reason="Test rejection reason" + ).model_dump(mode="json") + ) + assert not failed_reject_response.success + assert failed_reject_response.message == "Proposed agency is not pending." + + # Confirm cannot approve endpoint already rejected + failed_approve_response: ProposalAgencyApproveResponse = rv.post_v3( + f"/proposal/agencies/{proposal_id_for_rejection}/approve", + expected_model=ProposalAgencyApproveResponse + ) + assert not failed_approve_response.success + assert failed_approve_response.message == "Proposed agency is not pending." +