Skip to content

Commit

Permalink
[Platformization] Create POST endpoint to add new config (Recidiviz/r…
Browse files Browse the repository at this point in the history
…ecidiviz-data#29373)

## Description of the change

This PR enables `POST` on the
`<state_code_str>/opportunities/<opportunity_type>/configurations`
endpoint. It is mostly a wrapper around the querier `add_config()`
method. The endpoint uses the current timestamp as well as the
authenticated user information to populated the creation fields.

## Type of change

> All pull requests must have at least one of the following labels
applied (otherwise the PR will fail):

| Label | Description |
|-----------------------------
|-----------------------------------------------------------------------------------------------------------
|
| Type: Bug | non-breaking change that fixes an issue |
| Type: Feature | non-breaking change that adds functionality |
| Type: Breaking Change | fix or feature that would cause existing
functionality to not work as expected |
| Type: Non-breaking refactor | change addresses some tech debt item or
prepares for a later change, but does not change functionality |
| Type: Configuration Change | adjusts configuration to achieve some end
related to functionality, development, performance, or security |
| Type: Dependency Upgrade | upgrades a project dependency - these
changes are not included in release notes |

## Related issues

Closes Recidiviz/recidiviz-data#29090

## Checklists

### Development

**This box MUST be checked by the submitter prior to merging**:
- [x] **Double- and triple-checked that there is no Personally
Identifiable Information (PII) being mistakenly added in this pull
request**

These boxes should be checked by the submitter prior to merging:
- [x] Tests have been written to cover the code changed/added as part of
this pull request

### Code review

These boxes should be checked by reviewers prior to merging:

- [ ] This pull request has a descriptive title and information useful
to a reviewer
- [ ] Potential security implications or infrastructural changes have
been considered, if relevant

GitOrigin-RevId: c4199edf0445740bb17677185f833dc14dd551c3
  • Loading branch information
Catacola authored and Helper Bot committed May 11, 2024
1 parent c95a02b commit b363f47
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 19 deletions.
16 changes: 12 additions & 4 deletions recidiviz/admin_panel/line_staff_tools/workflows_api_schemas.py
Expand Up @@ -48,16 +48,24 @@ class OpportunitySchema(CamelCaseSchema):
gating_feature_variant = fields.Str(required=False)


class OpportunityConfigurationSchema(WorkflowsConfigSchema):
class OpportunityConfigurationRequestSchema(WorkflowsConfigSchema):
"""
Schema representing and opportunity configuration with additional
information used in the admin panel.
Schema representing an opportunity configuration to add to the database.
Contains additional metadata not shown in the tool.
"""

description = fields.Str(required=True)
status = fields.Enum(OpportunityStatus, required=True)


class OpportunityConfigurationResponseSchema(OpportunityConfigurationRequestSchema):
"""
Schema representing an opportunity configuration in the database with additional
metadata to be displayed in the admin panel.
"""

created_at = fields.Str(required=True)
created_by = fields.Str(required=True)
status = fields.Enum(OpportunityStatus, required=True)


class OpportunityConfigurationsQueryArgs(CamelOrSnakeCaseSchema):
Expand Down
56 changes: 51 additions & 5 deletions recidiviz/admin_panel/routes/workflows.py
Expand Up @@ -15,7 +15,8 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# =============================================================================
"""Admin panel routes for configuring workflows settings."""

import datetime
import logging
from http import HTTPStatus
from typing import Any, Dict, List

Expand All @@ -24,11 +25,13 @@

from recidiviz.admin_panel.admin_stores import fetch_state_codes
from recidiviz.admin_panel.line_staff_tools.workflows_api_schemas import (
OpportunityConfigurationSchema,
OpportunityConfigurationRequestSchema,
OpportunityConfigurationResponseSchema,
OpportunityConfigurationsQueryArgs,
OpportunitySchema,
StateCodeSchema,
)
from recidiviz.auth.helpers import get_authenticated_user_email
from recidiviz.calculator.query.state.views.outliers.workflows_enabled_states import (
get_workflows_enabled_states,
)
Expand Down Expand Up @@ -79,22 +82,23 @@ def get(self, state_code_str: str) -> List[FullOpportunityInfo]:
"<state_code_str>/opportunities/<opportunity_type>/configurations"
)
class OpportunityConfigurationsAPI(MethodView):
"""Endpoint to list configs for a given workflow type."""
"""Implementation of <state_code_str>/opportunities/<opportunity_type>/configurations endpoints."""

@workflows_blueprint.arguments(
OpportunityConfigurationsQueryArgs,
location="query",
error_status_code=HTTPStatus.BAD_REQUEST,
)
@workflows_blueprint.response(
HTTPStatus.OK, OpportunityConfigurationSchema(many=True)
HTTPStatus.OK, OpportunityConfigurationResponseSchema(many=True)
)
def get(
self,
query_args: Dict[str, Any],
state_code_str: str,
opportunity_type: str,
) -> List[FullOpportunityConfig]:
"""Endpoint to list configs for a given workflow type."""
offset = query_args.get("offset", 0)
status = query_args.get("status", None)

Expand All @@ -104,14 +108,56 @@ def get(
)
return configs

@workflows_blueprint.arguments(
OpportunityConfigurationRequestSchema,
location="json",
error_status_code=HTTPStatus.BAD_REQUEST,
)
@workflows_blueprint.response(HTTPStatus.OK)
def post(
self,
body_args: Dict[str, Any],
state_code_str: str,
opportunity_type: str,
) -> int:
"""Endpoint to create a new config."""
state_code = refine_state_code(state_code_str)
user_email, error_str = get_authenticated_user_email()
if error_str:
logging.error("Error determining logged-in user: %s", error_str)
abort(HTTPStatus.BAD_REQUEST, message=error_str)

new_config_id = WorkflowsQuerier(state_code).add_config(
opportunity_type,
created_by=user_email,
created_at=datetime.datetime.now(),
description=body_args["description"],
feature_variant=body_args["feature_variant"],
display_name=body_args["display_name"],
methodology_url=body_args["methodology_url"],
is_alert=body_args["is_alert"],
initial_header=body_args["initial_header"],
denial_reasons=body_args["denial_reasons"],
eligible_criteria_copy=body_args["eligible_criteria_copy"],
ineligible_criteria_copy=body_args["ineligible_criteria_copy"],
dynamic_eligibility_text=body_args["dynamic_eligibility_text"],
call_to_action=body_args["call_to_action"],
denial_text=body_args["denial_text"],
snooze=body_args["snooze"],
sidebar_components=body_args["sidebar_components"],
)
return new_config_id


@workflows_blueprint.route(
"<state_code_str>/opportunities/<opportunity_type>/configurations/<int:config_id>"
)
class OpportunitySingleConfigurationAPI(MethodView):
"""Endpoint to retrieve a config given an id."""

@workflows_blueprint.response(HTTPStatus.OK, OpportunityConfigurationSchema())
@workflows_blueprint.response(
HTTPStatus.OK, OpportunityConfigurationResponseSchema()
)
def get(
self,
state_code_str: str,
Expand Down
1 change: 1 addition & 0 deletions recidiviz/case_triage/workflows/api_schemas.py
Expand Up @@ -187,6 +187,7 @@ class CriteriaCopySchema(CamelCaseSchema):
)
sidebar_components = fields.List(fields.Str())
methodology_url = fields.Str()
is_alert = fields.Bool()


class WorkflowsFullConfigSchema(WorkflowsConfigSchema):
Expand Down
Expand Up @@ -44,9 +44,10 @@
"featureVariant": "feature_variant",
"ineligibleCriteriaCopy": {},
"initialHeader": "header",
"isAlert": False,
"methodologyUrl": "url",
"sidebarComponents": ["someComponent"],
"snooze": {},
"snooze": {"defaultSnoozeDays": 30, "maxSnoozeDays": 180},
"stateCode": "US_ID",
"status": "ACTIVE",
},
Expand All @@ -63,9 +64,10 @@
"featureVariant": "feature_variant",
"ineligibleCriteriaCopy": {},
"initialHeader": "header",
"isAlert": False,
"methodologyUrl": "url",
"sidebarComponents": ["someComponent"],
"snooze": {},
"snooze": {"defaultSnoozeDays": 30, "maxSnoozeDays": 180},
"stateCode": "US_ID",
"status": "INACTIVE",
},
Expand All @@ -82,9 +84,10 @@
"featureVariant": "feature_variant",
"ineligibleCriteriaCopy": {},
"initialHeader": "header",
"isAlert": False,
"methodologyUrl": "url",
"sidebarComponents": ["someComponent"],
"snooze": {},
"snooze": {"defaultSnoozeDays": 30, "maxSnoozeDays": 180},
"stateCode": "US_ID",
"status": "ACTIVE",
},
Expand Down
80 changes: 78 additions & 2 deletions recidiviz/tests/admin_panel/routes/workflows_test.py
Expand Up @@ -24,6 +24,7 @@
import pytest
from flask import Flask, url_for
from flask_smorest import Api
from freezegun import freeze_time

from recidiviz.admin_panel.all_routes import admin_panel_blueprint
from recidiviz.admin_panel.routes.workflows import workflows_blueprint
Expand Down Expand Up @@ -51,7 +52,7 @@ def generate_config(
ineligible_criteria_copy={},
dynamic_eligibility_text="text",
call_to_action="do something",
snooze={},
snooze={"default_snooze_days": 30, "max_snooze_days": 180},
is_alert=False,
sidebar_components=["someComponent"],
denial_text="Deny",
Expand Down Expand Up @@ -174,7 +175,7 @@ def test_get_opportunities(
self.assertEqual(expected_response, response.json)

########
# GET /workflows/<state_code>/<opportunity_type>/configurations
# GET /workflows/<state_code>/opportunities/<opportunity_type>/configurations
########
@patch(
"recidiviz.admin_panel.routes.workflows.WorkflowsQuerier",
Expand Down Expand Up @@ -223,6 +224,81 @@ def test_get_configs_for_opportunity_passes_query_params(
status=TEST_STATUS,
)

########
# POST /workflows/<state_code>/opportunities/<opportunity_type>/configurations
########
@patch(
"recidiviz.admin_panel.routes.workflows.get_authenticated_user_email",
)
@patch(
"recidiviz.admin_panel.routes.workflows.WorkflowsQuerier",
)
@patch(
"recidiviz.admin_panel.routes.workflows.get_workflows_enabled_states",
)
def test_post_new_config(
self,
mock_enabled_states: MagicMock,
mock_querier: MagicMock,
mock_get_email: MagicMock,
) -> None:
mock_enabled_states.return_value = ["US_ID"]
mock_get_email.return_value = ("e@mail.com", None)

config_fields = generate_config(-1, datetime.datetime(9, 9, 9))

req_body = {
"stateCode": "US_ID",
"description": config_fields.description,
"featureVariant": config_fields.feature_variant,
"displayName": config_fields.display_name,
"methodologyUrl": config_fields.methodology_url,
"isAlert": config_fields.is_alert,
"initialHeader": config_fields.initial_header,
"denialReasons": config_fields.denial_reasons,
"eligibleCriteriaCopy": config_fields.eligible_criteria_copy,
"ineligibleCriteriaCopy": config_fields.ineligible_criteria_copy,
"dynamicEligibilityText": config_fields.dynamic_eligibility_text,
"callToAction": config_fields.call_to_action,
"denialText": config_fields.denial_text,
"snooze": {"defaultSnoozeDays": 30, "maxSnoozeDays": 180},
"sidebarComponents": config_fields.sidebar_components,
"status": "ACTIVE",
}

mock_querier.return_value.add_config.return_value = TEST_CONFIG_ID

with freeze_time(datetime.datetime(10, 10, 10)):
response = self.client.post(
self.opportunity_configuration_url,
json=req_body,
)

self.assertEqual(HTTPStatus.OK, response.status_code)
self.assertEqual(TEST_CONFIG_ID, response.json)
mock_querier.return_value.add_config.assert_called_with(
TEST_WORKFLOW_TYPE,
created_by="e@mail.com",
created_at=datetime.datetime.now(),
description=req_body["description"],
feature_variant=req_body["featureVariant"],
display_name=req_body["displayName"],
methodology_url=req_body["methodologyUrl"],
is_alert=req_body["isAlert"],
initial_header=req_body["initialHeader"],
denial_reasons=req_body["denialReasons"],
eligible_criteria_copy=req_body["eligibleCriteriaCopy"],
ineligible_criteria_copy=req_body["ineligibleCriteriaCopy"],
dynamic_eligibility_text=req_body["dynamicEligibilityText"],
call_to_action=req_body["callToAction"],
denial_text=req_body["denialText"],
snooze={"default_snooze_days": 30, "max_snooze_days": 180},
sidebar_components=req_body["sidebarComponents"],
)

########
# GET /workflows/<state_code>/opportunities/<opportunity_type>/configurations/<id>
########
@patch(
"recidiviz.admin_panel.routes.workflows.WorkflowsQuerier",
)
Expand Down
1 change: 0 additions & 1 deletion recidiviz/tests/workflows/querier/querier_test.py
Expand Up @@ -85,7 +85,6 @@ def make_add_config_arguments(
opportunity_type: str, feature_variant: Optional[str] = None
) -> Dict[str, Any]:
return {
"state_code": "US_ID",
"opportunity_type": opportunity_type,
"created_by": "Maria",
"created_at": datetime.datetime(2024, 5, 12),
Expand Down
Expand Up @@ -18,7 +18,6 @@
Snapshots for recidiviz/tests/workflows/querier/querier_test.py
Update by running `pytest recidiviz/tests/workflows/querier/querier_test.py --snapshot-update`
You will need to replace this header afterward.
"""
# -*- coding: utf-8 -*-
# snapshottest: v1 - https://goo.gl/zC4yUc
Expand Down
6 changes: 3 additions & 3 deletions recidiviz/workflows/querier/querier.py
Expand Up @@ -15,6 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# ============================================================================
"""Querier class to encapsulate requests to the Workflows postgres DBs."""
import datetime
import logging
from functools import cached_property
from typing import Any, Dict, List, Optional, Set, Union
Expand Down Expand Up @@ -245,9 +246,8 @@ def get_config_for_id(
def add_config(
self,
opportunity_type: str,
state_code: StateCode,
created_by: str,
created_at: str,
created_at: datetime.datetime,
description: str,
feature_variant: Optional[str],
display_name: str,
Expand All @@ -272,7 +272,7 @@ def add_config(
insert_statement = (
insert(OpportunityConfiguration)
.values(
state_code=state_code,
state_code=self.state_code.value,
opportunity_type=opportunity_type,
created_by=created_by,
created_at=created_at,
Expand Down

0 comments on commit b363f47

Please sign in to comment.