From fbd35442070a2bff5ee5fc5ac9194dcfa057d0da Mon Sep 17 00:00:00 2001 From: Mike H Date: Thu, 5 Dec 2024 14:16:01 -0500 Subject: [PATCH] [Issue #3057] Create Agencies API (#3065) ## Summary Fixes #3057 ### Time to review: 30 mins ## Changes proposed Add agencies API supporting pagination and basic filtering (future requirements around shape TBD) ## Context for reviewers Open question: What should the schema return? Right now we are returning all Agency model fields. Maybe we should make this payload smaller depending on the frontend needs. ## Additional information See attached unit test --- api/openapi.generated.yml | 198 +++++++++++++++++- api/src/api/agencies_v1/__init__.py | 6 + api/src/api/agencies_v1/agency_blueprint.py | 9 + api/src/api/agencies_v1/agency_routes.py | 54 +++++ api/src/api/agencies_v1/agency_schema.py | 74 +++++++ api/src/app.py | 2 + api/src/services/agencies_v1/get_agencies.py | 62 ++++++ .../api/agencies_v1/test_agencies_routes.py | 79 +++++++ api/tests/src/db/models/factories.py | 19 +- 9 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 api/src/api/agencies_v1/__init__.py create mode 100644 api/src/api/agencies_v1/agency_blueprint.py create mode 100644 api/src/api/agencies_v1/agency_routes.py create mode 100644 api/src/api/agencies_v1/agency_schema.py create mode 100644 api/src/services/agencies_v1/get_agencies.py create mode 100644 api/tests/src/api/agencies_v1/test_agencies_routes.py diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index ce06aa570..e0369ed33 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -23,6 +23,7 @@ tags: - name: Health - name: Opportunity v1 - name: Extract v1 +- name: Agency v1 - name: User v1 servers: . paths: @@ -86,6 +87,47 @@ paths: sort_direction: descending security: - ApiKeyAuth: [] + /v1/agencies: + post: + parameters: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AgencyListResponse' + description: Successful response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Validation error + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Authentication error + tags: + - Agency v1 + summary: Agencies Get + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AgencyListRequest' + examples: + example1: + summary: No filters + value: + pagination: + order_by: created_at + page_offset: 1 + page_size: 25 + sort_direction: descending + security: + - ApiKeyAuth: [] /v1/users/token: post: parameters: @@ -693,6 +735,158 @@ components: type: integer description: The HTTP status code example: 200 + AgencyFilterV1: + type: object + properties: + agency_id: + type: integer + AgencyPaginationV1: + type: object + properties: + order_by: + type: string + enum: + - created_at + description: The field to sort the response by + sort_direction: + description: Whether to sort the response ascending or descending + enum: + - ascending + - descending + type: + - string + page_size: + type: integer + minimum: 1 + description: The size of the page to fetch + example: 25 + page_offset: + type: integer + minimum: 1 + description: The page number to fetch, starts counting from 1 + example: 1 + required: + - order_by + - page_offset + - page_size + - sort_direction + AgencyListRequest: + type: object + properties: + filters: + type: + - object + allOf: + - $ref: '#/components/schemas/AgencyFilterV1' + pagination: + type: + - object + allOf: + - $ref: '#/components/schemas/AgencyPaginationV1' + required: + - pagination + AgencyContactInfo: + type: object + properties: + contact_name: + type: string + description: Full name of the agency contact person + address_line_1: + type: string + description: Primary street address of the agency + address_line_2: + type: + - string + - 'null' + description: Additional address information (suite, unit, etc.) + city: + type: string + description: City where the agency is located + state: + type: string + description: State where the agency is located + zip_code: + type: string + description: Postal code for the agency address + phone_number: + type: string + description: Contact phone number for the agency + primary_email: + type: string + description: Main email address for agency communications + secondary_email: + type: + - string + - 'null' + description: Alternative email address for agency communications + AgencyResponse: + type: object + properties: + agency_id: + type: integer + agency_name: + type: string + agency_code: + type: string + sub_agency_code: + type: + - string + - 'null' + assistance_listing_number: + type: string + agency_submission_notification_setting: + type: string + top_level_agency: + type: + - object + allOf: + - $ref: '#/components/schemas/AgencyResponse' + agency_contact_info: + type: + - object + - 'null' + anyOf: + - $ref: '#/components/schemas/AgencyContactInfo' + - type: 'null' + agency_download_file_types: + type: array + description: List of download file types supported by the agency + items: + enum: + - xml + - pdf + type: + - string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + AgencyListResponse: + type: object + properties: + pagination_info: + description: The pagination information for paginated endpoints + type: *id001 + allOf: + - $ref: '#/components/schemas/PaginationInfo' + message: + type: string + description: The message to return + example: Success + data: + type: array + description: A list of agency records + items: + type: + - object + allOf: + - $ref: '#/components/schemas/AgencyResponse' + status_code: + type: integer + description: The HTTP status code + example: 200 User: type: object properties: @@ -859,7 +1053,7 @@ components: - archived type: - string - AgencyFilterV1: + AgencyFilterV11: type: object properties: one_of: @@ -1002,7 +1196,7 @@ components: type: - object allOf: - - $ref: '#/components/schemas/AgencyFilterV1' + - $ref: '#/components/schemas/AgencyFilterV11' assistance_listing_number: type: - object diff --git a/api/src/api/agencies_v1/__init__.py b/api/src/api/agencies_v1/__init__.py new file mode 100644 index 000000000..776504fcc --- /dev/null +++ b/api/src/api/agencies_v1/__init__.py @@ -0,0 +1,6 @@ +from src.api.agencies_v1.agency_blueprint import agency_blueprint + +# import agency_routes module to register the API routes on the blueprint +import src.api.agencies_v1.agency_routes # noqa: F401 E402 isort:skip + +__all__ = ["agency_blueprint"] diff --git a/api/src/api/agencies_v1/agency_blueprint.py b/api/src/api/agencies_v1/agency_blueprint.py new file mode 100644 index 000000000..673769023 --- /dev/null +++ b/api/src/api/agencies_v1/agency_blueprint.py @@ -0,0 +1,9 @@ +from apiflask import APIBlueprint + +agency_blueprint = APIBlueprint( + "agency_v1", + __name__, + tag="Agency v1", + cli_group="agency_v1", + url_prefix="/v1", +) diff --git a/api/src/api/agencies_v1/agency_routes.py b/api/src/api/agencies_v1/agency_routes.py new file mode 100644 index 000000000..e3bbfe901 --- /dev/null +++ b/api/src/api/agencies_v1/agency_routes.py @@ -0,0 +1,54 @@ +import logging + +import src.adapters.db as db +import src.adapters.db.flask_db as flask_db +import src.api.agencies_v1.agency_schema as agency_schema +import src.api.response as response +from src.api.agencies_v1.agency_blueprint import agency_blueprint +from src.auth.api_key_auth import api_key_auth +from src.logging.flask_logger import add_extra_data_to_current_request_logs +from src.services.agencies_v1.get_agencies import AgencyListParams, get_agencies + +logger = logging.getLogger(__name__) + +examples = { + "example1": { + "summary": "No filters", + "value": { + "pagination": { + "order_by": "created_at", + "page_offset": 1, + "page_size": 25, + "sort_direction": "descending", + }, + }, + }, +} + + +@agency_blueprint.post("/agencies") +@agency_blueprint.input( + agency_schema.AgencyListRequestSchema, + arg_name="raw_list_params", + examples=examples, +) +@agency_blueprint.output(agency_schema.AgencyListResponseSchema) +@agency_blueprint.auth_required(api_key_auth) +@flask_db.with_db_session() +def agencies_get(db_session: db.Session, raw_list_params: dict) -> response.ApiResponse: + list_params: AgencyListParams = AgencyListParams.model_validate(raw_list_params) + + # Call service with params to get results + with db_session.begin(): + results, pagination_info = get_agencies(db_session, list_params) + + add_extra_data_to_current_request_logs( + { + "response.pagination.total_pages": pagination_info.total_pages, + "response.pagination.total_records": pagination_info.total_records, + } + ) + logger.info("Successfully fetched agencies") + + # Serialize results + return response.ApiResponse(message="Success", data=results, pagination_info=pagination_info) diff --git a/api/src/api/agencies_v1/agency_schema.py b/api/src/api/agencies_v1/agency_schema.py new file mode 100644 index 000000000..e1fc8dd19 --- /dev/null +++ b/api/src/api/agencies_v1/agency_schema.py @@ -0,0 +1,74 @@ +from src.api.schemas.extension import Schema, fields +from src.api.schemas.response_schema import AbstractResponseSchema, PaginationMixinSchema +from src.constants.lookup_constants import AgencyDownloadFileType +from src.pagination.pagination_schema import generate_pagination_schema + + +class AgencyFilterV1Schema(Schema): + agency_id = fields.Integer() + + +class AgencyListRequestSchema(Schema): + filters = fields.Nested(AgencyFilterV1Schema()) + pagination = fields.Nested( + generate_pagination_schema( + "AgencyPaginationV1Schema", + ["created_at"], + ), + required=True, + ) + + +class AgencyContactInfoSchema(Schema): + """Schema for agency contact information""" + + contact_name = fields.String(metadata={"description": "Full name of the agency contact person"}) + address_line_1 = fields.String(metadata={"description": "Primary street address of the agency"}) + address_line_2 = fields.String( + allow_none=True, + metadata={"description": "Additional address information (suite, unit, etc.)"}, + ) + city = fields.String(metadata={"description": "City where the agency is located"}) + state = fields.String(metadata={"description": "State where the agency is located"}) + zip_code = fields.String(metadata={"description": "Postal code for the agency address"}) + phone_number = fields.String(metadata={"description": "Contact phone number for the agency"}) + primary_email = fields.String( + metadata={"description": "Main email address for agency communications"} + ) + secondary_email = fields.String( + allow_none=True, + metadata={"description": "Alternative email address for agency communications"}, + ) + + +class AgencyResponseSchema(Schema): + """Schema for agency response""" + + agency_id = fields.Integer() + agency_name = fields.String() + agency_code = fields.String() + sub_agency_code = fields.String(allow_none=True) + assistance_listing_number = fields.String() + agency_submission_notification_setting = fields.String() # Enum value + + top_level_agency = fields.Nested(lambda: AgencyResponseSchema(exclude=("top_level_agency",))) + + # Agency contact info as nested object + agency_contact_info = fields.Nested(AgencyContactInfoSchema, allow_none=True) + + # File types as a list of strings + agency_download_file_types = fields.List( + fields.Enum(AgencyDownloadFileType), + metadata={"description": "List of download file types supported by the agency"}, + ) + + # Add timestamps from TimestampMixin + created_at = fields.DateTime() + updated_at = fields.DateTime() + + +class AgencyListResponseSchema(AbstractResponseSchema, PaginationMixinSchema): + data = fields.List( + fields.Nested(AgencyResponseSchema), + metadata={"description": "A list of agency records"}, + ) diff --git a/api/src/app.py b/api/src/app.py index fe92d3a1d..9a78e19e1 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -13,6 +13,7 @@ import src.api.feature_flags.feature_flag_config as feature_flag_config import src.logging import src.logging.flask_logger as flask_logger +from src.api.agencies_v1 import agency_blueprint as agencies_v1_blueprint from src.api.extracts_v1 import extract_blueprint as extracts_v1_blueprint from src.api.healthcheck import healthcheck_blueprint from src.api.opportunities_v0 import opportunity_blueprint as opportunities_v0_blueprint @@ -134,6 +135,7 @@ def register_blueprints(app: APIFlask) -> None: app.register_blueprint(opportunities_v0_1_blueprint) app.register_blueprint(opportunities_v1_blueprint) app.register_blueprint(extracts_v1_blueprint) + app.register_blueprint(agencies_v1_blueprint) auth_endpoint_config = AuthEndpointConfig() if auth_endpoint_config.auth_endpoint: diff --git a/api/src/services/agencies_v1/get_agencies.py b/api/src/services/agencies_v1/get_agencies.py new file mode 100644 index 000000000..af4481995 --- /dev/null +++ b/api/src/services/agencies_v1/get_agencies.py @@ -0,0 +1,62 @@ +import logging +from typing import Sequence, Tuple + +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.orm import joinedload + +import src.adapters.db as db +from src.db.models.agency_models import Agency +from src.pagination.pagination_models import PaginationInfo, PaginationParams +from src.pagination.paginator import Paginator + +logger = logging.getLogger(__name__) + + +class AgencyFilters(BaseModel): + agency_id: int | None = None + agency_name: str | None = None + + +class AgencyListParams(BaseModel): + pagination: PaginationParams + + filters: AgencyFilters | None = Field(default_factory=AgencyFilters) + + +def get_agencies( + db_session: db.Session, list_params: AgencyListParams +) -> Tuple[Sequence[Agency], PaginationInfo]: + stmt = select(Agency).options(joinedload(Agency.top_level_agency), joinedload("*")) + + # Exclude test agencies + stmt = stmt.where(Agency.is_test_agency != True) # noqa: E712 + + if list_params.filters: + if list_params.filters.agency_name: + stmt = stmt.where(Agency.agency_name == list_params.filters.agency_name) + + # Execute the query and fetch all agencies + agencies = db_session.execute(stmt).unique().scalars().all() + + # Create a dictionary to map agency names to agency instances + agency_dict = {agency.agency_name: agency for agency in agencies} + + # Process top-level agencies + for agency in agencies: + if "-" in agency.agency_name: + top_level_name = agency.agency_name.split("-")[0].strip() + # Find the top-level agency using the dictionary + top_level_agency = agency_dict.get(top_level_name) + if top_level_agency: + agency.top_level_agency = top_level_agency + + # Apply pagination after processing + paginator: Paginator[Agency] = Paginator( + Agency, stmt, db_session, page_size=list_params.pagination.page_size + ) + + paginated_agencies = paginator.page_at(page_offset=list_params.pagination.page_offset) + pagination_info = PaginationInfo.from_pagination_params(list_params.pagination, paginator) + + return paginated_agencies, pagination_info diff --git a/api/tests/src/api/agencies_v1/test_agencies_routes.py b/api/tests/src/api/agencies_v1/test_agencies_routes.py new file mode 100644 index 000000000..d4dbdaeef --- /dev/null +++ b/api/tests/src/api/agencies_v1/test_agencies_routes.py @@ -0,0 +1,79 @@ +import pytest + +from src.db.models.agency_models import Agency +from tests.conftest import BaseTestClass +from tests.src.db.models.factories import AgencyFactory + + +class TestAgenciesRoutes(BaseTestClass): + @pytest.fixture(scope="class", autouse=True) + def cleanup_agencies(self, db_session): + yield + + # Use no_autoflush to prevent premature flushes + with db_session.no_autoflush: + # Fetch all agencies + agencies = db_session.query(Agency).all() + # Delete each agency + for agency in agencies: + db_session.delete(agency) + + db_session.commit() + + def test_agencies_get_default_dates( + self, client, api_auth_token, enable_factory_create, db_session + ): + # These should return in the default date range + AgencyFactory.create_batch(4) + + # These should be excluded + AgencyFactory.create_batch(3, is_test_agency=True) + + payload = { + "filters": {}, + "pagination": { + "page": 1, + "page_size": 10, + "page_offset": 1, + "order_by": "created_at", + "sort_direction": "descending", + }, + } + response = client.post("/v1/agencies", headers={"X-Auth": api_auth_token}, json=payload) + assert response.status_code == 200 + data = response.json["data"] + assert len(data) == 4 + + def test_agencies_get_with_sub_agencies( + self, client, api_auth_token, enable_factory_create, db_session + ): + # Create top-level agencies + hhs = AgencyFactory.create(agency_name="HHS") + dod = AgencyFactory.create(agency_name="DOD") + + # Create sub-agencies + AgencyFactory.create(agency_name="HHS-AOA", top_level_agency=hhs) + AgencyFactory.create(agency_name="HHS-CDC", top_level_agency=hhs) + AgencyFactory.create(agency_name="DOD-ARMY", top_level_agency=dod) + AgencyFactory.create(agency_name="DOD-NAVY", top_level_agency=dod) + + payload = { + "filters": {}, + "pagination": { + "page": 1, + "page_size": 10, + "page_offset": 1, + "order_by": "created_at", + "sort_direction": "descending", + }, + } + + response = client.post("/v1/agencies", headers={"X-Auth": api_auth_token}, json=payload) + assert response.status_code == 200 + data = response.json["data"] + + # Verify the relationships + for agency in data: + if "-" in agency["agency_name"]: + top_level_name = agency["agency_name"].split("-")[0] + assert agency["top_level_agency"]["agency_name"] == top_level_name diff --git a/api/tests/src/db/models/factories.py b/api/tests/src/db/models/factories.py index 3ad8b9ac0..13c94bba8 100644 --- a/api/tests/src/db/models/factories.py +++ b/api/tests/src/db/models/factories.py @@ -797,9 +797,20 @@ class AgencyFactory(BaseFactory): class Meta: model = agency_models.Agency + @classmethod + def _setup_next_sequence(cls): + if _db_session is not None: + value = _db_session.query(func.max(agency_models.Agency.agency_id)).scalar() + if value is not None: + return value + 1 + + return 1 + + agency_id = factory.Sequence(lambda n: n) agency_name = factory.Faker("agency_name") - agency_code = factory.Faker("agency_code") + agency_code = factory.Iterator(CustomProvider.AGENCIES) + sub_agency_code = factory.LazyAttribute(lambda a: a.agency_code.split("-")[0]) assistance_listing_number = factory.Faker("random_int", min=1, max=999) @@ -833,6 +844,12 @@ class Meta: unique=True, ) + # Create the contact info first and use its ID + agency_contact_info = factory.SubFactory(AgencyContactInfoFactory) + agency_contact_info_id = factory.LazyAttribute( + lambda a: a.agency_contact_info.agency_contact_info_id if a.agency_contact_info else None + ) + #################################### # Staging Table Factories