Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add API endpoints for departments #1138

Open
wants to merge 72 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
7057311
Create __init__.py
michplunkett Dec 14, 2024
549ec84
Base API files
michplunkett Dec 14, 2024
5f43d63
Update views.py
michplunkett Dec 17, 2024
bb0aab4
Update views.py
michplunkett Dec 17, 2024
eb02d88
Lint
michplunkett Dec 18, 2024
ddb8233
Remove Flask-Sitemap
michplunkett Dec 18, 2024
04976b8
Move limiter to utils
michplunkett Dec 18, 2024
b9414e8
Add back sitemap
michplunkett Dec 18, 2024
e7c02be
Update views.py
michplunkett Dec 18, 2024
cf5f7a4
Update views.py
michplunkett Dec 18, 2024
ada954b
Update views.py
michplunkett Dec 18, 2024
2742f80
Update views.py
michplunkett Dec 18, 2024
07fe24c
Modify Blueprints
michplunkett Dec 19, 2024
bc57312
Delete officers.py
michplunkett Dec 19, 2024
c1388c4
Delete incidents.py
michplunkett Dec 19, 2024
066f294
Delete departments.py
michplunkett Dec 19, 2024
ff35e1f
Create api.py
michplunkett Dec 19, 2024
5e00b0b
Create api.py
michplunkett Dec 19, 2024
fcb3dd7
Update __init__.py
michplunkett Dec 19, 2024
2ca4be7
Update api.py
michplunkett Dec 19, 2024
bcd27c2
Update api.py
michplunkett Dec 19, 2024
6485464
Update views.py
michplunkett Dec 19, 2024
659993a
Update api.py
michplunkett Dec 19, 2024
6505ce7
Create test_v1_api.py
michplunkett Dec 19, 2024
5a39961
Restructure
michplunkett Dec 19, 2024
8af766a
Delete api.py
michplunkett Dec 19, 2024
88fd11c
Update __init__.py
michplunkett Dec 19, 2024
9a17b02
Create v1.py
michplunkett Dec 19, 2024
0c9b088
Create test_api_v1.py
michplunkett Dec 19, 2024
bd2971f
Update test_api_v1.py
michplunkett Dec 19, 2024
f73b203
Update test_api_v1.py
michplunkett Dec 19, 2024
8a21eca
Update v1.py
michplunkett Dec 19, 2024
55d3d0c
Update test_api_v1.py
michplunkett Dec 19, 2024
1efde38
Update v1.py
michplunkett Dec 19, 2024
60a9e49
Update test_api_v1.py
michplunkett Dec 19, 2024
668b1b6
Update test_api_v1.py
michplunkett Dec 19, 2024
b56d070
Update v1.py
michplunkett Dec 19, 2024
8e3f44f
Update database.py
michplunkett Dec 19, 2024
cb66a22
Update db.py
michplunkett Dec 20, 2024
9a1fe2f
Update __init__.py
michplunkett Dec 20, 2024
2f50bb9
Update test_api_v1.py
michplunkett Dec 20, 2024
fd5f406
Update database.py
michplunkett Dec 20, 2024
e74eff2
Update db.py
michplunkett Dec 20, 2024
6ba9269
repr tests
michplunkett Dec 20, 2024
2fcdca8
Update db.py
michplunkett Dec 20, 2024
7c8dcb1
Update __init__.py
michplunkett Dec 20, 2024
11443db
Update database.py
michplunkett Dec 20, 2024
f610941
Update database.py
michplunkett Dec 20, 2024
74f20d2
Update v1.py
michplunkett Dec 20, 2024
e8535cb
Update database.py
michplunkett Dec 20, 2024
013f4bb
Update views.py
michplunkett Dec 20, 2024
c6b7f52
Update test_models.py
michplunkett Dec 20, 2024
0bacc74
Update database.py
michplunkett Dec 20, 2024
112fd35
Revert "Update views.py"
michplunkett Dec 20, 2024
8f6a4e3
Update database.py
michplunkett Dec 20, 2024
0ba2ec8
Add tests
michplunkett Dec 20, 2024
6d0f577
Update db.py
michplunkett Dec 20, 2024
731085b
Update database.py
michplunkett Dec 20, 2024
6ebd1de
Update views.py
michplunkett Dec 20, 2024
9b95c66
Update database.py
michplunkett Dec 21, 2024
bc618af
Merge branch 'develop' into build-api
michplunkett Jan 2, 2025
88be2c3
Update database.py
michplunkett Jan 2, 2025
42f8e6f
Merge branch 'develop' into build-api
michplunkett Jan 3, 2025
cca977a
Merge branch 'develop' into build-api
michplunkett Jan 6, 2025
d2d897c
Merge branch 'develop' into build-api
michplunkett Jan 15, 2025
ca3fdd4
Merge branch 'develop' into build-api
michplunkett Jan 16, 2025
9435fc3
Update database.py
michplunkett Jan 16, 2025
e1f6c54
Merge branch 'develop' into build-api
michplunkett Jan 16, 2025
1385597
Update database.py
michplunkett Jan 16, 2025
024d8fa
Update v1.py
michplunkett Jan 16, 2025
b14f0dd
Update v1.py
michplunkett Jan 16, 2025
5905525
Update v1.py
michplunkett Jan 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions OpenOversight/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from flask_migrate import Migrate
from flask_wtf.csrf import CSRFProtect

from OpenOversight.app.api.v1 import v1 as api_v1_blueprint
from OpenOversight.app.auth.views import auth as auth_blueprint
from OpenOversight.app.email_client import EmailClient
from OpenOversight.app.filters import instantiate_filters
Expand Down Expand Up @@ -48,6 +49,7 @@ def create_app(config_name="default"):
compress.init_app(app)

# Register Blueprints for application routes
app.register_blueprint(api_v1_blueprint)
app.register_blueprint(auth_blueprint)
app.register_blueprint(main_blueprint)

Expand Down
Empty file.
147 changes: 147 additions & 0 deletions OpenOversight/app/api/v1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
from http import HTTPMethod
from typing import List

from flask import Blueprint, Response, jsonify
from sqlalchemy.orm import contains_eager, joinedload

from OpenOversight.app.models.database import (
Assignment,
BaseModel,
Department,
Description,
Incident,
Link,
Officer,
Salary,
db,
)
from OpenOversight.app.models.database_cache import (
get_database_cache_entry,
put_database_cache_entry,
)
from OpenOversight.app.utils.constants import (
KEY_DEPT_ALL_ASSIGNMENTS,
KEY_DEPT_ALL_INCIDENTS,
KEY_DEPT_ALL_LINKS,
KEY_DEPT_ALL_NOTES,
KEY_DEPT_ALL_OFFICERS,
KEY_DEPT_ALL_SALARIES,
)
from OpenOversight.app.utils.flask import limiter


v1 = Blueprint("v1", __name__, url_prefix="/api/v1")


def objs_to_dicts_jsonify(obj_list: List[BaseModel]) -> Response:
return jsonify([o.to_dict() for o in obj_list])


@v1.route("/departments/<int:department_id>/officers", methods=[HTTPMethod.GET])
@limiter.limit("5/minute")
def get_dept_officers(department_id: int) -> Response:
cache_params = (Department(id=department_id), KEY_DEPT_ALL_OFFICERS)
officers = get_database_cache_entry(*cache_params)

if officers is None:
officers = (
db.session.query(Officer)
.options(joinedload(Officer.assignments).joinedload(Assignment.job))
.options(joinedload(Officer.salaries))
.filter_by(department_id=department_id)
.all()
)
put_database_cache_entry(*cache_params, officers)

return objs_to_dicts_jsonify(officers)


@v1.route("/departments/<int:department_id>/assignments", methods=[HTTPMethod.GET])
@limiter.limit("5/minute")
def get_dept_assignments(department_id: int) -> Response:
cache_params = Department(id=department_id), KEY_DEPT_ALL_ASSIGNMENTS
assignments = get_database_cache_entry(*cache_params)

if assignments is None:
assignments = (
db.session.query(Assignment)
.join(Assignment.base_officer)
.filter(Officer.department_id == department_id)
.options(contains_eager(Assignment.base_officer))
.options(joinedload(Assignment.unit))
.options(joinedload(Assignment.job))
.all()
)
put_database_cache_entry(*cache_params, assignments)

return objs_to_dicts_jsonify(assignments)


@v1.route("/departments/<int:department_id>/incidents", methods=[HTTPMethod.GET])
@limiter.limit("5/minute")
def get_dept_incidents(department_id: int) -> Response:
cache_params = (Department(id=department_id), KEY_DEPT_ALL_INCIDENTS)
incidents = get_database_cache_entry(*cache_params)

if incidents is None:
incidents = Incident.query.filter_by(department_id=department_id).all()
put_database_cache_entry(*cache_params, incidents)

return objs_to_dicts_jsonify(incidents)


@v1.route("/departments/<int:department_id>/salaries", methods=[HTTPMethod.GET])
@limiter.limit("5/minute")
def get_dept_salaries(department_id: int) -> Response:
cache_params = (Department(id=department_id), KEY_DEPT_ALL_SALARIES)
salaries = get_database_cache_entry(*cache_params)

if salaries is None:
salaries = (
db.session.query(Salary)
.join(Salary.officer)
.filter(Officer.department_id == department_id)
.options(contains_eager(Salary.officer))
.all()
)
put_database_cache_entry(*cache_params, salaries)

return objs_to_dicts_jsonify(salaries)


@v1.route("/departments/<int:department_id>/links", methods=[HTTPMethod.GET])
@limiter.limit("5/minute")
def get_dept_links(department_id: int) -> Response:
cache_params = (Department(id=department_id), KEY_DEPT_ALL_LINKS)
links = get_database_cache_entry(*cache_params)

if links is None:
links = (
db.session.query(Link)
.join(Link.officers)
.filter(Officer.department_id == department_id)
.options(contains_eager(Link.officers))
.all()
)
put_database_cache_entry(*cache_params, links)

return objs_to_dicts_jsonify(links)


@v1.route("/departments/<int:department_id>/descriptions", methods=[HTTPMethod.GET])
@limiter.limit("5/minute")
def get_dept_descriptions(department_id: int) -> Response:
cache_params = (Department(id=department_id), KEY_DEPT_ALL_NOTES)
notes = get_database_cache_entry(*cache_params)

if notes is None:
notes = (
db.session.query(Description)
.join(Description.officer)
.filter(Officer.department_id == department_id)
.options(contains_eager(Description.officer))
.all()
)
put_database_cache_entry(*cache_params, notes)
Comment on lines +134 to +145
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be worth moving these to static functions on each of the objects since they are used both here and in views.


return objs_to_dicts_jsonify(notes)
22 changes: 17 additions & 5 deletions OpenOversight/app/main/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ def get_officer():
def redirect_get_started_labeling():
flash(FLASH_MSG_PERMANENT_REDIRECT)
return redirect(
url_for("main.get_started_labeling"), code=HTTPStatus.PERMANENT_REDIRECT
url_for("main.get_started_labeling"),
code=HTTPStatus.PERMANENT_REDIRECT,
)


Expand Down Expand Up @@ -422,7 +423,9 @@ def redirect_edit_assignment(officer_id: int, assignment_id: int):
flash(FLASH_MSG_PERMANENT_REDIRECT)
return redirect(
url_for(
"main.edit_assignment", officer_id=officer_id, assignment_id=assignment_id
"main.edit_assignment",
officer_id=officer_id,
assignment_id=assignment_id,
),
code=HTTPStatus.PERMANENT_REDIRECT,
)
Expand Down Expand Up @@ -632,7 +635,9 @@ def display_tag(tag_id: int):
def redirect_classify_submission(image_id: int, contains_cops: int):
return redirect(
url_for(
"main.classify_submission", image_id=image_id, contains_cops=contains_cops
"main.classify_submission",
image_id=image_id,
contains_cops=contains_cops,
),
code=HTTPStatus.PERMANENT_REDIRECT,
)
Expand Down Expand Up @@ -816,7 +821,10 @@ def edit_department(department_id: int):
f"You attempted to delete a rank, {rank}, that is still in use"
)
return redirect(
url_for("main.edit_department", department_id=department_id)
url_for(
"main.edit_department",
department_id=department_id,
)
)

for new_rank, order in new_ranks:
Expand Down Expand Up @@ -2302,7 +2310,11 @@ def redirect_edit_description(officer_id: int, obj_id=None):
def redirect_delete_description(officer_id: int, obj_id=None):
flash(FLASH_MSG_PERMANENT_REDIRECT)
return redirect(
url_for("main.description_api_delete", officer_id=officer_id, obj_id=obj_id),
url_for(
"main.description_api_delete",
officer_id=officer_id,
obj_id=obj_id,
),
code=HTTPStatus.PERMANENT_REDIRECT,
)

Expand Down
8 changes: 4 additions & 4 deletions OpenOversight/app/models/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,11 @@ class Department(BaseModel, TrackUpdates):
__table_args__ = (UniqueConstraint("name", "state", name="departments_name_state"),)

@property
def display_name(self):
def display_name(self) -> str:
return self.name if not self.state else f"[{self.state}] {self.name}"

@cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_ASSIGNMENTS))
def total_documented_assignments(self):
def total_documented_assignments(self) -> int:
return (
db.session.query(Assignment.id)
.join(Officer, Assignment.officer_id == Officer.id)
Expand All @@ -212,13 +212,13 @@ def total_documented_assignments(self):
)

@cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_INCIDENTS))
def total_documented_incidents(self):
def total_documented_incidents(self) -> int:
return (
db.session.query(Incident).filter(Incident.department_id == self.id).count()
)

@cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_OFFICERS))
def total_documented_officers(self):
def total_documented_officers(self) -> int:
return (
db.session.query(Officer).filter(Officer.department_id == self.id).count()
)
Expand Down
77 changes: 77 additions & 0 deletions OpenOversight/tests/routes/test_api_v1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import json
from http import HTTPStatus

import pytest
from flask import current_app, url_for
from sqlalchemy.orm import contains_eager, joinedload

from OpenOversight.app.models.database import Assignment, Incident, Officer, Salary
from OpenOversight.app.utils.constants import ENCODING_UTF_8


@pytest.mark.parametrize("department_id", [1, 3000])
def test_get_dept_attributes(client, session, department_id: int):
with current_app.test_request_context():
# Get officers
expected_officers = Officer.query.filter_by(department_id=department_id).count()

resp_officers = client.get(
url_for("v1.get_dept_officers", department_id=department_id),
follow_redirects=True,
)
officers = json.loads(resp_officers.data.decode(ENCODING_UTF_8))

assert resp_officers.status_code == HTTPStatus.OK
assert len(officers) == expected_officers

# Get assignments
expected_assignments = (
session.query(Assignment)
.join(Assignment.base_officer)
.filter(Officer.department_id == department_id)
.options(contains_eager(Assignment.base_officer))
.options(joinedload(Assignment.unit))
.options(joinedload(Assignment.job))
.all()
)

resp_assignments = client.get(
url_for("v1.get_dept_assignments", department_id=department_id),
follow_redirects=True,
)
Comment on lines +38 to +41
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could probably get rid of follow_redirects, but I think it might be worth keeping in case we modify the endpoint at some point.

assignments = json.loads(resp_assignments.data.decode(ENCODING_UTF_8))

assert resp_assignments.status_code == HTTPStatus.OK
assert len(assignments) == len(expected_assignments)

# Get incidents
expected_incidents = Incident.query.filter_by(
department_id=department_id
).count()

resp_incidents = client.get(
url_for("v1.get_dept_incidents", department_id=department_id),
follow_redirects=True,
)
incidents = json.loads(resp_incidents.data.decode(ENCODING_UTF_8))

assert resp_incidents.status_code == HTTPStatus.OK
assert len(incidents) == expected_incidents

# Get salaries
expected_salaries = (
session.query(Salary)
.join(Salary.officer)
.filter(Officer.department_id == department_id)
.options(contains_eager(Salary.officer))
.count()
)

resp_salaries = client.get(
url_for("v1.get_dept_salaries", department_id=department_id),
follow_redirects=True,
)
salaries = json.loads(resp_salaries.data.decode(ENCODING_UTF_8))

assert resp_salaries.status_code == HTTPStatus.OK
assert len(salaries) == expected_salaries
Loading