diff --git a/OpenOversight/app/__init__.py b/OpenOversight/app/__init__.py index 23f22a4d7..1fce62e35 100644 --- a/OpenOversight/app/__init__.py +++ b/OpenOversight/app/__init__.py @@ -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 @@ -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) diff --git a/OpenOversight/app/api/__init__.py b/OpenOversight/app/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/OpenOversight/app/api/v1.py b/OpenOversight/app/api/v1.py new file mode 100644 index 000000000..e2a103a94 --- /dev/null +++ b/OpenOversight/app/api/v1.py @@ -0,0 +1,56 @@ +from http import HTTPMethod + +from flask import Blueprint, Response, jsonify +from sqlalchemy.orm import Query + +from OpenOversight.app.models.database import Department +from OpenOversight.app.utils.flask import limiter + + +v1 = Blueprint("v1", __name__, url_prefix="/api/v1") + + +def objs_to_dicts_jsonify(obj_list: Query) -> Response: + return jsonify([o.to_dict() for o in obj_list]) + + +@v1.route("/departments//assignments", methods=[HTTPMethod.GET]) +@limiter.limit("5/minute") +def get_dept_assignments(department_id: int) -> Response: + assignments = Department.get_assignments(department_id) + return objs_to_dicts_jsonify(assignments) + + +@v1.route("/departments//descriptions", methods=[HTTPMethod.GET]) +@limiter.limit("5/minute") +def get_dept_descriptions(department_id: int) -> Response: + descriptions = Department.get_descriptions(department_id) + return objs_to_dicts_jsonify(descriptions) + + +@v1.route("/departments//incidents", methods=[HTTPMethod.GET]) +@limiter.limit("5/minute") +def get_dept_incidents(department_id: int) -> Response: + incidents = Department.get_incidents(department_id) + return objs_to_dicts_jsonify(incidents) + + +@v1.route("/departments//links", methods=[HTTPMethod.GET]) +@limiter.limit("5/minute") +def get_dept_links(department_id: int) -> Response: + links = Department.get_links(department_id) + return objs_to_dicts_jsonify(links) + + +@v1.route("/departments//officers", methods=[HTTPMethod.GET]) +@limiter.limit("5/minute") +def get_dept_officers(department_id: int) -> Response: + officers = Department.get_officers(department_id) + return objs_to_dicts_jsonify(officers) + + +@v1.route("/departments//salaries", methods=[HTTPMethod.GET]) +@limiter.limit("5/minute") +def get_dept_salaries(department_id: int) -> Response: + salaries = Department.get_salaries(department_id) + return objs_to_dicts_jsonify(salaries) diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index d3006da18..e91b05a40 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -21,7 +21,7 @@ from flask_login import current_user, login_required, login_user from flask_wtf import FlaskForm from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import contains_eager, joinedload, selectinload +from sqlalchemy.orm import selectinload from sqlalchemy.orm.exc import NoResultFound from OpenOversight.app.auth.forms import LoginForm @@ -71,10 +71,6 @@ User, db, ) -from OpenOversight.app.models.database_cache import ( - get_database_cache_entry, - put_database_cache_entry, -) from OpenOversight.app.utils.auth import ac_or_admin_required, admin_required from OpenOversight.app.utils.choices import AGE_CHOICES, GENDER_CHOICES, RACE_CHOICES from OpenOversight.app.utils.cloud import crop_image, save_image_to_s3_and_db @@ -82,9 +78,7 @@ ENCODING_UTF_8, FLASH_MSG_PERMANENT_REDIRECT, KEY_DEPT_ALL_ASSIGNMENTS, - KEY_DEPT_ALL_INCIDENTS, KEY_DEPT_ALL_LINKS, - KEY_DEPT_ALL_NOTES, KEY_DEPT_ALL_OFFICERS, KEY_DEPT_ALL_SALARIES, KEY_DEPT_TOTAL_ASSIGNMENTS, @@ -217,7 +211,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, ) @@ -422,7 +417,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, ) @@ -632,7 +629,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, ) @@ -816,7 +815,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: @@ -1572,18 +1574,7 @@ def redirect_download_dept_officers_csv(department_id: int): ) @limiter.limit("5/minute") def download_dept_officers_csv(department_id: int): - 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) - + officers = Department.get_officers(department_id) field_names = [ "id", "unique identifier", @@ -1599,6 +1590,7 @@ def download_dept_officers_csv(department_id: int): "job title", "most recent salary", ] + return make_downloadable_csv( officers, department_id, "Officers", field_names, officer_record_maker ) @@ -1620,20 +1612,7 @@ def redirect_download_dept_assignments_csv(department_id: int): ) @limiter.limit("5/minute") def download_dept_assignments_csv(department_id: int): - 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) - + assignments = Department.get_assignments(department_id) field_names = [ "id", "officer id", @@ -1645,6 +1624,7 @@ def download_dept_assignments_csv(department_id: int): "unit id", "unit description", ] + return make_downloadable_csv( assignments, department_id, @@ -1670,12 +1650,7 @@ def redirect_download_incidents_csv(department_id: int): ) @limiter.limit("5/minute") def download_incidents_csv(department_id: int): - 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) - + incidents = Department.get_incidents(department_id) field_names = [ "id", "report_num", @@ -1687,6 +1662,7 @@ def download_incidents_csv(department_id: int): "links", "officers", ] + return make_downloadable_csv( incidents, department_id, @@ -1712,18 +1688,7 @@ def redirect_download_dept_salaries_csv(department_id: int): ) @limiter.limit("5/minute") def download_dept_salaries_csv(department_id: int): - 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) - + salaries = Department.get_salaries(department_id) field_names = [ "id", "officer id", @@ -1734,6 +1699,7 @@ def download_dept_salaries_csv(department_id: int): "year", "is_fiscal_year", ] + return make_downloadable_csv( salaries, department_id, "Salaries", field_names, salary_record_maker ) @@ -1751,18 +1717,7 @@ def redirect_download_dept_links_csv(department_id: int): @main.route("/download/departments//links", methods=[HTTPMethod.GET]) @limiter.limit("5/minute") def download_dept_links_csv(department_id: int): - 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) - + links = Department.get_links(department_id) field_names = [ "id", "title", @@ -1773,6 +1728,7 @@ def download_dept_links_csv(department_id: int): "officers", "incidents", ] + return make_downloadable_csv( links, department_id, "Links", field_names, links_record_maker ) @@ -1794,18 +1750,7 @@ def redirect_download_dept_descriptions_csv(department_id: int): ) @limiter.limit("5/minute") def download_dept_descriptions_csv(department_id: int): - 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) - + descriptions = Department.get_descriptions(department_id) field_names = [ "id", "text_contents", @@ -1814,8 +1759,9 @@ def download_dept_descriptions_csv(department_id: int): "created_at", "last_updated_at", ] + return make_downloadable_csv( - notes, department_id, "Notes", field_names, descriptions_record_maker + descriptions, department_id, "Notes", field_names, descriptions_record_maker ) @@ -2302,7 +2248,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, ) diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py index 6172cb790..05cc515e1 100644 --- a/OpenOversight/app/models/database.py +++ b/OpenOversight/app/models/database.py @@ -14,19 +14,34 @@ from flask_sqlalchemy import SQLAlchemy from sqlalchemy import CheckConstraint, UniqueConstraint, func from sqlalchemy.inspection import inspect -from sqlalchemy.orm import DeclarativeMeta, declarative_mixin, declared_attr, validates +from sqlalchemy.orm import ( + DeclarativeMeta, + contains_eager, + declarative_mixin, + declared_attr, + joinedload, + validates, +) from sqlalchemy.sql import func as sql_func from werkzeug.security import check_password_hash, generate_password_hash from OpenOversight.app.models.database_cache import ( DB_CACHE, + get_database_cache_entry, model_cache_key, + put_database_cache_entry, remove_database_cache_entries, ) from OpenOversight.app.utils.choices import GENDER_CHOICES, RACE_CHOICES from OpenOversight.app.utils.constants import ( ENCODING_UTF_8, KEY_DB_CREATOR, + KEY_DEPT_ALL_ASSIGNMENTS, + KEY_DEPT_ALL_INCIDENTS, + KEY_DEPT_ALL_LINKS, + KEY_DEPT_ALL_NOTES, + KEY_DEPT_ALL_OFFICERS, + KEY_DEPT_ALL_SALARIES, KEY_DEPT_TOTAL_ASSIGNMENTS, KEY_DEPT_TOTAL_INCIDENTS, KEY_DEPT_TOTAL_OFFICERS, @@ -184,50 +199,6 @@ def creator(cls): return db.relationship("User", foreign_keys=[cls.created_by]) -class Department(BaseModel, TrackUpdates): - __tablename__ = "departments" - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(255), index=False, unique=False, nullable=False) - short_name = db.Column(db.String(100), unique=False, nullable=False) - state = db.Column(db.String(2), server_default="", nullable=False) - - # See https://github.com/lucyparsons/OpenOversight/issues/462 - unique_internal_identifier_label = db.Column( - db.String(100), unique=False, nullable=True - ) - - __table_args__ = (UniqueConstraint("name", "state", name="departments_name_state"),) - - @property - def display_name(self): - 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): - return ( - db.session.query(Assignment.id) - .join(Officer, Assignment.officer_id == Officer.id) - .filter(Officer.department_id == self.id) - .count() - ) - - @cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_INCIDENTS)) - def total_documented_incidents(self): - 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): - return ( - db.session.query(Officer).filter(Officer.department_id == self.id).count() - ) - - def remove_database_cache_entries(self, update_types: List[str]) -> None: - """Remove the Department model key from the cache if it exists.""" - remove_database_cache_entries(self, update_types) - - class Job(BaseModel, TrackUpdates): __tablename__ = "jobs" @@ -264,10 +235,10 @@ class Note(BaseModel, TrackUpdates): class Description(BaseModel, TrackUpdates): __tablename__ = "descriptions" - officer = db.relationship("Officer", back_populates="descriptions") id = db.Column(db.Integer, primary_key=True) text_contents = db.Column(db.Text()) officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE")) + officer = db.relationship("Officer", back_populates="descriptions") class Officer(BaseModel, TrackUpdates): @@ -620,6 +591,211 @@ class Image(BaseModel, TrackUpdates): ) +class Incident(BaseModel, TrackUpdates): + __tablename__ = "incidents" + + id = db.Column(db.Integer, primary_key=True) + date = db.Column(db.Date, unique=False, index=True) + time = db.Column(db.Time, unique=False, index=True) + report_number = db.Column(db.String(50), index=True) + description = db.Column(db.Text(), nullable=True) + address_id = db.Column( + db.Integer, db.ForeignKey("locations.id", name="incidents_address_id_fkey") + ) + address = db.relationship( + "Location", + backref=db.backref("incidents", cascade_backrefs=False), + lazy="joined", + ) + license_plates = db.relationship( + "LicensePlate", + secondary=incident_license_plates, + lazy="subquery", + backref=db.backref("incidents", cascade_backrefs=False, lazy=True), + ) + links = db.relationship( + "Link", + secondary=incident_links, + lazy="subquery", + backref=db.backref("incidents", cascade_backrefs=False, lazy=True), + ) + officers = db.relationship( + "Officer", + secondary=officer_incidents, + lazy="subquery", + backref=db.backref( + "incidents", + cascade_backrefs=False, + order_by="Incident.date.desc(), Incident.time.desc()", + ), + ) + department_id = db.Column( + db.Integer, db.ForeignKey("departments.id", name="incidents_department_id_fkey") + ) + department = db.relationship( + "Department", backref=db.backref("incidents", cascade_backrefs=False), lazy=True + ) + + +class Link(BaseModel, TrackUpdates): + __tablename__ = "links" + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(100), index=True) + url = db.Column(db.Text(), nullable=False) + link_type = db.Column(db.String(100), index=True) + description = db.Column(db.Text(), nullable=True) + author = db.Column(db.String(255), nullable=True) + has_content_warning = db.Column(db.Boolean, nullable=False, default=False) + + @validates("url") + def validate_url(self, key, url): + return url_validator(url) + + +class Department(BaseModel, TrackUpdates): + __tablename__ = "departments" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), index=False, unique=False, nullable=False) + short_name = db.Column(db.String(100), unique=False, nullable=False) + state = db.Column(db.String(2), server_default="", nullable=False) + + # See https://github.com/lucyparsons/OpenOversight/issues/462 + unique_internal_identifier_label = db.Column( + db.String(100), unique=False, nullable=True + ) + + __table_args__ = (UniqueConstraint("name", "state", name="departments_name_state"),) + + @property + def display_name(self) -> str: + return self.name if not self.state else f"[{self.state}] {self.name}" + + @staticmethod + def get_assignments(department_id: int) -> List[Assignment]: + 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 assignments + + @staticmethod + def get_descriptions(department_id: int) -> List[Description]: + cache_params = (Department(id=department_id), KEY_DEPT_ALL_NOTES) + descriptions = get_database_cache_entry(*cache_params) + + if descriptions is None: + descriptions = ( + 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, descriptions) + + return descriptions + + @staticmethod + def get_incidents(department_id: int) -> List[Incident]: + 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 incidents + + @staticmethod + def get_links(department_id: int) -> List[Link]: + 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 links + + @staticmethod + def get_officers(department_id: int) -> List[Officer]: + 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 officers + + @staticmethod + def get_salaries(department_id: int) -> List[Salary]: + 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 salaries + + @cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_ASSIGNMENTS)) + def total_documented_assignments(self) -> int: + return ( + db.session.query(Assignment.id) + .join(Officer, Assignment.officer_id == Officer.id) + .filter(Officer.department_id == self.id) + .count() + ) + + @cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_INCIDENTS)) + 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) -> int: + return ( + db.session.query(Officer).filter(Officer.department_id == self.id).count() + ) + + def remove_database_cache_entries(self, update_types: List[str]) -> None: + """Remove the Department model key from the cache if it exists.""" + remove_database_cache_entries(self, update_types) + + class Location(BaseModel, TrackUpdates): __tablename__ = "locations" @@ -678,68 +854,9 @@ def validate_state(self, key, state): return state_validator(state) -class Link(BaseModel, TrackUpdates): - __tablename__ = "links" - - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(100), index=True) - url = db.Column(db.Text(), nullable=False) - link_type = db.Column(db.String(100), index=True) - description = db.Column(db.Text(), nullable=True) - author = db.Column(db.String(255), nullable=True) - has_content_warning = db.Column(db.Boolean, nullable=False, default=False) - - @validates("url") - def validate_url(self, key, url): - return url_validator(url) - - -class Incident(BaseModel, TrackUpdates): - __tablename__ = "incidents" - - id = db.Column(db.Integer, primary_key=True) - date = db.Column(db.Date, unique=False, index=True) - time = db.Column(db.Time, unique=False, index=True) - report_number = db.Column(db.String(50), index=True) - description = db.Column(db.Text(), nullable=True) - address_id = db.Column( - db.Integer, db.ForeignKey("locations.id", name="incidents_address_id_fkey") - ) - address = db.relationship( - "Location", backref=db.backref("incidents", cascade_backrefs=False) - ) - license_plates = db.relationship( - "LicensePlate", - secondary=incident_license_plates, - lazy="subquery", - backref=db.backref("incidents", cascade_backrefs=False, lazy=True), - ) - links = db.relationship( - "Link", - secondary=incident_links, - lazy="subquery", - backref=db.backref("incidents", cascade_backrefs=False, lazy=True), - ) - officers = db.relationship( - "Officer", - secondary=officer_incidents, - lazy="subquery", - backref=db.backref( - "incidents", - cascade_backrefs=False, - order_by="Incident.date.desc(), Incident.time.desc()", - ), - ) - department_id = db.Column( - db.Integer, db.ForeignKey("departments.id", name="incidents_department_id_fkey") - ) - department = db.relationship( - "Department", backref=db.backref("incidents", cascade_backrefs=False), lazy=True - ) - - class User(UserMixin, BaseModel): __tablename__ = "users" + id = db.Column(db.Integer, primary_key=True) # A universally unique identifier (UUID) that can be diff --git a/OpenOversight/tests/routes/test_api_v1.py b/OpenOversight/tests/routes/test_api_v1.py new file mode 100644 index 000000000..764b8b25a --- /dev/null +++ b/OpenOversight/tests/routes/test_api_v1.py @@ -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, + ) + 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