From 777321fdb82f3338a2d84fd9c546a343e884e5fc Mon Sep 17 00:00:00 2001 From: Max Chis Date: Thu, 25 Sep 2025 16:45:14 -0400 Subject: [PATCH 1/2] Create /search/agency endpoint with test --- src/api/endpoints/search/agency/__init__.py | 0 .../search/agency/models/__init__.py | 0 .../search/agency/models/response.py | 7 ++ src/api/endpoints/search/agency/query.py | 66 +++++++++++++++++++ src/api/endpoints/search/routes.py | 22 ++++++- .../integration/api/search/__init__.py | 0 .../integration/api/search/agency/__init__.py | 0 .../api/search/agency/test_search.py | 53 +++++++++++++++ .../integration/api/search/url/__init__.py | 0 .../api/{ => search/url}/test_search.py | 0 .../data_creator/commands/impl/agency.py | 13 +++- tests/helpers/data_creator/core.py | 4 +- tests/helpers/simple_test_data_functions.py | 6 +- 13 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 src/api/endpoints/search/agency/__init__.py create mode 100644 src/api/endpoints/search/agency/models/__init__.py create mode 100644 src/api/endpoints/search/agency/models/response.py create mode 100644 src/api/endpoints/search/agency/query.py create mode 100644 tests/automated/integration/api/search/__init__.py create mode 100644 tests/automated/integration/api/search/agency/__init__.py create mode 100644 tests/automated/integration/api/search/agency/test_search.py create mode 100644 tests/automated/integration/api/search/url/__init__.py rename tests/automated/integration/api/{ => search/url}/test_search.py (100%) diff --git a/src/api/endpoints/search/agency/__init__.py b/src/api/endpoints/search/agency/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/api/endpoints/search/agency/models/__init__.py b/src/api/endpoints/search/agency/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/api/endpoints/search/agency/models/response.py b/src/api/endpoints/search/agency/models/response.py new file mode 100644 index 00000000..c7ed4460 --- /dev/null +++ b/src/api/endpoints/search/agency/models/response.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class AgencySearchResponse(BaseModel): + agency_id: int + agency_name: str + location_display_name: str diff --git a/src/api/endpoints/search/agency/query.py b/src/api/endpoints/search/agency/query.py new file mode 100644 index 00000000..7873c16c --- /dev/null +++ b/src/api/endpoints/search/agency/query.py @@ -0,0 +1,66 @@ +from typing import Any, Sequence + +from sqlalchemy import select, func, RowMapping +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.endpoints.search.agency.models.response import AgencySearchResponse +from src.db import Location +from src.db.models.impl.agency.sqlalchemy import Agency +from src.db.models.impl.link.agency_location.sqlalchemy import LinkAgencyLocation +from src.db.models.views.location_expanded import LocationExpandedView +from src.db.queries.base.builder import QueryBuilderBase + +from src.db.helpers.session import session_helper as sh + +class SearchAgencyQueryBuilder(QueryBuilderBase): + + def __init__( + self, + location_id: int | None, + query: str + ): + super().__init__() + self.location_id = location_id + self.query = query + + async def run(self, session: AsyncSession) -> list[AgencySearchResponse]: + + query = ( + select( + Agency.agency_id, + Agency.name.label("agency_name"), + LocationExpandedView.display_name.label("location_display_name") + ) + .join( + LinkAgencyLocation, + LinkAgencyLocation.agency_id == Agency.agency_id + ) + .join( + LocationExpandedView, + LocationExpandedView.id == LinkAgencyLocation.location_id + ) + ) + + if self.location_id is not None: + query = query.where( + LocationExpandedView.id == self.location_id + ) + query = query.order_by( + func.similarity( + Agency.name, + self.query + ).desc() + ) + + mappings: Sequence[RowMapping] = await sh.mappings(session, query) + + return [ + AgencySearchResponse( + **mapping + ) + for mapping in mappings + ] + + + + diff --git a/src/api/endpoints/search/routes.py b/src/api/endpoints/search/routes.py index a1b576f2..a8e5296e 100644 --- a/src/api/endpoints/search/routes.py +++ b/src/api/endpoints/search/routes.py @@ -1,6 +1,8 @@ from fastapi import APIRouter, Query, Depends from src.api.dependencies import get_async_core +from src.api.endpoints.search.agency.models.response import AgencySearchResponse +from src.api.endpoints.search.agency.query import SearchAgencyQueryBuilder from src.api.endpoints.search.dtos.response import SearchURLResponse from src.core.core import AsyncCore from src.security.manager import get_access_info @@ -18,4 +20,22 @@ async def search_url( """ Search for a URL in the database """ - return await async_core.search_for_url(url) \ No newline at end of file + return await async_core.search_for_url(url) + + +@search_router.get("/agency") +async def search_agency( + location_id: int | None = Query( + description="The location id to search for", + default=None + ), + query: str = Query(description="The query to search for"), + access_info: AccessInfo = Depends(get_access_info), + async_core: AsyncCore = Depends(get_async_core), +) -> list[AgencySearchResponse]: + return await async_core.adb_client.run_query_builder( + SearchAgencyQueryBuilder( + location_id=location_id, + query=query + ) + ) \ No newline at end of file diff --git a/tests/automated/integration/api/search/__init__.py b/tests/automated/integration/api/search/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/automated/integration/api/search/agency/__init__.py b/tests/automated/integration/api/search/agency/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/automated/integration/api/search/agency/test_search.py b/tests/automated/integration/api/search/agency/test_search.py new file mode 100644 index 00000000..7b475ace --- /dev/null +++ b/tests/automated/integration/api/search/agency/test_search.py @@ -0,0 +1,53 @@ +import pytest + +from tests.helpers.api_test_helper import APITestHelper +from tests.helpers.data_creator.core import DBDataCreator +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_search_agency( + api_test_helper: APITestHelper, + db_data_creator: DBDataCreator, + pittsburgh_locality: LocalityCreationInfo, + allegheny_county: CountyCreationInfo +): + + agency_a_id: int = await db_data_creator.agency("A Agency") + agency_b_id: int = await db_data_creator.agency("AB Agency") + agency_c_id: int = await db_data_creator.agency("ABC Agency") + + await db_data_creator.link_agencies_to_location( + agency_ids=[agency_a_id, agency_c_id], + location_id=pittsburgh_locality.location_id + ) + await db_data_creator.link_agencies_to_location( + agency_ids=[agency_b_id], + location_id=allegheny_county.location_id + ) + + responses: list[dict] = api_test_helper.request_validator.get_v2( + url="/search/agency", + params={ + "query": "A Agency", + } + ) + assert len(responses) == 3 + assert responses[0]["agency_id"] == agency_a_id + assert responses[1]["agency_id"] == agency_b_id + assert responses[2]["agency_id"] == agency_c_id + + responses = api_test_helper.request_validator.get_v2( + url="/search/agency", + params={ + "query": "A Agency", + "location_id": pittsburgh_locality.location_id + } + ) + + assert len(responses) == 2 + assert responses[0]["agency_id"] == agency_a_id + assert responses[1]["agency_id"] == agency_c_id + + diff --git a/tests/automated/integration/api/search/url/__init__.py b/tests/automated/integration/api/search/url/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/automated/integration/api/test_search.py b/tests/automated/integration/api/search/url/test_search.py similarity index 100% rename from tests/automated/integration/api/test_search.py rename to tests/automated/integration/api/search/url/test_search.py diff --git a/tests/helpers/data_creator/commands/impl/agency.py b/tests/helpers/data_creator/commands/impl/agency.py index 97b27a1a..0bf04ce6 100644 --- a/tests/helpers/data_creator/commands/impl/agency.py +++ b/tests/helpers/data_creator/commands/impl/agency.py @@ -6,10 +6,21 @@ from src.core.enums import SuggestionType from src.core.tasks.url.operators.agency_identification.dtos.suggestion import URLAgencySuggestionInfo from tests.helpers.data_creator.commands.base import DBDataCreatorCommandBase +from tests.helpers.simple_test_data_functions import generate_test_name + @final class AgencyCommand(DBDataCreatorCommandBase): + def __init__( + self, + name: str | None = None + ): + super().__init__() + if name is None: + name = generate_test_name() + self.name = name + @override async def run(self) -> int: agency_id = randint(1, 99999999) @@ -19,7 +30,7 @@ async def run(self) -> int: url_id=-1, suggestion_type=SuggestionType.UNKNOWN, pdap_agency_id=agency_id, - agency_name=f"Test Agency {agency_id}", + agency_name=self.name, state=f"Test State {agency_id}", county=f"Test County {agency_id}", locality=f"Test Locality {agency_id}" diff --git a/tests/helpers/data_creator/core.py b/tests/helpers/data_creator/core.py index 8a2c7ef5..0efe279d 100644 --- a/tests/helpers/data_creator/core.py +++ b/tests/helpers/data_creator/core.py @@ -156,8 +156,8 @@ async def batch_and_urls( urls=[iui.url for iui in iuis.url_mappings] ) - async def agency(self) -> int: - return await self.run_command(AgencyCommand()) + async def agency(self, name: str | None = None) -> int: + return await self.run_command(AgencyCommand(name)) async def auto_relevant_suggestions(self, url_id: int, relevant: bool = True): await self.run_command( diff --git a/tests/helpers/simple_test_data_functions.py b/tests/helpers/simple_test_data_functions.py index 7c42fd8d..4d321dc5 100644 --- a/tests/helpers/simple_test_data_functions.py +++ b/tests/helpers/simple_test_data_functions.py @@ -4,6 +4,8 @@ """ import uuid +from tests.helpers.counter import next_int + def generate_test_urls(count: int) -> list[str]: results = [] @@ -17,7 +19,9 @@ def generate_test_urls(count: int) -> list[str]: def generate_test_url(i: int) -> str: return f"https://test.com/{i}" -def generate_test_name(i: int) -> str: +def generate_test_name(i: int | None = None) -> str: + if i is None: + return f"Test Name {next_int()}" return f"Test Name {i}" def generate_test_description(i: int) -> str: From c13f9cea005fc8da366410e12e6664da2d4d5e65 Mon Sep 17 00:00:00 2001 From: Max Chis Date: Thu, 25 Sep 2025 16:50:09 -0400 Subject: [PATCH 2/2] Create /search/agency endpoint with test --- src/api/endpoints/search/agency/query.py | 20 ++++++++++---------- src/api/endpoints/search/routes.py | 15 +++++++++++++-- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/api/endpoints/search/agency/query.py b/src/api/endpoints/search/agency/query.py index 7873c16c..d3bda3ef 100644 --- a/src/api/endpoints/search/agency/query.py +++ b/src/api/endpoints/search/agency/query.py @@ -1,23 +1,22 @@ -from typing import Any, Sequence +from typing import Sequence from sqlalchemy import select, func, RowMapping from sqlalchemy.ext.asyncio import AsyncSession from src.api.endpoints.search.agency.models.response import AgencySearchResponse -from src.db import Location +from src.db.helpers.session import session_helper as sh from src.db.models.impl.agency.sqlalchemy import Agency from src.db.models.impl.link.agency_location.sqlalchemy import LinkAgencyLocation from src.db.models.views.location_expanded import LocationExpandedView from src.db.queries.base.builder import QueryBuilderBase -from src.db.helpers.session import session_helper as sh class SearchAgencyQueryBuilder(QueryBuilderBase): def __init__( self, location_id: int | None, - query: str + query: str | None ): super().__init__() self.location_id = location_id @@ -45,12 +44,13 @@ async def run(self, session: AsyncSession) -> list[AgencySearchResponse]: query = query.where( LocationExpandedView.id == self.location_id ) - query = query.order_by( - func.similarity( - Agency.name, - self.query - ).desc() - ) + if self.query is not None: + query = query.order_by( + func.similarity( + Agency.name, + self.query + ).desc() + ) mappings: Sequence[RowMapping] = await sh.mappings(session, query) diff --git a/src/api/endpoints/search/routes.py b/src/api/endpoints/search/routes.py index a8e5296e..393387d9 100644 --- a/src/api/endpoints/search/routes.py +++ b/src/api/endpoints/search/routes.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Query, Depends + +from fastapi import APIRouter, Query, Depends, HTTPException +from starlette import status from src.api.dependencies import get_async_core from src.api.endpoints.search.agency.models.response import AgencySearchResponse @@ -29,10 +31,19 @@ async def search_agency( description="The location id to search for", default=None ), - query: str = Query(description="The query to search for"), + query: str | None = Query( + description="The query to search for", + default=None + ), access_info: AccessInfo = Depends(get_access_info), async_core: AsyncCore = Depends(get_async_core), ) -> list[AgencySearchResponse]: + if query is None and location_id is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="At least one of query or location_id must be provided" + ) + return await async_core.adb_client.run_query_builder( SearchAgencyQueryBuilder( location_id=location_id,