Skip to content

Commit

Permalink
[Justice Counts] Add an Admin Panel API endpoint for deleting an agen…
Browse files Browse the repository at this point in the history
…cy (Recidiviz/recidiviz-data#29355)

## Description of the change

Add an Admin Panel API endpoint for deleting an agency.

To reduce redundant logic, we extract out the existing delete_agency
script and import it into a util file.

Note: I first tried importing it into the admin API straight from
recidiviz/tools/justice_counts/delete_agency.py but this caused a
circular dependency:
https://github.com/Recidiviz/recidiviz-data/actions/runs/8838706773/job/24270463398

## Super/Child agencies

It looks like our existing delete agency script actually does not handle
superagencies/child agencies properly. My fix for this is:

# If the agency is a superagency, nullify the super_agency_id field from
all of its
# children. Nullifying a child agency's super_agency_id field causes the
child
    # agency to no longer be considered a child agency.

I tested locally that this behavior works properly and that the child
agencies are correctly "uncoupled" from their superagencies.

But would love someone else to verify that nullifying
Agency.super_agency_id for child agencies is the right approach!

## Testing

### Unit testing
Add a comprehensive unit test to the admin panel test suite.

### Testing the Admin Panel Endpoint Locally

I ran the admin panel endpoint via the request_api for a local agency
and manually verified that the endpoint works.
```
python -m recidiviz.tools.justice_counts.control_panel.request_api admin/agency/XXX '{}' delete
```

I also verified this on a superagency and it correctly:
* Deleted the superagency.
* Turned the superagency's child agencies into regular agencies.

I ran it on a child agency and it correctly:
* Deleted the child agency
* The child agency no longer showed up as a child for the superagency in
the admin panel.

### Testing the Util Command
Successfully ran the existing delete agency tool against a staging
agency to confirm that the modification of the delete agency tool file
does not break it.
```
python -m recidiviz.tools.justice_counts.delete_agency \
    --project-id=justice-counts-staging \
    --agency-id=361 \
    --dry-run=false
```
<img width="585" alt="Screenshot 2024-04-25 at 11 54 34 AM"
src="https://github.com/Recidiviz/recidiviz-data/assets/130382407/51e3492a-7b3e-430a-a3b9-6036fe80d2c6">

## Related issues

Closes Recidiviz/recidiviz-data#28832

GitOrigin-RevId: e68e4e78cd94c3ede4d1d3a826c5807b7c0675de
  • Loading branch information
brandon-hills authored and Helper Bot committed May 11, 2024
1 parent 6c99814 commit 00517e9
Show file tree
Hide file tree
Showing 4 changed files with 383 additions and 71 deletions.
15 changes: 15 additions & 0 deletions recidiviz/justice_counts/control_panel/routes/admin.py
Expand Up @@ -32,6 +32,7 @@
from recidiviz.justice_counts.datapoint import DatapointInterface
from recidiviz.justice_counts.exceptions import JusticeCountsServerError
from recidiviz.justice_counts.user_account import UserAccountInterface
from recidiviz.justice_counts.utils.agency_utils import delete_agency
from recidiviz.justice_counts.utils.constants import (
COPY_SUPERAGENCY_METRIC_SETTINGS_TO_CHILD_AGENCIES_JOB_NAME,
VALID_SYSTEMS,
Expand Down Expand Up @@ -302,6 +303,20 @@ def get_agency(agency_id: int) -> Response:
}
)

@admin_blueprint.route("/agency/<agency_id>", methods=["DELETE"])
@auth_decorator
def delete_agency_endpoint(agency_id: int) -> Response:
"""Deletes an individual agency."""
if auth0_client is None:
return make_response(
"auth0_client could not be initialized. Environment is not development or gcp.",
500,
)
agency_json = delete_agency(
session=current_session, agency_id=agency_id, dry_run=False
)
return jsonify(agency_json)

@admin_blueprint.route("/agency", methods=["PUT"])
@auth_decorator
def create_or_update_agency() -> Response:
Expand Down
110 changes: 110 additions & 0 deletions recidiviz/justice_counts/utils/agency_utils.py
@@ -0,0 +1,110 @@
# Recidiviz - a data platform for criminal justice reform
# Copyright (C) 2022 Recidiviz, Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# =============================================================================
"""
Utils for more sophisticated agency logic.
"""

import logging
from typing import Dict

from sqlalchemy.orm import Session

from recidiviz.justice_counts.agency import AgencyInterface
from recidiviz.persistence.database.schema.justice_counts import schema

logger = logging.getLogger(__name__)


def delete_agency(session: Session, agency_id: int, dry_run: bool) -> Dict:
"""Delete the given agency and all its data."""
agency = AgencyInterface.get_agency_by_id(
session=session,
agency_id=agency_id,
with_users=True,
with_settings=True,
)

logger.info("DRY RUN: %s", dry_run)
logger.info("Will delete %s", agency.name)

objects_to_delete = []
associations = (
session.query(schema.AgencyUserAccountAssociation)
.filter_by(agency_id=agency_id)
.all()
)
objects_to_delete.extend(associations)
logger.info("Will delete %d UserAccountAssociations", len(associations))

reports = session.query(schema.Report).filter_by(source_id=agency_id).all()
objects_to_delete.extend(reports)
logger.info("Will delete %d reports", len(reports))

datapoints = session.query(schema.Datapoint).filter_by(source_id=agency_id).all()
# Delete the Datapoint History entries associated with each deleted datapoint.
for datapoint in datapoints:
datapoint_histories = (
session.query(schema.DatapointHistory)
.filter_by(datapoint_id=datapoint.id)
.all()
)
objects_to_delete.extend(datapoint_histories)

objects_to_delete.extend(datapoints)
logger.info("Will delete %d datapoints", len(datapoints))

settings = session.query(schema.AgencySetting).filter_by(source_id=agency_id).all()
objects_to_delete.extend(settings)
logger.info("Will delete %d agency settings", len(settings))

jurisdictions = (
session.query(schema.AgencyJurisdiction).filter_by(source_id=agency_id).all()
)
objects_to_delete.extend(jurisdictions)
logger.info("Will delete %d jurisdictions", len(jurisdictions))

spreadsheets = (
session.query(schema.Spreadsheet).filter_by(agency_id=agency_id).all()
)
objects_to_delete.extend(spreadsheets)
logger.info("Will delete %d spreadsheets", len(spreadsheets))

# If the agency is a superagency, nullify the super_agency_id field from all of its
# children. Nullifying a child agency's super_agency_id field causes the child
# agency to no longer be considered a child agency.
agencies = session.query(schema.Agency).filter_by(id=agency_id).all()
for agency in agencies:
if not agency.is_superagency:
continue
children = (
session.query(schema.Agency).filter_by(super_agency_id=agency.id).all()
)
# Set all children's super_agency_ids to None.
for child_agency in children:
child_agency.super_agency_id = None

objects_to_delete.extend(agencies)
logger.info("Will delete %d agencies", len(agencies))

if dry_run is False:
for obj in objects_to_delete:
session.delete(obj)
session.commit()
logger.info("%s deleted", agency.name)

# Return the agency as a JSON object for frontend purposes.
return agency.to_json(with_team=False, with_settings=False)

0 comments on commit 00517e9

Please sign in to comment.