Skip to content

Commit

Permalink
Sort Departments and add stats and cacheing (#995)
Browse files Browse the repository at this point in the history
## Fixes issue
#797

## Description of Changes
Added sorting by `state` and `name` columns for department listing ,
added baseline `Department` statistics for each department, and added
cacheing for those `Department` statistics.

## Screenshots (if appropriate)
Before change:
<img width="1220" alt="Screenshot 2023-07-28 at 3 24 50 PM"
src="https://github.com/lucyparsons/OpenOversight/assets/5885605/1f8a7a17-f4b4-4593-8651-a3b4f0c6dfdf">

After change:
<img width="1220" alt="Screenshot 2023-07-28 at 3 27 03 PM"
src="https://github.com/lucyparsons/OpenOversight/assets/5885605/77f70398-53aa-4f5d-af94-d9b4bc06834c">

## Tests and linting
 - [x] This branch is up-to-date with the `develop` branch.
 - [x] `pytest` passes on my local development environment.
 - [x] `pre-commit` passes on my local development environment.
  • Loading branch information
michplunkett authored Jul 30, 2023
1 parent ab6fd58 commit 9bef4ba
Show file tree
Hide file tree
Showing 6 changed files with 127 additions and 6 deletions.
4 changes: 3 additions & 1 deletion OpenOversight/app/main/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ def set_session_timezone():
@sitemap_include
@main.route("/browse", methods=[HTTPMethod.GET])
def browse():
departments = Department.query.filter(Department.officers.any())
departments = Department.query.filter(Department.officers.any()).order_by(
Department.state.asc(), Department.name.asc()
)
return render_template("browse.html", departments=departments)


Expand Down
56 changes: 55 additions & 1 deletion OpenOversight/app/models/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from datetime import date

from authlib.jose import JoseError, JsonWebToken
from cachetools import TTLCache, cached
from cachetools.keys import hashkey
from flask import current_app
from flask_login import UserMixin
from flask_sqlalchemy import SQLAlchemy
Expand All @@ -11,7 +13,13 @@
from sqlalchemy.sql import func as sql_func
from werkzeug.security import check_password_hash, generate_password_hash

from OpenOversight.app.utils.constants import ENCODING_UTF_8
from OpenOversight.app.utils.constants import (
ENCODING_UTF_8,
HOUR,
KEY_TOTAL_ASSIGNMENTS,
KEY_TOTAL_INCIDENTS,
KEY_TOTAL_OFFICERS,
)
from OpenOversight.app.validators import state_validator, url_validator


Expand All @@ -35,6 +43,31 @@
)


# This is a last recently used cache that also utilizes a time-to-live function for each
# value saved in it (12 hours).
# TODO: Change this into a singleton so that we can clear values when updates happen
DATABASE_CACHE = TTLCache(maxsize=1024, ttl=12 * HOUR)


# TODO: In the singleton create functions for other model types.
def _date_updated_cache_key(update_type: str):
"""Return a key function to calculate the cache key for Department
methods using the department id and a given update type.
Department.id is used instead of a Department obj because the default Python
__hash__ is unique per obj instance, meaning multiple instances of the same
department will have different hashes.
Update type is used in the hash to differentiate between the update types we compute
per department.
"""

def _cache_key(dept: "Department"):
return hashkey(dept.id, update_type)

return _cache_key


class Department(BaseModel):
__tablename__ = "departments"
id = db.Column(db.Integer, primary_key=True)
Expand All @@ -61,6 +94,27 @@ def to_custom_dict(self):
"unique_internal_identifier_label": self.unique_internal_identifier_label,
}

@cached(cache=DATABASE_CACHE, key=_date_updated_cache_key(KEY_TOTAL_ASSIGNMENTS))
def total_documented_assignments(self):
return (
db.session.query(Assignment.id)
.join(Officer, Assignment.officer_id == Officer.id)
.filter(Officer.department_id == self.id)
.count()
)

@cached(cache=DATABASE_CACHE, key=_date_updated_cache_key(KEY_TOTAL_INCIDENTS))
def total_documented_incidents(self):
return (
db.session.query(Incident).filter(Incident.department_id == self.id).count()
)

@cached(cache=DATABASE_CACHE, key=_date_updated_cache_key(KEY_TOTAL_OFFICERS))
def total_documented_officers(self):
return (
db.session.query(Officer).filter(Officer.department_id == self.id).count()
)


class Job(BaseModel):
__tablename__ = "jobs"
Expand Down
7 changes: 7 additions & 0 deletions OpenOversight/app/templates/browse.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ <h2>
</a>
{% endif %}
</h2>
<div>
<b class="dept-{{ department.id }}">Officers Documented:</b> {{ department.total_documented_officers() }}
<br>
<b class="dept-{{ department.id }}">Assignments Documented:</b> {{ department.total_documented_assignments() }}
<br>
<b class="dept-{{ department.id }}">Incidents Documented:</b> {{ department.total_documented_incidents() }}
</div>
<p>
<a class="btn btn-lg btn-primary"
href="{{ url_for('main.list_officer', department_id=department.id) }}">Officers</a>
Expand Down
15 changes: 11 additions & 4 deletions OpenOversight/app/utils/constants.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import os


# Cache Key Constants
KEY_TOTAL_ASSIGNMENTS = "total_assignments"
KEY_TOTAL_INCIDENTS = "total_incidents"
KEY_TOTAL_OFFICERS = "total_officers"

# Config Key Constants
KEY_OFFICERS_PER_PAGE = "OFFICERS_PER_PAGE"
KEY_TIMEZONE = "TIMEZONE"

# File Handling Constants
ENCODING_UTF_8 = "utf-8"
SAVED_UMASK = os.umask(0o077) # Ensure the file is read/write by the creator only

# File Name Constants
SERVICE_ACCOUNT_FILE = "service_account_key.json"

# Key Constants
KEY_OFFICERS_PER_PAGE = "OFFICERS_PER_PAGE"
KEY_TIMEZONE = "TIMEZONE"

# Numerical Constants
BYTE = 1
KILOBYTE = 1024 * BYTE
MEGABYTE = 1024 * KILOBYTE
MINUTE = 60
HOUR = 60 * MINUTE
50 changes: 50 additions & 0 deletions OpenOversight/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
User,
db,
)
from OpenOversight.tests.conftest import SPRINGFIELD_PD


def test_department_repr(mockdata):
Expand All @@ -28,6 +29,55 @@ def test_department_repr(mockdata):
)


def test_department_total_documented_officers(mockdata):
springfield_officers = (
Department.query.filter_by(name=SPRINGFIELD_PD.name, state=SPRINGFIELD_PD.state)
.join(Officer, Department.id == Officer.department_id)
.count()
)

test_count = (
Department.query.filter_by(name=SPRINGFIELD_PD.name, state=SPRINGFIELD_PD.state)
.first()
.total_documented_officers()
)

assert springfield_officers == test_count


def test_department_total_documented_assignments(mockdata):
springfield_assignments = (
Department.query.filter_by(name=SPRINGFIELD_PD.name, state=SPRINGFIELD_PD.state)
.join(Officer, Department.id == Officer.department_id)
.join(Assignment, Officer.id == Assignment.officer_id)
.count()
)

test_count = (
Department.query.filter_by(name=SPRINGFIELD_PD.name, state=SPRINGFIELD_PD.state)
.first()
.total_documented_assignments()
)

assert springfield_assignments == test_count


def test_department_total_documented_incidents(mockdata):
springfield_incidents = (
Department.query.filter_by(name=SPRINGFIELD_PD.name, state=SPRINGFIELD_PD.state)
.join(Incident, Department.id == Incident.department_id)
.count()
)

test_count = (
Department.query.filter_by(name=SPRINGFIELD_PD.name, state=SPRINGFIELD_PD.state)
.first()
.total_documented_incidents()
)

assert springfield_incidents == test_count


def test_officer_repr(mockdata):
officer = Officer.query.first()
if officer.unique_internal_identifier:
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ bleach-allowlist==1.0.3
blinker~=1.6.2
boto3==1.28.1
botocore==1.31.1
cachetools==5.3.1
certifi~=2023.5.7
cffi~=1.15.1
click==8.1.4
Expand Down

0 comments on commit 9bef4ba

Please sign in to comment.