Skip to content

Commit

Permalink
[Issue #3057] Create Agencies API (#3065)
Browse files Browse the repository at this point in the history
## 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
  • Loading branch information
mikehgrantsgov authored Dec 5, 2024
1 parent cee2bbb commit fbd3544
Show file tree
Hide file tree
Showing 9 changed files with 500 additions and 3 deletions.
198 changes: 196 additions & 2 deletions api/openapi.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ tags:
- name: Health
- name: Opportunity v1
- name: Extract v1
- name: Agency v1
- name: User v1
servers: .
paths:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -859,7 +1053,7 @@ components:
- archived
type:
- string
AgencyFilterV1:
AgencyFilterV11:
type: object
properties:
one_of:
Expand Down Expand Up @@ -1002,7 +1196,7 @@ components:
type:
- object
allOf:
- $ref: '#/components/schemas/AgencyFilterV1'
- $ref: '#/components/schemas/AgencyFilterV11'
assistance_listing_number:
type:
- object
Expand Down
6 changes: 6 additions & 0 deletions api/src/api/agencies_v1/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
9 changes: 9 additions & 0 deletions api/src/api/agencies_v1/agency_blueprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from apiflask import APIBlueprint

agency_blueprint = APIBlueprint(
"agency_v1",
__name__,
tag="Agency v1",
cli_group="agency_v1",
url_prefix="/v1",
)
54 changes: 54 additions & 0 deletions api/src/api/agencies_v1/agency_routes.py
Original file line number Diff line number Diff line change
@@ -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)
74 changes: 74 additions & 0 deletions api/src/api/agencies_v1/agency_schema.py
Original file line number Diff line number Diff line change
@@ -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"},
)
2 changes: 2 additions & 0 deletions api/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading

0 comments on commit fbd3544

Please sign in to comment.