From d5c6f8d87d95d69d3a20e355f9f18bf3c46cec23 Mon Sep 17 00:00:00 2001 From: RyEggGit Date: Sat, 3 Feb 2024 11:59:04 -0500 Subject: [PATCH] Added Simple Get Route for Incidents --- backend/database/models/incident.py | 51 ++++++++++-------- backend/database/models/partner.py | 21 ++++---- backend/routes/partners.py | 56 ++++++++++++++++--- backend/tests/conftest.py | 52 ++++++++++++++---- backend/tests/test_partners.py | 84 ++++++++++++++++++++++++++++- 5 files changed, 217 insertions(+), 47 deletions(-) diff --git a/backend/database/models/incident.py b/backend/database/models/incident.py index ae3d5c9dd..ef046881d 100644 --- a/backend/database/models/incident.py +++ b/backend/database/models/incident.py @@ -1,9 +1,10 @@ """Define the SQL classes for Users.""" +from __future__ import annotations # allows type hinting of class itself import enum from datetime import datetime - from ..core import CrudMixin, db from backend.database.models._assoc_tables import incident_agency, incident_tag +from sqlalchemy.orm import RelationshipProperty class RecordType(enum.Enum): @@ -57,13 +58,20 @@ class VictimStatus(enum.Enum): class Incident(db.Model, CrudMixin): + """The incident table is the fact table.""" + # Just a note: this only explicitly used for type hinting + def __init__(self, **kwargs): + super().__init__(**kwargs) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) - source_id = db.Column( - db.Integer, db.ForeignKey("partner.id")) + source_id: RelationshipProperty[int] = db.Column( + db.Integer, db.ForeignKey("partner.id") + ) source_details = db.relationship( - "SourceDetails", backref="incident", uselist=False) + "SourceDetails", backref="incident", uselist=False + ) date_record_created = db.Column(db.DateTime) time_of_incident = db.Column(db.DateTime) time_confidence = db.Column(db.Integer) @@ -89,7 +97,8 @@ class Incident(db.Model, CrudMixin): # descriptions = db.relationship("Description", backref="incident") tags = db.relationship("Tag", secondary=incident_tag, backref="incidents") agencies_present = db.relationship( - "Agency", secondary=incident_agency, backref="recorded_incidents") + "Agency", secondary=incident_agency, backref="recorded_incidents" + ) participants = db.relationship("Participant", backref="incident") attachments = db.relationship("Attachment", backref="incident") investigations = db.relationship("Investigation", backref="incident") @@ -119,22 +128,22 @@ def create(self, refresh: bool = True): # ) # text = db.Column(db.Text) # type = db.Column(db.Text) # TODO: enum - # TODO: are there rules for this column other than text? - # organization_id = db.Column(db.Text) - # location = db.Column(db.Text) # TODO: location object - # # TODO: neighborhood seems like a weird identifier that may not always - # # apply in consistent ways across municipalities. - # neighborhood = db.Column(db.Text) - # stop_type = db.Column(db.Text) # TODO: enum - # call_type = db.Column(db.Text) # TODO: enum - # has_multimedia = db.Column(db.Boolean) - # from_report = db.Column(db.Boolean) - # # These may require an additional table. Also can dox a victim - # was_victim_arrested = db.Column(db.Boolean) - # arrest_id = db.Column(db.Integer) # TODO: foreign key of some sort? - # # Does an existing warrant count here? - # criminal_case_brought = db.Column(db.Boolean) - # case_id = db.Column(db.Integer) # TODO: foreign key of some sort? +# TODO: are there rules for this column other than text? +# organization_id = db.Column(db.Text) +# location = db.Column(db.Text) # TODO: location object +# # TODO: neighborhood seems like a weird identifier that may not always +# # apply in consistent ways across municipalities. +# neighborhood = db.Column(db.Text) +# stop_type = db.Column(db.Text) # TODO: enum +# call_type = db.Column(db.Text) # TODO: enum +# has_multimedia = db.Column(db.Boolean) +# from_report = db.Column(db.Boolean) +# # These may require an additional table. Also can dox a victim +# was_victim_arrested = db.Column(db.Boolean) +# arrest_id = db.Column(db.Integer) # TODO: foreign key of some sort? +# # Does an existing warrant count here? +# criminal_case_brought = db.Column(db.Boolean) +# case_id = db.Column(db.Integer) # TODO: foreign key of some sort? class SourceDetails(db.Model): diff --git a/backend/database/models/partner.py b/backend/database/models/partner.py index 24eccc6b4..b3f1a8933 100644 --- a/backend/database/models/partner.py +++ b/backend/database/models/partner.py @@ -1,5 +1,6 @@ - +from __future__ import annotations # allows type hinting of class itself from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.orm import RelationshipProperty from ..core import db, CrudMixin from enum import Enum from datetime import datetime @@ -27,10 +28,10 @@ def get_value(self): class PartnerMember(db.Model, CrudMixin): __tablename__ = "partner_user" id = db.Column(db.Integer, primary_key=True, autoincrement=True) - partner_id = db.Column(db.Integer, db.ForeignKey('partner.id'), - primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('user.id'), - primary_key=True) + partner_id = db.Column( + db.Integer, db.ForeignKey("partner.id"), primary_key=True + ) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), primary_key=True) user = db.relationship("User", back_populates="partner_association") partner = db.relationship("Partner", back_populates="member_association") role = db.Column(db.Enum(MemberRole)) @@ -53,10 +54,12 @@ class Partner(db.Model, CrudMixin): name = db.Column(db.Text) url = db.Column(db.Text) contact_email = db.Column(db.Text) - reported_incidents = db.relationship( - 'Incident', backref='source', lazy="select") - member_association = db.relationship( - 'PartnerMember', back_populates="partner", lazy="select") + reported_incidents: RelationshipProperty[int] = db.relationship( + "Incident", backref="source", lazy="select" + ) + member_association: RelationshipProperty[PartnerMember] = db.relationship( + "PartnerMember", back_populates="partner", lazy="select" + ) members = association_proxy("member_association", "user") def __repr__(self): diff --git a/backend/routes/partners.py b/backend/routes/partners.py index 5ec3be358..d1c1ddf80 100644 --- a/backend/routes/partners.py +++ b/backend/routes/partners.py @@ -1,3 +1,5 @@ +from typing import Any + from backend.auth.jwt import min_role_required from backend.mixpanel.mix import track_to_mp from backend.database.models.user import User, UserRole @@ -5,7 +7,7 @@ from flask_jwt_extended import get_jwt from flask_jwt_extended.view_decorators import jwt_required from flask_sqlalchemy import Pagination -from ..database import Partner, PartnerMember, MemberRole, db +from ..database import Partner, PartnerMember, MemberRole, db, Incident from ..schemas import ( CreatePartnerSchema, AddMemberSchema, @@ -15,8 +17,10 @@ partner_member_to_orm, partner_to_orm, validate, + incident_orm_to_json, ) + bp = Blueprint("partner_routes", __name__, url_prefix="/api/v1/partners") @@ -146,6 +150,9 @@ class Config: @min_role_required(UserRole.PUBLIC) @validate() # type: ignore def get_partner_users(partner_id: int): + # check if the partner exists + partner = Partner.get(partner_id) + # Get the page number from the query parameters (default to 1) page = request.args.get("page", 1, type=int) @@ -155,13 +162,9 @@ def get_partner_users(partner_id: int): # Query the PartnerMember table for records with # the given partner_id and paginate the results pagination: Pagination = PartnerMember.query.filter_by( - partner_id=partner_id + partner_id=partner.id ).paginate(page=page, per_page=per_page, error_out=False) - # If the partner_id is invalid, return a 404 error - if pagination.total == 0: - return {"message": "Partner not found"}, 404 - # Get the User objects associated with the members on the current page users: list[User] = [ User.query.get(member.user_id) for member in pagination.items @@ -242,3 +245,44 @@ def add_member_to_partner(partner_id: int): }, ) return partner_member_orm_to_json(created) + + +@bp.route("//incidents", methods=["GET"]) +@jwt_required() # type: ignore +@min_role_required(UserRole.PUBLIC) +@validate() # type: ignore +def get_incidents(partner_id: int): + """ + Get all incidents associated with a partner. + + Accepts Query Parameters for pagination: + per_page: number of results per page + page: page number + """ + + # check if the partner exists + partner: Partner = Partner.get(partner_id) # type: ignore + + # Get the page number from the query parameters (default to 1) + page = request.args.get("page", 1, type=int) + + # Get the number of items per page from the query parameters (default to 20) + per_page = request.args.get("per_page", 20, type=int) + + # Query the Incident table for records with the given partner_id + # and paginate the results + pagination: Any = Incident.query.filter_by(source_id=partner.id).paginate( + page=page, per_page=per_page, error_out=False + ) + + incidents: list[dict[str, Any]] = [ + incident_orm_to_json(incident) for incident in pagination.items + ] + + # Convert the Incident objects to dictionaries and return them as JSON + return { + "results": incidents, + "page": pagination.page, + "totalPages": pagination.pages, + "totalResults": pagination.total, + } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 377217c93..2bfabd37c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -4,10 +4,11 @@ from backend.auth import user_manager from backend.config import TestingConfig from backend.database import User, UserRole, db -from backend.database import Partner, PartnerMember, MemberRole +from backend.database import Partner, PartnerMember, MemberRole, Incident from datetime import datetime from pytest_postgresql.janitor import DatabaseJanitor from sqlalchemy import insert +from typing import Any example_email = "test@email.com" admin_email = "admin@email.com" @@ -81,6 +82,35 @@ def example_user(db_session): return user +@pytest.fixture # type: ignore +def example_incidents(db_session: Any, example_partner: Partner): + incidents = [ + Incident( + source_id=example_partner.id, + date_record_created=datetime.now(), + time_of_incident=datetime.now(), + time_confidence=90, + complaint_date=datetime.now().date(), + closed_date=datetime.now().date(), + location="Location 1", + longitude=12.34, + latitude=56.78, + description="Description 1", + stop_type="Stop Type 1", + call_type="Call Type 1", + has_attachments=True, + from_report=True, + was_victim_arrested=False, + criminal_case_brought=True, + ) + ] + for incident in incidents: + db_session.add(incident) + db_session.commit() + + return incidents + + @pytest.fixture def admin_user(db_session): user = User( @@ -102,7 +132,7 @@ def partner_admin(db_session, example_partner): email=p_admin_email, password=user_manager.hash_password(example_password), role=UserRole.CONTRIBUTOR, # This is not a system admin, - # so we can't use ADMIN here + # so we can't use ADMIN here first_name="contributor", last_name="last", phone_number="(012) 345-6789", @@ -110,9 +140,11 @@ def partner_admin(db_session, example_partner): db_session.add(user) db_session.commit() insert_statement = insert(PartnerMember).values( - partner_id=example_partner.id, user_id=user.id, - role=MemberRole.ADMIN, date_joined=datetime.now(), - is_active=True + partner_id=example_partner.id, + user_id=user.id, + role=MemberRole.ADMIN, + date_joined=datetime.now(), + is_active=True, ) db_session.execute(insert_statement) db_session.commit() @@ -127,14 +159,16 @@ def partner_publisher(db_session, example_partner): password=user_manager.hash_password(example_password), role=UserRole.CONTRIBUTOR, first_name="contributor", - last_name="last" + last_name="last", ) db_session.add(user) db_session.commit() insert_statement = insert(PartnerMember).values( - partner_id=example_partner.id, user_id=user.id, - role=MemberRole.PUBLISHER, date_joined=datetime.now(), - is_active=True + partner_id=example_partner.id, + user_id=user.id, + role=MemberRole.PUBLISHER, + date_joined=datetime.now(), + is_active=True, ) db_session.execute(insert_statement) db_session.commit() diff --git a/backend/tests/test_partners.py b/backend/tests/test_partners.py index 8634e6077..622d7b4cc 100644 --- a/backend/tests/test_partners.py +++ b/backend/tests/test_partners.py @@ -1,6 +1,6 @@ import pytest from backend.auth import user_manager -from backend.database import Partner, PartnerMember, MemberRole +from backend.database import Partner, PartnerMember, MemberRole, Incident from backend.database.models.user import User, UserRole from typing import Any @@ -311,6 +311,8 @@ def test_get_partner_users( def test_get_partner_users_error( client: Any, access_token: str, + example_partner: Partner, + example_members: PartnerMember, ) -> None: # Test that we can get partner users res: Any = client.get( @@ -318,4 +320,82 @@ def test_get_partner_users_error( headers={"Authorization": "Bearer {0}".format(access_token)}, ) assert res.status_code == 404 - assert res.get_json()["message"] == "Partner not found" + + +def test_get_incidents( + client: Any, + example_partner: Partner, + access_token: str, + example_incidents: list[Incident], +) -> None: + # Test getting incidents for the partner + res = client.get( + f"/api/v1/partners/{example_partner.id}/incidents", + headers={"Authorization": "Bearer {0}".format(access_token)}, + ) + assert res.status_code == 200 + data = res.get_json() + + # Verify the response structure + assert "results" in data + assert "page" in data + assert "totalPages" in data + assert "totalResults" in data + + # Verify the results + assert len(data["results"]) == len(example_incidents) + + # Verify the page number + assert data["page"] == 1 + + # Verify the total pages + assert data["totalPages"] == 1 + + # Verify the total results + assert data["totalResults"] == len(example_incidents) + + +def test_get_incidents_partner_not_found( + client: Any, + access_token: str, +) -> None: + # Test getting incidents for the partner + res = client.get( + f"/api/v1/partners/{9999}/incidents", + headers={"Authorization": "Bearer {0}".format(access_token)}, + ) + assert res.status_code == 404 + + +def test_get_incidents_pagination( + client: Any, + access_token: str, + example_partner: Partner, + example_incidents: list[Incident], +) -> None: + # Test getting incidents for the partner + res = client.get( + f"/api/v1/partners/{example_partner.id}/incidents", + headers={"Authorization": "Bearer {0}".format(access_token)}, + ) + + assert res.status_code == 200 + data = res.get_json() + + # Verify the response structure + assert "results" in data + assert "page" in data + assert "totalPages" in data + assert "totalResults" in data + + # Verify the results + assert len(data["results"]) == len(example_incidents) + + # Verify the page number + assert data["page"] == 1 + + # Verify the total pages + assert data["totalPages"] == 1 + + # Verify the total results + assert data["totalResults"] == len(example_incidents)