diff --git a/alembic/dev_seeds.py b/alembic/dev_seeds.py index 472feb5a..e1baa7ec 100644 --- a/alembic/dev_seeds.py +++ b/alembic/dev_seeds.py @@ -2,12 +2,11 @@ from backend.database import User, UserRole from backend.auth import user_manager from backend.database.models.incident import Incident -from backend.database.models.officer import Officer +from backend.database.models.perpetrator import Perpetrator from backend.database.models.source import Source from backend.database.models.use_of_force import UseOfForce - def create_user(user): user_exists = ( db.session.query(User).filter_by(email=user.email).first() is not None @@ -61,6 +60,7 @@ def create_user(user): ) ) + def create_source(source): source_exists = ( db.session.query(Source).filter_by(id=source.id).first() is not None @@ -69,13 +69,13 @@ def create_source(source): if not source_exists: source.create() + create_source( Source( - id="mpv", - publication_name="Mapping Police Violence", - publication_date="01/01/2015", - author="Samuel Sinyangwe", - URL="https://mappingpoliceviolence.us", + id=10000000, + name="Mapping Police Violence", + url="https://mappingpoliceviolence.us", + contact_email="info@campaignzero.org" ) ) @@ -83,22 +83,23 @@ def create_source(source): def create_incident(key=1, date="10-01-2019", lon=84, lat=34): base_id = 10000000 id = base_id + key + mpv = db.session.query(Source).filter_by( + name="Mapping Police Violence").first() incident = Incident( id=id, + source=mpv, location=f"Test location {key}", longitude=lon, latitude=lat, description=f"Test description {key}", - department=f"Small Police Department {key}", time_of_incident=f"{date} 00:00:00", - officers=[ - Officer( + perpetrators=[ + Perpetrator( first_name=f"TestFirstName {key}", last_name=f"TestLastName {key}", ) ], - use_of_force=[UseOfForce(item=f"gunshot {key}")], - source="mpv", + use_of_force=[UseOfForce(item=f"gunshot {key}")] ) exists = db.session.query(Incident).filter_by(id=id).first() is not None diff --git a/backend/database/__init__.py b/backend/database/__init__.py index f535b124..6c93fb2c 100644 --- a/backend/database/__init__.py +++ b/backend/database/__init__.py @@ -16,7 +16,8 @@ from .models.incident import * from .models.investigation import * from .models.legal_case import * -from .models.multimedia import * +from .models.attachment import * +from .models.perpetrator import * from .models.officer import * from .models.participant import * from .models.tag import * @@ -25,5 +26,4 @@ from .models.use_of_force import * from .models.user import * from .models.victim import * -from .models.accusation import * from .models.source import * diff --git a/backend/database/core.py b/backend/database/core.py index 8bbfb3d6..5f303b1c 100644 --- a/backend/database/core.py +++ b/backend/database/core.py @@ -15,9 +15,7 @@ from flask_sqlalchemy import SQLAlchemy from psycopg2 import connect from psycopg2.extensions import connection -from sqlalchemy import ForeignKey from sqlalchemy.exc import ResourceClosedError -from sqlalchemy.ext.declarative import declared_attr from werkzeug.utils import secure_filename from ..config import TestingConfig @@ -50,34 +48,6 @@ def get(cls, id: Any, abort_if_null: bool = True): return obj -class SourceMixin: - """Adds support for unique, external source ID's""" - - # Identifies the source dataset or organization - @declared_attr - def source(self): - return db.Column(db.Text, ForeignKey("source.id")) - - # Identifies the unique primary key in the source - source_id = db.Column(db.Text) - - def __init_subclass__(cls, **kwargs): - # Require that source ID's be unique within each source. Postgres does - # not enforce uniqueness if either value is null. - # https://www.postgresql.org/docs/9.0/ddl-constraints.html#AEN2445 - uc = db.UniqueConstraint( - "source", "source_id", name=f"{cls.__name__.lower()}_source_uc" - ) - - cls.__table_args__ = tuple( - a - for a in (uc, getattr(cls, "__table_args__", None)) - if a is not None - ) - - super().__init_subclass__(**kwargs) - - QUERIES_DIR = os.path.abspath( os.path.join(os.path.dirname(__file__), "queries") ) diff --git a/backend/database/models/_assoc_tables.py b/backend/database/models/_assoc_tables.py new file mode 100644 index 00000000..9a10d4c2 --- /dev/null +++ b/backend/database/models/_assoc_tables.py @@ -0,0 +1,40 @@ +from .. import db +from backend.database.models.officer import Rank + +incident_agency = db.Table( + 'incident_agency', + db.Column('incident_id', db.Integer, db.ForeignKey('incident.id'), + primary_key=True), + db.Column('agency_id', db.Integer, db.ForeignKey('agency.id'), + primary_key=True), + db.Column('officers_present', db.Integer) +) + +incident_tag = db.Table( + 'incident_tag', + db.Column('incident_id', db.Integer, db.ForeignKey('incident.id'), + primary_key=True), + db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True) +) + +agency_officer = db.Table( + 'agency_officer', + db.Column('agency_id', db.Integer, db.ForeignKey('agency.id'), + primary_key=True), + db.Column('officer_id', db.Integer, db.ForeignKey('officer.id'), + primary_key=True), + db.Column('earliest_employment', db.Text), + db.Column('latest_employment', db.Text), + db.Column('badge_number', db.Text), + db.Column('unit', db.Text), + db.Column('highest_rank', db.Enum(Rank)), + db.Column('currently_employed', db.Boolean) +) + +perpetrator_officer = db.Table( + 'perpetrator_officer', + db.Column('perpetrator_id', db.Integer, db.ForeignKey('perpetrator.id'), + primary_key=True), + db.Column('officer_id', db.Integer, db.ForeignKey('officer.id'), + primary_key=True) +) diff --git a/backend/database/models/accusation.py b/backend/database/models/accusation.py deleted file mode 100644 index fc67a52c..00000000 --- a/backend/database/models/accusation.py +++ /dev/null @@ -1,17 +0,0 @@ -from .. import db - - -class Accusation(db.Model): - """Models CPDP `complaints-accused` table""" - - id = db.Column(db.Integer, primary_key=True) - incident_id = db.Column(db.Integer, db.ForeignKey("incident.id")) - officer_id = db.Column(db.Integer, db.ForeignKey("officer.id")) - category = db.Column(db.Text) - category_code = db.Column(db.Text) - finding = db.Column(db.Text) - outcome = db.Column(db.Text) - - __table_args__ = ( - db.UniqueConstraint("incident_id", "officer_id", name="accusation_uc"), - ) diff --git a/backend/database/models/agency.py b/backend/database/models/agency.py index fd579b70..9f3744aa 100644 --- a/backend/database/models/agency.py +++ b/backend/database/models/agency.py @@ -1,7 +1,27 @@ +import enum +from backend.database.models._assoc_tables import agency_officer from .. import db +class JURISDICTION(enum.Enum): + FEDERAL = 1 + STATE = 2 + COUNTY = 3 + MUNICIPAL = 4 + PRIVATE = 5 + OTHER = 6 + + class Agency(db.Model): id = db.Column(db.Integer, primary_key=True) - incident_id = db.Column(db.Integer, db.ForeignKey("incident.id")) name = db.Column(db.Text) + website_url = db.Column(db.Text) + hq_address = db.Column(db.Text) + hq_city = db.Column(db.Text) + hq_zip = db.Column(db.Text) + jurisdiction = db.Column(db.Enum(JURISDICTION)) + known_officers = db.relationship( + "Officer", secondary=agency_officer, backref="known_employers") + + def __repr__(self): + return f"" diff --git a/backend/database/models/attachment.py b/backend/database/models/attachment.py new file mode 100644 index 00000000..9d0259e0 --- /dev/null +++ b/backend/database/models/attachment.py @@ -0,0 +1,10 @@ +from .. import db + + +class Attachment(db.Model): + id = db.Column(db.Integer, primary_key=True) + incident_id = db.Column(db.Integer, db.ForeignKey("incident.id")) + title = db.Column(db.Text) + hash = db.Column(db.Text) + location = db.Column(db.Text) + filetype = db.Column(db.Text) diff --git a/backend/database/models/incident.py b/backend/database/models/incident.py index 6515ee03..6745c30b 100644 --- a/backend/database/models/incident.py +++ b/backend/database/models/incident.py @@ -1,10 +1,15 @@ """Define the SQL classes for Users.""" import enum +from ..core import CrudMixin, db +from backend.database.models._assoc_tables import incident_agency, incident_tag -from ..core import CrudMixin, SourceMixin, db -# Question: Should we be doing string enums? +class RecordType(enum.Enum): + NEWS_REPORT = 1 + GOVERNMENT_RECORD = 2 + LEGAL_ACTION = 3 + PERSONAL_ACCOUNT = 4 class InitialEncounter(enum.Enum): @@ -50,31 +55,26 @@ class VictimStatus(enum.Enum): DECEASED = 5 -# TODO: This file's a bit of a mess (my fault!) -# There are a lot of association tables in here, and the incidents table is -# not clearly either a facts table or component table. -# We need to get a better idea of the relationships we want and then we should -# implement them accordingly. - - -class Incident(db.Model, CrudMixin, SourceMixin): +class Incident(db.Model, CrudMixin): """The incident table is the fact table.""" id = db.Column(db.Integer, primary_key=True, autoincrement=True) + source_id = db.Column( + db.String, db.ForeignKey("source.id")) + source_details = db.relationship( + "SourceDetails", backref="incident", uselist=False) time_of_incident = db.Column(db.DateTime) + time_confidence = db.Column(db.Integer) complaint_date = db.Column(db.Date) closed_date = db.Column(db.Date) location = db.Column(db.Text) # TODO: location object # Float is double precision (8 bytes) by default in Postgres longitude = db.Column(db.Float) latitude = db.Column(db.Float) - # TODO: neighborhood seems like a weird identifier that may not always - # apply in consistent ways across municipalities. - neighborhood = db.Column(db.Text) description = 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) + has_attachments = 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) @@ -83,31 +83,38 @@ class Incident(db.Model, CrudMixin, SourceMixin): criminal_case_brought = db.Column(db.Boolean) case_id = db.Column(db.Integer) # TODO: foreign key of some sort? victims = db.relationship("Victim", backref="incident") - # TODO: Remove this. incident-officer relationship is many-many using - # accusation as the join table. - officers = db.relationship("Officer", backref="incident") - department = db.Column(db.Text) + perpetrators = db.relationship("Perpetrator", backref="incident") # descriptions = db.relationship("Description", backref="incident") - tags = db.relationship("Tag", backref="incident") + tags = db.relationship("Tag", secondary=incident_tag, backref="incidents") + agencies_present = db.relationship( + "Agency", secondary=incident_agency, backref="recorded_incidents") participants = db.relationship("Participant", backref="incident") - multimedias = db.relationship("Multimedia", backref="incident") + attachments = db.relationship("Attachment", backref="incident") investigations = db.relationship("Investigation", backref="incident") results_of_stop = db.relationship("ResultOfStop", backref="incident") actions = db.relationship("Action", backref="incident") use_of_force = db.relationship("UseOfForce", backref="incident") legal_case = db.relationship("LegalCase", backref="incident") - accusations = db.relationship("Accusation", backref="incident") - -class Description(db.Model): - id = db.Column(db.Integer, primary_key=True) # description id - incident_id = db.Column( - db.Integer, db.ForeignKey("incident.id"), nullable=False - ) - text = db.Column(db.Text) - type = db.Column(db.Text) # TODO: enum + def __repr__(self): + """Represent instance as a unique string.""" + return f"" + + +# On the Description object: +# Seems like this is based on the WITNESS standard. It also appears that the +# original intention of that standard is to allow multiple descriptions to be +# applied to a single incident. I recomend we handle this as part of a +# larger epic when we add the annotation system, which is related. +# class Description(db.Model): +# id = db.Column(db.Integer, primary_key=True) # description id +# incident_id = db.Column( +# db.Integer, db.ForeignKey("incident.id"), nullable=False +# ) +# text = db.Column(db.Text) +# type = db.Column(db.Text) # TODO: enum # TODO: are there rules for this column other than text? - source = db.Column(db.Text) + # source = 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. @@ -122,3 +129,27 @@ class Description(db.Model): # # 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): + id = db.Column(db.Integer, primary_key=True) # source details id + incident_id = db.Column( + db.Integer, db.ForeignKey("incident.id"), nullable=False + ) + record_type = db.Column(db.Enum(RecordType)) + # For Journalistic Publications + publication_name = db.Column(db.Text) + publication_date = db.Column(db.Date) + publication_url = db.Column(db.Text) + author = db.Column(db.Text) + author_url = db.Column(db.Text) + author_email = db.Column(db.Text) + # For Government Records + reporting_organization = db.Column(db.Text) + reporting_organization_url = db.Column(db.Text) + reporting_organization_email = db.Column(db.Text) + # For Legal Records + court = db.Column(db.Text) + judge = db.Column(db.Text) + docket_number = db.Column(db.Text) + date_of_action = db.Column(db.Date) diff --git a/backend/database/models/multimedia.py b/backend/database/models/multimedia.py deleted file mode 100644 index 3042a34a..00000000 --- a/backend/database/models/multimedia.py +++ /dev/null @@ -1,6 +0,0 @@ -from .. import db - - -class Multimedia(db.Model): - id = db.Column(db.Integer, primary_key=True) - incident_id = db.Column(db.Integer, db.ForeignKey("incident.id")) diff --git a/backend/database/models/officer.py b/backend/database/models/officer.py index 87f1f5f9..1d11334e 100644 --- a/backend/database/models/officer.py +++ b/backend/database/models/officer.py @@ -1,7 +1,5 @@ import enum -from backend.database.core import SourceMixin - from .. import db @@ -18,18 +16,17 @@ class Rank(str, enum.Enum): CHIEF = "CHIEF" -class Officer(db.Model, SourceMixin): +class Officer(db.Model): id = db.Column(db.Integer, primary_key=True) # officer id - incident_id = db.Column(db.Integer, db.ForeignKey("incident.id")) first_name = db.Column(db.Text) last_name = db.Column(db.Text) race = db.Column(db.Text) + ethnicity = db.Column(db.Text) gender = db.Column(db.Text) - appointed_date = db.Column(db.DateTime) - badge = db.Column(db.Text) - unit = db.Column(db.Text) # type? # Note: rank at time of incident - rank = db.Column(db.Text) # type? + rank = db.Column(db.Enum(Rank)) star = db.Column(db.Text) # type? date_of_birth = db.Column(db.Date) - accusations = db.relationship("Accusation", backref="officer") + + def __repr__(self): + return f"" diff --git a/backend/database/models/perpetrator.py b/backend/database/models/perpetrator.py new file mode 100644 index 00000000..be44f075 --- /dev/null +++ b/backend/database/models/perpetrator.py @@ -0,0 +1,24 @@ +from backend.database.models._assoc_tables import perpetrator_officer +from backend.database.models.officer import Rank +from .. import db + + +class Perpetrator(db.Model): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + incident_id = db.Column(db.Integer, db.ForeignKey("incident.id")) + first_name = db.Column(db.Text) + last_name = db.Column(db.Text) + race = db.Column(db.Text) + ethnicity = db.Column(db.Text) + gender = db.Column(db.Text) + badge = db.Column(db.Text) + unit = db.Column(db.Text) # type? + # Note: rank at time of incident + rank = db.Column(db.Enum(Rank)) + star = db.Column(db.Text) # type? + role = db.Column(db.Text) + suspects = db.relationship( + "Officer", secondary=perpetrator_officer, backref="accusations") + + def __repr__(self): + return f"" diff --git a/backend/database/models/source.py b/backend/database/models/source.py index ffb4b87a..4c5641cf 100644 --- a/backend/database/models/source.py +++ b/backend/database/models/source.py @@ -2,8 +2,9 @@ class Source(db.Model, CrudMixin): - id = db.Column(db.Text, primary_key=True) - publication_name = db.Column(db.Text) - publication_date = db.Column(db.Date) - author = db.Column(db.Text) - URL = db.Column(db.Text) + id = db.Column(db.String, primary_key=True) + 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") diff --git a/backend/database/models/tag.py b/backend/database/models/tag.py index ed0f5d48..dc71438c 100644 --- a/backend/database/models/tag.py +++ b/backend/database/models/tag.py @@ -3,7 +3,7 @@ class Tag(db.Model): id = db.Column(db.Integer, primary_key=True) - incident_id = db.Column( - db.Integer, db.ForeignKey("incident.id"), nullable=False - ) term = db.Column(db.Text) + + def __repr__(self): + return f"" diff --git a/backend/database/models/victim.py b/backend/database/models/victim.py index 8e4a3ba3..3f7ae785 100644 --- a/backend/database/models/victim.py +++ b/backend/database/models/victim.py @@ -6,8 +6,9 @@ class Victim(db.Model): incident_id = db.Column(db.Integer, db.ForeignKey("incident.id")) name = db.Column(db.Text) race = db.Column(db.Text) + ethnicity = db.Column(db.Text) gender = db.Column(db.Text) - date_of_birth = db.Column(db.Date) # TODO: add "estimated"? + age = db.Column(db.Integer) manner_of_injury = db.Column(db.Text) # TODO: is an enum injury_description = db.Column(db.Text) injury_condition = db.Column(db.Text) diff --git a/backend/schemas.py b/backend/schemas.py index aaa62d68..190cc53e 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -10,11 +10,14 @@ from .database import User from .database.models.action import Action -from .database.models.incident import Description, Incident +from .database.models.source import Source +from .database.models.incident import Incident, SourceDetails +from .database.models.agency import Agency +from .database.models.officer import Officer from .database.models.investigation import Investigation from .database.models.legal_case import LegalCase -from .database.models.multimedia import Multimedia -from .database.models.officer import Officer +from .database.models.attachment import Attachment +from .database.models.perpetrator import Perpetrator from .database.models.participant import Participant from .database.models.result_of_stop import ResultOfStop from .database.models.tag import Tag @@ -95,11 +98,10 @@ def validate(auth=True, **kwargs): _incident_list_attrs = [ "victims", - "officers", - "descriptions", + "perpetrators", "tags", "participants", - "multimedias", + "attachments", "investigations", "results_of_stop", "actions", @@ -127,13 +129,16 @@ def schema_create(model_type: DeclarativeMeta, **kwargs) -> ModelMetaclass: return sqlalchemy_to_pydantic(model_type, exclude="id", **kwargs) +CreateSourceSchema = schema_create(Source) _BaseCreateIncidentSchema = schema_create(Incident) -CreateVictimSchema = schema_create(Victim) CreateOfficerSchema = schema_create(Officer) -CreateDescriptionSchema = schema_create(Description) +CreateAgencySchema = schema_create(Agency) +CreateVictimSchema = schema_create(Victim) +CreatePerpetratorSchema = schema_create(Perpetrator) +CreateSourceDetailsSchema = schema_create(SourceDetails) CreateTagSchema = schema_create(Tag) CreateParticipantSchema = schema_create(Participant) -CreateMultimediaSchema = schema_create(Multimedia) +CreateAttachmentSchema = schema_create(Attachment) CreateInvestigationSchema = schema_create(Investigation) CreateResultOfStopSchema = schema_create(ResultOfStop) CreateActionSchema = schema_create(Action) @@ -143,10 +148,10 @@ def schema_create(model_type: DeclarativeMeta, **kwargs) -> ModelMetaclass: class CreateIncidentSchema(_BaseCreateIncidentSchema, _IncidentMixin): victims: Optional[List[CreateVictimSchema]] - officers: Optional[List[CreateOfficerSchema]] + perpetrators: Optional[List[CreatePerpetratorSchema]] tags: Optional[List[CreateTagSchema]] participants: Optional[List[CreateParticipantSchema]] - multimedias: Optional[List[CreateMultimediaSchema]] + attachments: Optional[List[CreateAttachmentSchema]] investigations: Optional[List[CreateInvestigationSchema]] results_of_stop: Optional[List[CreateResultOfStopSchema]] actions: Optional[List[CreateActionSchema]] @@ -160,11 +165,10 @@ def schema_get(model_type: DeclarativeMeta, **kwargs) -> ModelMetaclass: _BaseIncidentSchema = schema_get(Incident) VictimSchema = schema_get(Victim) -OfficerSchema = schema_get(Officer) -DescriptionSchema = schema_get(Description) +PerpetratorSchema = schema_get(Perpetrator) TagSchema = schema_get(Tag) ParticipantSchema = schema_get(Participant) -MultimediaSchema = schema_get(Multimedia) +AttachmentSchema = schema_get(Attachment) InvestigationSchema = schema_get(Investigation) ResultOfStopSchema = schema_get(ResultOfStop) ActionSchema = schema_get(Action) @@ -174,10 +178,10 @@ def schema_get(model_type: DeclarativeMeta, **kwargs) -> ModelMetaclass: class IncidentSchema(_BaseIncidentSchema, _IncidentMixin): victims: List[VictimSchema] - officers: List[OfficerSchema] + perpetrators: List[PerpetratorSchema] tags: List[TagSchema] participants: List[ParticipantSchema] - multimedias: List[MultimediaSchema] + attachments: List[AttachmentSchema] investigations: List[InvestigationSchema] results_of_stop: List[ResultOfStopSchema] actions: List[ActionSchema] @@ -198,7 +202,7 @@ def incident_to_orm(incident: CreateIncidentSchema) -> Incident: associated with a schema instance. """ - converters = {"officers": Officer, "use_of_force": UseOfForce} + converters = {"perpetrators": Perpetrator, "use_of_force": UseOfForce} orm_attrs = incident.dict() for k, v in orm_attrs.items(): is_dict = isinstance(v, dict) @@ -217,7 +221,6 @@ def incident_orm_to_json(incident: Incident) -> dict: exclude={ "actions", "investigations", - "multimedias", "legal_case", "participants", "results_of_stop", diff --git a/backend/scraper/configs.yaml b/backend/scraper/configs.yaml index 2050a464..2f6d3b99 100644 --- a/backend/scraper/configs.yaml +++ b/backend/scraper/configs.yaml @@ -45,7 +45,6 @@ tables: - death_location_state - description - perpetrator - - department_present optional: - incident_type victim: diff --git a/backend/scraper/data_scrapers/load_full_database.py b/backend/scraper/data_scrapers/load_full_database.py index bfa9d937..4f9a8d76 100644 --- a/backend/scraper/data_scrapers/load_full_database.py +++ b/backend/scraper/data_scrapers/load_full_database.py @@ -4,7 +4,7 @@ from dateutil import parser from ...api import db -from ...database import UseOfForce, Incident, Officer +from ...database import UseOfForce, Incident, Perpetrator def isnan(x): @@ -69,8 +69,8 @@ def row_to_orm(row): if row.department_present: incident.department = row.department_present if row.perpetrator: - incident.officers = [ - Officer( + incident.perpetrators = [ + Perpetrator( last_name=row.perpetrator, ) ] diff --git a/backend/tests/test_incidents.py b/backend/tests/test_incidents.py index 49825b9a..60b789ab 100644 --- a/backend/tests/test_incidents.py +++ b/backend/tests/test_incidents.py @@ -3,52 +3,52 @@ import pytest from backend.database import Incident, Source +mock_sources = { + "cpdp": {"name": "Citizens Police Data Project"}, + "mpv": {"name": "Mapping Police Violence"}, +} + mock_incidents = { "domestic": { "time_of_incident": "2021-03-14 01:05:09", "description": "Domestic disturbance", - "officers": [ + "perpetrators": [ {"first_name": "Susie", "last_name": "Suserson"}, {"first_name": "Lisa", "last_name": "Wong"}, ], "use_of_force": [{"item": "Injurious restraint"}], - "source": "cpdp", + "source": "Citizens Police Data Project", "location": "123 Right St Chicago, IL", }, "traffic": { "time_of_incident": "2021-10-01 00:00:00", "description": "Traffic stop", - "officers": [ + "perpetrators": [ {"first_name": "Ronda", "last_name": "Sousa"}, ], "use_of_force": [{"item": "verbalization"}], - "source": "mpv", + "source": "Mapping Police Violence", "location": "Park St and Boylston Boston", }, "firearm": { "time_of_incident": "2021-10-05 00:00:00", "description": "Robbery", - "officers": [ + "perpetrators": [ {"first_name": "Dale", "last_name": "Green"}, ], "use_of_force": [{"item": "indirect firearm"}], - "source": "cpdp", + "source": "Citizens Police Data Project", "location": "CHICAGO ILLINOIS", }, "missing_fields": { "description": "Robbery", - "officers": [ + "perpetrators": [ {"first_name": "Dale", "last_name": "Green"}, ], - "source": "cpdp", + "source": "Citizens Police Data Project", }, } -mock_sources = { - "cpdp": {"publication_name": "chicago police data project"}, - "mpv": {"publication_name": "Mapping Police Violence"}, -} - @pytest.fixture def example_incidents(db_session, client, access_token): @@ -69,7 +69,7 @@ def example_incidents(db_session, client, access_token): def test_create_incident(db_session, example_incidents): - expected = mock_incidents["domestic"] + # expected = mock_incidents["domestic"] created = example_incidents["domestic"] incident_obj = ( @@ -78,9 +78,10 @@ def test_create_incident(db_session, example_incidents): assert incident_obj.time_of_incident == datetime(2021, 3, 14, 1, 5, 9) for i in [0, 1]: - assert incident_obj.officers[i].id == created["officers"][i]["id"] + assert incident_obj.perpetrators[i].id == \ + created["perpetrators"][i]["id"] assert incident_obj.use_of_force[0].id == created["use_of_force"][0]["id"] - assert incident_obj.source == expected["source"] + # assert incident_obj.source == expected["source"] def test_get_incident(app, client, db_session, access_token): diff --git a/frontend/compositions/incident-view/incident-view-body/incident-data/index.tsx b/frontend/compositions/incident-view/incident-view-body/incident-data/index.tsx index b8d96a42..9771f73b 100644 --- a/frontend/compositions/incident-view/incident-view-body/incident-data/index.tsx +++ b/frontend/compositions/incident-view/incident-view-body/incident-data/index.tsx @@ -13,7 +13,7 @@ export default function IncidentData(incident: Incident) {

{description}

-

Departments Involved:

+

Agencies Involved:

{/* TODO: Display badge numbers and departments once api model is updated.
{incident.officers.map((officer) => ( diff --git a/frontend/compositions/officer-view/officer-affiliations/index.tsx b/frontend/compositions/officer-view/officer-affiliations/index.tsx index c1b00acc..46df2295 100644 --- a/frontend/compositions/officer-view/officer-affiliations/index.tsx +++ b/frontend/compositions/officer-view/officer-affiliations/index.tsx @@ -5,12 +5,9 @@ export default function OfficerAffiliations(officer: OfficerRecordType) { const { affiliations } = officer const { wrapper } = styles - const affiliationsString = affiliations.join(", ") - return (

Professional Affiliations:

-

{affiliationsString}

) } diff --git a/frontend/compositions/officer-view/officer-view-header/index.tsx b/frontend/compositions/officer-view/officer-view-header/index.tsx index 765dcd67..92dbf4fa 100644 --- a/frontend/compositions/officer-view/officer-view-header/index.tsx +++ b/frontend/compositions/officer-view/officer-view-header/index.tsx @@ -2,7 +2,7 @@ import { OfficerRecordType } from "../../../models/officer" import styles from "./officer-view-header.module.css" export default function OfficerHeader(officer: OfficerRecordType) { - const { firstName, lastName, badgeNo, status, department } = officer + const { firstName, lastName, knownEmployers } = officer const { category, name, titleAndName, otherData, viewWrapper } = styles return ( @@ -15,16 +15,7 @@ export default function OfficerHeader(officer: OfficerRecordType) {

-

Badge Number

-

{badgeNo}

-
-
-

Officer Status

-

{status}

-
-
-

Department

-

{department}

+

Known Employers

diff --git a/frontend/compositions/officer-view/officer-work-history/work-history-instance/index.tsx b/frontend/compositions/officer-view/officer-work-history/work-history-instance/index.tsx index 6103525f..aef63730 100644 --- a/frontend/compositions/officer-view/officer-work-history/work-history-instance/index.tsx +++ b/frontend/compositions/officer-view/officer-work-history/work-history-instance/index.tsx @@ -3,16 +3,16 @@ import styles from "./work-history-instance.module.css" import Image from "next/image" export default function WorkHistoryInstance(pastWorkplace: EmploymentType) { - const { department, status, startDate, endDate } = pastWorkplace - const { departmentName, deptImage, deptAddress, webAddress } = department - const startDateString = new Date(startDate).toLocaleDateString().split(",")[0] - const endDateString = new Date(endDate).toLocaleDateString().split(",")[0] + const { agency, currentlyEmployed, earliestEmployment, latestEmployment } = pastWorkplace + const { agencyName, agencyImage, agencyHqAddress, websiteUrl } = agency + const startDateString = new Date(earliestEmployment).toLocaleDateString().split(",")[0] + const endDateString = new Date(latestEmployment).toLocaleDateString().split(",")[0] const { patch, wrapper, dates } = styles return (
- {departmentName.concat(" + {agencyName.concat("

{status} @@ -20,9 +20,9 @@ export default function WorkHistoryInstance(pastWorkplace: EmploymentType) { {startDateString} - {endDateString}

- {departmentName} + {agencyName} {/*TODO: Get Phone number from officer data, mock data currently does not have*/} -

(123) 456-7890 * {deptAddress}

+

(123) 456-7890 * {agencyHqAddress}

) diff --git a/frontend/compositions/officer-view/optional-officer-info/index.tsx b/frontend/compositions/officer-view/optional-officer-info/index.tsx index c63d2668..26fa1e8c 100644 --- a/frontend/compositions/officer-view/optional-officer-info/index.tsx +++ b/frontend/compositions/officer-view/optional-officer-info/index.tsx @@ -2,10 +2,10 @@ import { OfficerRecordType } from "../../../models/officer" import styles from "./optional-officer-info.module.css" export default function OptionalOfficerInfo(officer: OfficerRecordType) { - const { birthDate, gender, race, ethnicity, incomeBracket } = officer + const { dateOfBirth, gender, race } = officer const { category, data, viewWrapper } = styles - const dateString: string = new Date(birthDate).toLocaleDateString().split(",")[0] + const dateString: string = new Date(dateOfBirth).toLocaleDateString().split(",")[0] return (
@@ -21,14 +21,6 @@ export default function OptionalOfficerInfo(officer: OfficerRecordType) {

Race

{race}

-
-

Ethnicity

-

{ethnicity}

-
-
-

Income Bracket

-

{incomeBracket}

-
) diff --git a/frontend/compositions/profile-saved-tables/saved-results.tsx b/frontend/compositions/profile-saved-tables/saved-results.tsx index 752a8528..ccd47fb1 100644 --- a/frontend/compositions/profile-saved-tables/saved-results.tsx +++ b/frontend/compositions/profile-saved-tables/saved-results.tsx @@ -1,19 +1,19 @@ import React from "react" import { Column } from "react-table" import { useSearch } from "../../helpers" -import { Officer } from "../../helpers/api" +import { Perpetrator } from "../../helpers/api" import { DataTable } from "../../shared-components/data-table/data-table" export const savedResultsColumns: Column[] = [ { - Header: "Officer(s)", + Header: "Perpetrator(s)", accessor: (row: any) => - row["officers"].map((names: Officer) => Object.values(names).join(", ")).join(", "), - id: "officers" + row["perpetrators"].map((names: Perpetrator) => Object.values(names).join(", ")).join(", "), + id: "perpetrators" }, { - Header: "Department", - accessor: "department" + Header: "Agency", + accessor: "agency" }, { Header: "Use of Force", diff --git a/frontend/compositions/profile-saved-tables/saved-searches.tsx b/frontend/compositions/profile-saved-tables/saved-searches.tsx index ec0cb381..1bced6cf 100644 --- a/frontend/compositions/profile-saved-tables/saved-searches.tsx +++ b/frontend/compositions/profile-saved-tables/saved-searches.tsx @@ -1,7 +1,7 @@ import React from "react" import { Column } from "react-table" import { useSearch } from "../../helpers" -import { Officer } from "../../helpers/api" +import { Perpetrator } from "../../helpers/api" import { formatDate } from "../../helpers/syntax-helper" import { TooltipIcons, TooltipTypes } from "../../models" import { InfoTooltip } from "../../shared-components" @@ -21,14 +21,14 @@ export const searchesColumns: Column[] = [ id: "time_of_incident" }, { - Header: "Officer(s)", + Header: "Perpetrator(s)", accessor: (row: any) => - row["officers"].map((names: Officer) => Object.values(names).join(", ")).join(", "), - id: "officers" + row["perpetrators"].map((names: Perpetrator) => Object.values(names).join(", ")).join(", "), + id: "perpetrators" }, { - Header: "Department", - accessor: "department" + Header: "Agency", + accessor: "agency" }, { Header: () => ( diff --git a/frontend/compositions/search-results/search-results.tsx b/frontend/compositions/search-results/search-results.tsx index 389143a7..9e9d2f69 100644 --- a/frontend/compositions/search-results/search-results.tsx +++ b/frontend/compositions/search-results/search-results.tsx @@ -3,7 +3,7 @@ import { faSlidersH } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { useSearch } from "../../helpers" -import { Officer } from "../../helpers/api" +import { Perpetrator } from "../../helpers/api" import { formatDate } from "../../helpers/syntax-helper" import { TooltipIcons, TooltipTypes } from "../../models" import { InfoTooltip } from "../../shared-components" @@ -38,11 +38,11 @@ export const resultsColumns: Column[] = [ id: "searchDate" }, { - Header: "Officer(s) Involved", + Header: "Perpetrator(s) Involved", accessor: (row: any) => - row["officers"].map((names: Officer) => Object.values(names).join(", ")).join(", "), + row["perpetrators"].map((names: Perpetrator) => Object.values(names).join(", ")).join(", "), filter: "text", - id: "officers" + id: "perpetrators" }, { Header: () => ( diff --git a/frontend/helpers/api/api.ts b/frontend/helpers/api/api.ts index 081022c7..b32f5faa 100644 --- a/frontend/helpers/api/api.ts +++ b/frontend/helpers/api/api.ts @@ -38,6 +38,18 @@ export interface ResetPasswordResponse { message: string } +export interface Source { + name?: string + id?: number + url?: string + contact_email?: string +} + +export interface Perpetrator { + first_name?: string + last_name?: string +} + export interface Officer { first_name?: string last_name?: string @@ -48,16 +60,17 @@ export interface UseOfForce { } export interface Incident { id: number + source?: Source + source_id?: number location?: string locationLonLat?: [number, number] //TODO: Backend data does not return locationLonLat attribute. Remove this and refactor frontend latitude?: number longitude?: number time_of_incident?: string department?: string - officers: Officer[] + perpetrators: Perpetrator[] description?: string use_of_force?: UseOfForce[] - source?: string } interface AuthenticatedRequest { diff --git a/frontend/helpers/api/mocks/data.ts b/frontend/helpers/api/mocks/data.ts index 7c4fe908..f40ec38d 100644 --- a/frontend/helpers/api/mocks/data.ts +++ b/frontend/helpers/api/mocks/data.ts @@ -40,13 +40,12 @@ function createTestIncident( description: `Test description ${key}`, location: `Test location ${key}`, locationLonLat: [lonlat[0], lonlat[1]], - officers: [ + perpetrators: [ { first_name: `TestFirstName ${key}`, last_name: `TestLastName ${key}` } ], - source: "mpv", time_of_incident: new Date(date).toUTCString(), use_of_force: [ { diff --git a/frontend/helpers/mock-to-officer-type.ts b/frontend/helpers/mock-to-officer-type.ts index fcb88ed3..cd7dc0b0 100644 --- a/frontend/helpers/mock-to-officer-type.ts +++ b/frontend/helpers/mock-to-officer-type.ts @@ -1,4 +1,5 @@ import { OfficerRecordType, EmploymentType } from "../models/officer" +import { PerpetratorRecordType } from "../models/perpetrator" import { Incident, Officer, UseOfForce } from "../helpers/api" import officers from "../models/mock-data/officer.json" @@ -8,71 +9,47 @@ export function getOfficerFromMockData(officerId: number) { } } -export function mockToOfficerType(officer: typeof officers[0]): OfficerRecordType { +export function getPerpetratorFromMockData(perpetratorId: number) { + if (perpetratorId >= 0 && perpetratorId < 100) { + return mockToPerpetratorType(officers[perpetratorId]) + } +} + +export function mockToOfficerType(officer: (typeof officers)[0]): OfficerRecordType { function mockToWorkHistoryType(workHistory: typeof officer.workHistory): EmploymentType[] { const converted: EmploymentType[] = workHistory.map((item) => { return { - department: { - departmentName: item.deptName, - deptImage: item.deptImage.replace("./frontend/models/mock-data/dept-images", ""), - deptAddress: item.deptAddress, - webAddress: "https://www.google.com/search?q=police+department" + agency: { + agencyName: item.deptName, + agencyImage: item.deptImage.replace("./frontend/models/mock-data/dept-images", ""), + agencyHqAddress: item.deptAddress, + websiteUrl: "https://www.google.com/search?q=police+department" }, - status: item.status, - startDate: new Date(item.dates.split("-")[0].trim()), - endDate: new Date(item.dates.split("-")[1].trim()) + currentlyEmployed: true, + earliestEmployment: new Date(item.dates.split("-")[0].trim()), + latestEmployment: new Date(item.dates.split("-")[1].trim()) } }) return converted } - function mockToIncidentType(incidents: typeof officer.incidents): Incident[] { - const officerNames = incidents[0].officers - function mockToOfficerNameType(names: typeof officerNames): Officer[] { - const converted: Officer[] = names.map((item) => { - return { - first_name: item.split(".")[0] + ".", - last_name: item.split(".")[1] - } - }) - return converted - } - - const usesOfForce = incidents[0].useOfForce - function mockToForceType(forces: typeof usesOfForce): UseOfForce[] { - const converted: UseOfForce[] = forces.map((force) => { - return { - item: force - } - }) - return converted - } - - const converted: Incident[] = incidents.map((incident) => { - return { - ...incident, - id: incident.id, - officers: mockToOfficerNameType(incident.officers), - use_of_force: mockToForceType(incident.useOfForce) - } - }) - return converted + return { + recordId: officer.id, + firstName: officer.firstName, + lastName: officer.lastName, + dateOfBirth: new Date(officer.birthDate), + gender: officer.gender, + race: officer.race, + workHistory: mockToWorkHistoryType(officer.workHistory) } +} +export function mockToPerpetratorType(officer: (typeof officers)[0]): PerpetratorRecordType { return { recordId: officer.id, firstName: officer.firstName, lastName: officer.lastName, - badgeNo: officer.badgeNo, - status: officer.status, - department: officer.department, - birthDate: new Date(officer.birthDate), gender: officer.gender, - race: officer.race, - ethnicity: officer.ethnicity, - incomeBracket: officer.incomeBracket, - workHistory: mockToWorkHistoryType(officer.workHistory), - affiliations: officer.affiliations, - incidents: mockToIncidentType(officer.incidents) + race: officer.race } } diff --git a/frontend/models/officer.ts b/frontend/models/officer.ts index bf86c7ba..aa2bde14 100644 --- a/frontend/models/officer.ts +++ b/frontend/models/officer.ts @@ -1,51 +1,49 @@ -import { Incident } from "../helpers/api" +import { Incident, Perpetrator } from "../helpers/api" -export interface DepartmentType { - departmentName: string - deptImage: string - deptAddress: string - webAddress: string +export interface AgencyType { + agencyName: string + agencyImage: string + agencyHqAddress: string + websiteUrl: string } -export const departmentColumns = [ +export const agencyColumns = [ { - Header: "Department", - accessor: "dept" + Header: "agency", + accessor: "agency" }, { Header: "Address", - accessor: "deptAddress" + accessor: "agencyHqAddress" }, { Header: "Website", - accessor: "webAddress" + accessor: "websiteUrl" }, { - Header: "Dept Address", - accessor: "deptAddress" + Header: "Agency Address", + accessor: "agencyHqAddress" } ] export interface EmploymentType { - department: DepartmentType - status: string - startDate: Date - endDate: Date + agency: AgencyType + currentlyEmployed: boolean + earliestEmployment: Date + latestEmployment: Date + badgeNumber?: string } export interface OfficerRecordType { recordId: number firstName: string lastName: string - badgeNo: string - status: string - department: string - birthDate?: Date + dateOfBirth?: Date gender?: string race?: string ethnicity?: string - incomeBracket?: string - workHistory: EmploymentType[] - affiliations?: string[] - incidents?: Incident[] + knownEmployers?: AgencyType[] + workHistory?: EmploymentType[] + accusations?: Perpetrator[] + affiliations?: OfficerRecordType[] } diff --git a/frontend/models/perpetrator.ts b/frontend/models/perpetrator.ts new file mode 100644 index 00000000..431856b9 --- /dev/null +++ b/frontend/models/perpetrator.ts @@ -0,0 +1,39 @@ +import { Incident } from "../helpers/api" + +export interface AgencyType { + agencyName: string + agencyImage: string + agencyHqAddress: string + websiteUrl: string +} + +export const departmentColumns = [ + { + Header: "Department", + accessor: "dept" + }, + { + Header: "Address", + accessor: "deptAddress" + }, + { + Header: "Website", + accessor: "webAddress" + }, + { + Header: "Dept Address", + accessor: "deptAddress" + } +] + +export interface PerpetratorRecordType { + recordId: number + firstName?: string + lastName?: string + badge?: string + rank?: string + gender?: string + race?: string + ethnicity?: string + incident?: Incident +} diff --git a/frontend/tests/snapshots/__snapshots__/incident.test.tsx.snap b/frontend/tests/snapshots/__snapshots__/incident.test.tsx.snap index df5b5cbb..0878f619 100644 --- a/frontend/tests/snapshots/__snapshots__/incident.test.tsx.snap +++ b/frontend/tests/snapshots/__snapshots__/incident.test.tsx.snap @@ -68,7 +68,7 @@ Object {

- Departments Involved: + Agencies Involved:

- Departments Involved: + Agencies Involved:

- Badge Number -

-

- 30420452 -

-
-
-

- Officer Status -

-

- Chief -

-
-
-

- Department -

-

- Houston Police Department + Known Employers

@@ -102,30 +75,6 @@ Object { Black or African American

-
-

- Ethnicity -

-

- Hispanic or Latino -

-
-
-

- Income Bracket -

-

- Over $523,600 -

-

@@ -150,7 +99,7 @@ Object { />

- Captain + @@ -180,7 +129,7 @@ Object { />

- Sergeant + @@ -208,9 +157,6 @@ Object {

Professional Affiliations:

-

- Fraternal Order of Police, International Union of Police Associations, National Association of Police Organizations -

, @@ -239,34 +185,7 @@ Object {

- Badge Number -

-

- 30420452 -

- -
-

- Officer Status -

-

- Chief -

-
-
-

- Department -

-

- Houston Police Department + Known Employers

@@ -312,30 +231,6 @@ Object { Black or African American

-
-

- Ethnicity -

-

- Hispanic or Latino -

-
-
-

- Income Bracket -

-

- Over $523,600 -

-

@@ -360,7 +255,7 @@ Object { />

- Captain + @@ -390,7 +285,7 @@ Object { />

- Sergeant + @@ -418,9 +313,6 @@ Object {

Professional Affiliations:

-

- Fraternal Order of Police, International Union of Police Associations, National Association of Police Organizations -

, "debug": [Function], diff --git a/frontend/tests/snapshots/__snapshots__/visualizations.test.tsx.snap b/frontend/tests/snapshots/__snapshots__/visualizations.test.tsx.snap index 6538699f..ee55ebd6 100644 --- a/frontend/tests/snapshots/__snapshots__/visualizations.test.tsx.snap +++ b/frontend/tests/snapshots/__snapshots__/visualizations.test.tsx.snap @@ -92,7 +92,7 @@ exports[`the map renders Map correctly 1`] = ` { latitude: 42.3601, description: "Officer issued motorist ticket, use of excessive force resulted in civilian injury.", - officers: [ + perpetrators: [ { first_name: "George", last_name: "Lopez" }, { first_name: "Hannah", last_name: "Montana" } ]