Skip to content

Commit

Permalink
Merge pull request #214 from Police-Data-Accessibility-Project/mc_550…
Browse files Browse the repository at this point in the history
…_agencies_matching_endpoint

Mc 550 agencies matching endpoint
  • Loading branch information
maxachis authored Dec 11, 2024
2 parents 13ca852 + 528562d commit 323fdcc
Show file tree
Hide file tree
Showing 30 changed files with 466 additions and 171 deletions.
2 changes: 2 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from resources.LinkToGithub import namespace_link_to_github
from resources.LoginWithGithub import namespace_login_with_github
from resources.Map import namespace_map
from resources.Match import namespace_match
from resources.Notifications import namespace_notifications
from resources.OAuth import namespace_oauth
from resources.Permissions import namespace_permissions
Expand Down Expand Up @@ -66,6 +67,7 @@
namespace_map,
namespace_signup,
namespace_batch,
namespace_match,
]

MY_PREFIX = "/api"
Expand Down
1 change: 1 addition & 0 deletions database_client/database_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ def search_with_location_and_record_type(
:param locality: The locality to search for data sources in. If None, all data sources will be searched for.
:return: A list of dictionaries.
"""
optional_kwargs = {}
query = DynamicQueryConstructor.create_search_query(
state=state,
record_categories=record_categories,
Expand Down
140 changes: 140 additions & 0 deletions middleware/primary_resource_logic/match_logic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
from enum import Enum
from typing import Optional, List

from flask import Response

from database_client.database_client import DatabaseClient
from database_client.db_client_dataclasses import WhereMapping
from middleware.flask_response_manager import FlaskResponseManager
from middleware.schema_and_dto_logic.primary_resource_dtos.match_dtos import (
AgencyMatchOuterDTO,
AgencyMatchDTO,
)
from rapidfuzz import fuzz

from middleware.util import update_if_not_none


class AgencyMatchStatus(Enum):
EXACT = "Exact Match"
PARTIAL = "Partial Matches"
NO_MATCH = "No Match"


SIMILARITY_THRESHOLD = 80


def get_agency_match_message(status: AgencyMatchStatus):
match status:
case AgencyMatchStatus.EXACT:
return "Exact match found."
case AgencyMatchStatus.PARTIAL:
return "Partial matches found."
case AgencyMatchStatus.NO_MATCH:
return "No matches found."


class AgencyMatchResponse:

def __init__(self, status: AgencyMatchStatus, agencies: Optional[list] = None):
self.status = status
self.agencies = agencies
self.message = get_agency_match_message(status)


def match_agencies(db_client: DatabaseClient, dto: AgencyMatchOuterDTO):
amrs: List[AgencyMatchResponse] = []
for entry in dto.entries:
amr: AgencyMatchResponse = try_matching_agency(db_client=db_client, dto=entry)
amrs.append(amr)


def try_getting_exact_match_agency(dto: AgencyMatchDTO, agencies: list[dict]):
for agency in agencies:
if agency["submitted_name"] == dto.name:
return agency


def try_getting_partial_match_agencies(dto: AgencyMatchDTO, agencies: list[dict]):
partial_matches = []
for agency in agencies:
if fuzz.ratio(dto.name, agency["submitted_name"]) >= SIMILARITY_THRESHOLD:
partial_matches.append(agency)

return partial_matches

def format_response(amr: AgencyMatchResponse) -> Response:
data = {
"status": amr.status.value,
"message": amr.message,
}
update_if_not_none(dict_to_update=data, secondary_dict={"agencies": amr.agencies})
return FlaskResponseManager.make_response(
data=data,
)

def match_agency_wrapper(db_client: DatabaseClient, dto: AgencyMatchOuterDTO):
result = try_matching_agency(db_client=db_client, dto=dto)
return format_response(result)


def try_matching_agency(
db_client: DatabaseClient, dto: AgencyMatchDTO
) -> AgencyMatchResponse:

location_id = _get_location_id(db_client, dto)
if location_id is None:
return _no_match_response()

agencies = _get_agencies(db_client, location_id)
if len(agencies) == 0:
return _no_match_response()

exact_match_agency = try_getting_exact_match_agency(dto=dto, agencies=agencies)
if exact_match_agency is not None:
return _exact_match_response(exact_match_agency)

partial_match_agencies = try_getting_partial_match_agencies(
dto=dto, agencies=agencies
)
if len(partial_match_agencies) > 0:
return _partial_match_response(partial_match_agencies)

return _no_match_response()


def _partial_match_response(partial_match_agencies):
return AgencyMatchResponse(
status=AgencyMatchStatus.PARTIAL, agencies=partial_match_agencies
)


def _exact_match_response(exact_match_agency):
return AgencyMatchResponse(
status=AgencyMatchStatus.EXACT, agencies=[exact_match_agency]
)


def _no_match_response():
return AgencyMatchResponse(
status=AgencyMatchStatus.NO_MATCH,
)


def _get_agencies(db_client, location_id):
return db_client.get_agencies(
columns=["id", "submitted_name"],
where_mappings=WhereMapping.from_dict({"location_id": location_id}),
)


def _get_location_id(db_client, dto):
return db_client.get_location_id(
where_mappings=WhereMapping.from_dict(
{
"state_name": dto.state,
"county_name": dto.county,
"locality_name": dto.locality,
}
)
)
2 changes: 1 addition & 1 deletion middleware/primary_resource_logic/permissions_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def __init__(self, db_client: DatabaseClient, user_email: str):
try:
user_info = db_client.get_user_info(user_email)
except UserNotFoundError:
abort(HTTPStatus.NOT_FOUND, "User not found")
abort(HTTPStatus.BAD_REQUEST, "User not found")
return
self.db_client = db_client
self.user_email = user_email
Expand Down
2 changes: 1 addition & 1 deletion middleware/primary_resource_logic/search_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ def search_wrapper(
create_search_record(access_info, db_client, dto)
explicit_record_categories = get_explicit_record_categories(dto.record_categories)
search_results = db_client.search_with_location_and_record_type(
record_categories=explicit_record_categories,
state=dto.state,
# Pass modified record categories, which breaks down ALL into individual categories
record_categories=explicit_record_categories,
county=dto.county,
locality=dto.locality,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typing import Optional

from pydantic import BaseModel


class AgencyMatchDTO(BaseModel):
name: str
state: str
county: Optional[str]
locality: Optional[str]


class AgencyMatchOuterDTO(BaseModel):
entries: list[AgencyMatchDTO]
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from marshmallow import Schema, fields

from middleware.primary_resource_logic.match_logic import AgencyMatchStatus
from middleware.schema_and_dto_logic.common_response_schemas import MessageSchema
from middleware.schema_and_dto_logic.util import get_json_metadata


class AgencyMatchSchema(Schema):
name = fields.String(metadata=get_json_metadata("The name of the agency"))
state = fields.String(metadata=get_json_metadata("The state of the agency"))
county = fields.String(metadata=get_json_metadata("The county of the agency"))
locality = fields.String(metadata=get_json_metadata("The locality of the agency"))

class MatchAgenciesResultSchema(Schema):
submitted_name = fields.String(metadata=get_json_metadata("The name of the agency"))
id = fields.Integer(metadata=get_json_metadata("The id of the agency"))

class MatchAgencyResponseSchema(MessageSchema):

status = fields.Enum(
enum=AgencyMatchStatus,
by_value=fields.Str,
required=True,
metadata=get_json_metadata("The status of the match")
)
agencies = fields.List(
fields.Nested(
MatchAgenciesResultSchema(),
metadata=get_json_metadata("The list of results, if any")
),
required=False,
metadata=get_json_metadata("The list of results, if any")
)
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,5 @@ PyJWT~=2.9.0
marshmallow~=3.22.0
PyGithub~=2.4.0
dominate~=2.9.1
pre-commit~=4.0.1
pre-commit~=4.0.1
RapidFuzz~=3.10.1
30 changes: 30 additions & 0 deletions resources/Match.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from flask import Response

from middleware.access_logic import STANDARD_JWT_AUTH_INFO, AccessInfoPrimary
from middleware.decorators import endpoint_info
from middleware.primary_resource_logic.match_logic import try_matching_agency, match_agency_wrapper
from resources.PsycopgResource import PsycopgResource
from resources.endpoint_schema_config import SchemaConfigs
from resources.resource_helpers import ResponseInfo
from utilities.namespace import create_namespace, AppNamespaces

namespace_match = create_namespace(AppNamespaces.MATCH)


@namespace_match.route("/agency")
class MatchAgencies(PsycopgResource):

@endpoint_info(
namespace=namespace_match,
auth_info=STANDARD_JWT_AUTH_INFO,
schema_config=SchemaConfigs.MATCH_AGENCY,
response_info=ResponseInfo(
success_message="Found any possible matches for the search criteria."
),
description="Returns agencies, if any, that match or partially match the search criteria",
)
def post(self, access_info: AccessInfoPrimary) -> Response:
return self.run_endpoint(
wrapper_function=match_agency_wrapper,
schema_populate_parameters=SchemaConfigs.MATCH_AGENCY.value.get_schema_populate_parameters(),
)
10 changes: 10 additions & 0 deletions resources/endpoint_schema_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from middleware.schema_and_dto_logic.primary_resource_dtos.batch_dtos import (
BatchRequestDTO,
)
from middleware.schema_and_dto_logic.primary_resource_dtos.match_dtos import AgencyMatchDTO
from middleware.schema_and_dto_logic.primary_resource_dtos.reset_token_dtos import (
ResetPasswordDTO,
)
Expand All @@ -31,6 +32,8 @@
DataSourcesPostBatchRequestSchema,
DataSourcesPutBatchRequestSchema,
)
from middleware.schema_and_dto_logic.primary_resource_schemas.match_schemas import AgencyMatchSchema, \
MatchAgencyResponseSchema
from middleware.schema_and_dto_logic.primary_resource_schemas.reset_token_schemas import (
ResetPasswordSchema,
)
Expand Down Expand Up @@ -519,5 +522,12 @@ class SchemaConfigs(Enum):
input_dto_class=AgenciesPutDTO,
primary_output_schema=BatchPutResponseSchema(),
)
# endregion
# region Match
MATCH_AGENCY = EndpointSchemaConfig(
input_schema=AgencyMatchSchema(),
input_dto_class=AgencyMatchDTO,
primary_output_schema=MatchAgencyResponseSchema(),
)

# endregion
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,12 @@

from http import HTTPStatus

from flask import Response
from flask_jwt_extended import decode_token

from database_client.constants import PAGE_SIZE
from database_client.database_client import DatabaseClient

from tests.helper_scripts.simple_result_validators import (
check_response_status,
assert_is_oauth_redirect_link,
)


def assert_expected_pre_callback_response(response):
check_response_status(response, HTTPStatus.FOUND)
response_text = response.text
assert_is_oauth_redirect_link(response_text)
from tests.helper_scripts.constants import TEST_RESPONSE


def assert_api_key_exists_for_email(db_client: DatabaseClient, email: str, api_key):
Expand Down Expand Up @@ -50,3 +41,14 @@ def assert_contains_key_value_pairs(
assert key in dict_to_check, f"Expected {key} to be in {dict_to_check}"
dict_value = dict_to_check[key]
assert dict_value == value, f"Expected {key} to be {value}, was {dict_value}"


def assert_is_test_response(response):
assert_response_status(response, TEST_RESPONSE.status_code)
assert response.json == TEST_RESPONSE.response


def assert_response_status(response: Response, status_code):
assert (
response.status_code == status_code
), f"{response.request.base_url}: Expected status code {status_code}, got {response.status_code}: {response.text}"
Loading

0 comments on commit 323fdcc

Please sign in to comment.