diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 5e39272c..ef9d24e1 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:12-alpine + image: postgres:16-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -23,10 +23,10 @@ jobs: - 5432:5432 steps: - uses: actions/checkout@v4 - - name: Python 3.8 Setup + - name: Python 3.12 Setup uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.12 - name: Install dependencies run: | sudo apt-get update diff --git a/README.md b/README.md index 3840feba..1a5ada9f 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ MIXPANEL_TOKEN=your_mixpanel_token > **Note** > When running locally, you may need to update one of the ports in the `.env` file if it conflicts with another application on your machine. -3. Build and run the project with `docker-compose build; docker-compose up -d; docker-compose logs -f app` +3. Build and run the project with `docker-compose build && docker-compose up -d && docker-compose logs -f` ## Installation (Frontend Only) @@ -67,7 +67,7 @@ You'll need to replace `police-data-trust-api-1` with the name of the container docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES c0cf******** police-data-trust-api "/bin/sh -c '/wait &…" About a minute ago Up About a minute 0.0.0.0:5001->5001/tcp police-data-trust-api-1 -5e6f******** postgres:13.2 "docker-entrypoint.s…" 3 days ago Up About a minute 0.0.0.0:5432->5432/tcp police-data-trust-db-1 +5e6f******** postgres:16.1 "docker-entrypoint.s…" 3 days ago Up About a minute 0.0.0.0:5432->5432/tcp police-data-trust-db-1 dacd******** police-data-trust-web "docker-entrypoint.s…" 3 days ago Up About a minute 0.0.0.0:3000->3000/tcp police-data-trust-web-1 ``` diff --git a/backend/Dockerfile b/backend/Dockerfile index 3a7ef7ef..c75499f5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,8 +1,8 @@ -FROM python:3.9.5-slim-buster AS base +FROM python:3-slim-buster AS base RUN apt-get update && apt-get install curl -y && apt-get install g++ libpq-dev gcc -y -ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.7.3/wait /wait +ADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.12.1/wait /wait RUN chmod +x /wait diff --git a/backend/Dockerfile.cloud b/backend/Dockerfile.cloud index 0ae601a9..19a1b763 100644 --- a/backend/Dockerfile.cloud +++ b/backend/Dockerfile.cloud @@ -1,6 +1,6 @@ # docker build command: # docker build -t police-data-trust-backend-dev -f backend/Dockerfile.cloud . -FROM python:3.8-slim-buster +FROM python:3-slim-buster WORKDIR /app/ diff --git a/backend/database/__init__.py b/backend/database/__init__.py index 77bc0202..bacb988a 100644 --- a/backend/database/__init__.py +++ b/backend/database/__init__.py @@ -19,6 +19,8 @@ from .models.attachment import * from .models.perpetrator import * from .models.officer import * +from .models.employment import * +from .models.accusation import * from .models.participant import * from .models.tag import * from .models.result_of_stop import * diff --git a/backend/database/core.py b/backend/database/core.py index 7a5cb899..315d31ec 100644 --- a/backend/database/core.py +++ b/backend/database/core.py @@ -9,6 +9,7 @@ import click import pandas as pd +import psycopg import psycopg2.errors from flask import abort, current_app from flask.cli import AppGroup, with_appcontext @@ -122,7 +123,7 @@ def create_database( try: cursor.execute(f"CREATE DATABASE {database};") - except psycopg2.errors.lookup("42P04"): + except (psycopg2.errors.lookup("42P04"), psycopg.errors.DuplicateDatabase): click.echo(f"Database {database!r} already exists.") else: click.echo(f"Created database {database!r}.") diff --git a/backend/database/models/_assoc_tables.py b/backend/database/models/_assoc_tables.py index ccaa670e..cb158bd5 100644 --- a/backend/database/models/_assoc_tables.py +++ b/backend/database/models/_assoc_tables.py @@ -1,5 +1,4 @@ from .. import db -from backend.database.models.officer import Rank incident_agency = db.Table( @@ -17,25 +16,3 @@ 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 new file mode 100644 index 00000000..606d1ef3 --- /dev/null +++ b/backend/database/models/accusation.py @@ -0,0 +1,17 @@ +from .. import db + + +class Accusation(db.Model): + id = db.Column(db.Integer, primary_key=True) + perpetrator_id = db.Column(db.Integer, db.ForeignKey("perpetrator.id")) + officer_id = db.Column(db.Integer, db.ForeignKey("officer.id")) + user_id = db.Column(db.Integer, db.ForeignKey("user.id")) + date_created = db.Column(db.Text) + basis = db.Column(db.Text) + + attachments = db.relationship("Attachment", backref="accusation") + perpetrator = db.relationship("Perpetrator", back_populates="suspects") + officer = db.relationship("Officer", back_populates="accusations") + + def __repr__(self): + return f"" diff --git a/backend/database/models/agency.py b/backend/database/models/agency.py index 9f3744aa..4adb3710 100644 --- a/backend/database/models/agency.py +++ b/backend/database/models/agency.py @@ -1,5 +1,4 @@ import enum -from backend.database.models._assoc_tables import agency_officer from .. import db @@ -20,8 +19,8 @@ class Agency(db.Model): 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") + + known_officers = db.relationship("Employment", back_populates="agency") def __repr__(self): return f"" diff --git a/backend/database/models/attachment.py b/backend/database/models/attachment.py index 9d0259e0..e9334cc5 100644 --- a/backend/database/models/attachment.py +++ b/backend/database/models/attachment.py @@ -4,7 +4,8 @@ class Attachment(db.Model): id = db.Column(db.Integer, primary_key=True) incident_id = db.Column(db.Integer, db.ForeignKey("incident.id")) + accusation_id = db.Column(db.Integer, db.ForeignKey("accusation.id")) title = db.Column(db.Text) hash = db.Column(db.Text) - location = db.Column(db.Text) + url = db.Column(db.Text) filetype = db.Column(db.Text) diff --git a/backend/database/models/attorney.py b/backend/database/models/attorney.py index 38dbb73c..5fb96bfa 100644 --- a/backend/database/models/attorney.py +++ b/backend/database/models/attorney.py @@ -3,5 +3,4 @@ class Attorney(db.Model): id = db.Column(db.Integer, primary_key=True) - legal_case_id = db.Column(db.Integer, db.ForeignKey("legal_case.id")) text_contents = db.Column(db.String) diff --git a/backend/database/models/employment.py b/backend/database/models/employment.py new file mode 100644 index 00000000..e7117813 --- /dev/null +++ b/backend/database/models/employment.py @@ -0,0 +1,34 @@ +import enum +from .. import db + + +class Rank(str, enum.Enum): + # TODO: Is this comprehensive? + TECHNICIAN = "TECHNICIAN" + OFFICER = "OFFICER" + DETECTIVE = "DETECTIVE" + CORPORAL = "CORPORAL" + SERGEANT = "SERGEANT" + LIEUTENANT = "LIEUTENANT" + CAPTAIN = "CAPTAIN" + DEPUTY = "DEPUTY" + CHIEF = "CHIEF" + COMMISSIONER = "COMMISSIONER" + + +class Employment(db.Model): + id = db.Column(db.Integer, primary_key=True) + officer_id = db.Column(db.Integer, db.ForeignKey("officer.id")) + agency_id = db.Column(db.Integer, db.ForeignKey("agency.id")) + earliest_employment = db.Column(db.Text) + latest_employment = db.Column(db.Text) + badge_number = db.Column(db.Text) + unit = db.Column(db.Text) + highest_rank = db.Column(db.Enum(Rank)) + currently_employed = db.Column(db.Boolean) + + officer = db.relationship("Officer", back_populates="known_employers") + agency = db.relationship("Agency", back_populates="known_officers") + + def __repr__(self): + return f"" diff --git a/backend/database/models/incident.py b/backend/database/models/incident.py index 31dc78ad..d3265532 100644 --- a/backend/database/models/incident.py +++ b/backend/database/models/incident.py @@ -99,7 +99,9 @@ def __init__(self, **kwargs): 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") - perpetrators = db.relationship("Perpetrator", backref="incident") + perpetrators = db.relationship( + "Perpetrator", + backref="incident") # descriptions = db.relationship("Description", backref="incident") tags = db.relationship("Tag", secondary=incident_tag, backref="incidents") agencies_present = db.relationship( diff --git a/backend/database/models/legal_case.py b/backend/database/models/legal_case.py index e8077f6a..3a4fed63 100644 --- a/backend/database/models/legal_case.py +++ b/backend/database/models/legal_case.py @@ -3,7 +3,6 @@ class LegalCaseType(str, enum.Enum): - # TODO: Do we want string enumerations to be all caps? i.e. CIVIL = "CIVIL" CIVIL = "CIVIL" CRIMINAL = "CRIMINAL" @@ -15,15 +14,10 @@ class LegalCase(db.Model): jurisdiction = db.Column(db.String) judge = db.Column(db.String) docket_number = db.Column(db.String) - # TODO: Foreign key to officer/victim? defendant = db.Column(db.String) - defendant_council = db.relationship( - "Attorney", backref="legal_case_defendant", uselist=False - ) + defendant_council = db.Column(db.String) plaintiff = db.Column(db.String) - plaintiff_council = db.relationship( - "Attorney", backref="legal_case_plaintiff", uselist=False - ) + plaintiff_council = db.Column(db.String) start_date = db.Column(db.DateTime) end_date = db.Column(db.DateTime) outcome = db.Column(db.String) diff --git a/backend/database/models/officer.py b/backend/database/models/officer.py index e6d10c10..8e6213cf 100644 --- a/backend/database/models/officer.py +++ b/backend/database/models/officer.py @@ -1,19 +1,6 @@ import enum -from .. import db - - -class Rank(str, enum.Enum): - # TODO: Is this comprehensive? - TECHNICIAN = "TECHNICIAN" - OFFICER = "OFFICER" - DETECTIVE = "DETECTIVE" - CORPORAL = "CORPORAL" - SERGEANT = "SERGEANT" - LIEUTENANT = "LIEUTENANT" - CAPTAIN = "CAPTAIN" - DEPUTY = "DEPUTY" - CHIEF = "CHIEF" +from ..core import db, CrudMixin class State(str, enum.Enum): @@ -72,8 +59,8 @@ class State(str, enum.Enum): class StateID(db.Model): """ Represents a Statewide ID that follows an offcier even as they move between - law enforcement agencies. for an officer. For example, in New York, this - would be the Tax ID Number. + law enforcement agencies. For example, in New York, this would be + the Tax ID Number. """ id = db.Column(db.Integer, primary_key=True) officer_id = db.Column( @@ -83,17 +70,21 @@ class StateID(db.Model): value = db.Column(db.Text) # e.g. "958938" def __repr__(self): - return f"" + return f"" -class Officer(db.Model): +class Officer(db.Model, CrudMixin): id = db.Column(db.Integer, primary_key=True) # officer id first_name = db.Column(db.Text) + middle_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) date_of_birth = db.Column(db.Date) + known_employers = db.relationship("Employment", back_populates="officer") + accusations = db.relationship("Accusation", back_populates="officer") + state_ids = db.relationship("StateID", backref="officer") def __repr__(self): return f"" diff --git a/backend/database/models/perpetrator.py b/backend/database/models/perpetrator.py index 532e684e..241baf41 100644 --- a/backend/database/models/perpetrator.py +++ b/backend/database/models/perpetrator.py @@ -1,5 +1,5 @@ -from backend.database.models._assoc_tables import perpetrator_officer -from backend.database.models.officer import Rank, State +from backend.database.models.officer import State +from backend.database.models.employment import Rank from .. import db @@ -20,7 +20,7 @@ class Perpetrator(db.Model): state_id_name = db.Column(db.Text) role = db.Column(db.Text) suspects = db.relationship( - "Officer", secondary=perpetrator_officer, backref="accusations") + "Accusation", back_populates="perpetrator") def __repr__(self): return f"" diff --git a/backend/database/models/user.py b/backend/database/models/user.py index ab72c569..bf53c6a0 100644 --- a/backend/database/models/user.py +++ b/backend/database/models/user.py @@ -20,6 +20,7 @@ class CI_String(TypeDecorator): """Case-insensitive String subclass definition""" impl = String + cache_ok = True def __init__(self, length, **kwargs): if kwargs.get("collate"): @@ -69,7 +70,8 @@ class User(db.Model, UserMixin, CrudMixin): # User authentication information. The collation="NOCASE" is required # to search case insensitively when USER_IFIND_MODE is "nocase_collation". email = db.Column( - CI_String(255, collate="NOCASE"), nullable=False, unique=True + CI_String(255, collate="NOCASE"), + nullable=False, unique=True ) email_confirmed_at = db.Column(db.DateTime()) password = db.Column(db.String(255), nullable=False, server_default="") @@ -91,6 +93,9 @@ class User(db.Model, UserMixin, CrudMixin): "PartnerMember", back_populates="user", lazy="select") member_of = association_proxy("partner_association", "partner") + # Officer Accusations + accusations = db.relationship("Accusation", backref="user") + def verify_password(self, pw): return bcrypt.checkpw(pw.encode("utf8"), self.password.encode("utf8")) diff --git a/backend/routes/healthcheck.py b/backend/routes/healthcheck.py index c34c645e..f5b536fb 100644 --- a/backend/routes/healthcheck.py +++ b/backend/routes/healthcheck.py @@ -31,4 +31,4 @@ class Resp(BaseModel): def healthcheck(): """Verifies service health and returns the api version""" check_db() - return ({"apiVersion": spec.config.VERSION}, 200) + return {"apiVersion": spec.config.version}, 200 diff --git a/backend/routes/incidents.py b/backend/routes/incidents.py index 168a8f5c..6743a7f7 100644 --- a/backend/routes/incidents.py +++ b/backend/routes/incidents.py @@ -5,12 +5,13 @@ from backend.auth.jwt import min_role_required, contributor_has_partner from backend.mixpanel.mix import track_to_mp from mixpanel import MixpanelException -from flask import Blueprint, abort, current_app, request +from flask import Blueprint, abort, request from flask_jwt_extended.view_decorators import jwt_required from flask_jwt_extended import get_jwt from pydantic import BaseModel from typing import Any + from ..database import ( Incident, db, @@ -40,29 +41,6 @@ def get_incident(incident_id: int): return incident_orm_to_json(Incident.get(incident_id)) -@bp.route("/create", methods=["POST"]) -@jwt_required() -@min_role_required(UserRole.CONTRIBUTOR) -@contributor_has_partner() -@validate(json=CreateIncidentSchema) -def create_incident(): - """Create a single incident. - - Cannot be called in production environments - """ - if current_app.env == "production": - abort(418) - - try: - incident = incident_to_orm(request.context.json) - except Exception: - abort(400) - - created = incident.create() - track_to_mp(request, "create_incident", {"source_id": incident.source_id}) - return incident_orm_to_json(created) - - class SearchIncidentsSchema(BaseModel): location: Optional[str] = None dateStart: Optional[str] = None @@ -241,3 +219,74 @@ def delete_incident(incident_id: int): incident.delete() return {"message": "Incident deleted successfully"}, 204 + + +@bp.route("/create", methods=["POST"]) +@jwt_required() +@min_role_required(UserRole.CONTRIBUTOR) +@contributor_has_partner() +@validate(json=CreateIncidentSchema) +def create_incident(): + """Create a single incident. + """ + body = request.context.json + jwt_decoded: dict[str, str] = get_jwt() + user_id = jwt_decoded["sub"] + permission = PartnerMember.query.filter( + PartnerMember.user_id == user_id, + PartnerMember.role.in_((MemberRole.PUBLISHER, MemberRole.ADMIN)), + ).first() + if permission is None: + abort(403) + + existing_incident = Incident.query.filter_by( + source_id=body.source_id, + time_of_incident=body.time_of_incident, + longitude=body.longitude, + latitude=body.latitude, + location=body.location, + ).first() + if existing_incident: + abort(409, "Incident already exists") + try: + incident = incident_to_orm(body) + except Exception: + abort(400) + + created = incident.create() + track_to_mp(request, "create_incident", {"source_id": incident.source_id}) + return incident_orm_to_json(created) + + +@bp.route("update-incident/", methods=["PATCH"]) +@jwt_required() +@min_role_required(UserRole.CONTRIBUTOR) +@validate(json=CreateIncidentSchema) +def update_incident(incident_id): + """ + updating a single incident + """ + body = request.context.json + jwt_decoded: dict[str, str] = get_jwt() + user_id = jwt_decoded["sub"] + permission = PartnerMember.query.filter( + PartnerMember.user_id == user_id, + PartnerMember.role.in_((MemberRole.PUBLISHER, MemberRole.ADMIN)), + ).first() + if permission is None: + abort(403) + incident_obj = Incident.query.get( + incident_id + ) + if incident_obj: + db.session.delete(incident_obj) + db.session.commit() + incident = incident_to_orm(body) + created = incident.create() + return incident_orm_to_json(created) + else: + res = { + "status": "Error", + "message": "Incident to update not found" + }, 400 + return res diff --git a/backend/routes/officers.py b/backend/routes/officers.py index 65dd9256..bba4ebf7 100644 --- a/backend/routes/officers.py +++ b/backend/routes/officers.py @@ -6,13 +6,16 @@ from backend.mixpanel.mix import track_to_mp from mixpanel import MixpanelException from backend.database.models.user import UserRole +from backend.database.models.employment import Employment from flask import Blueprint, abort, request from flask_jwt_extended.view_decorators import jwt_required from pydantic import BaseModel -from ..database import Officer, db, agency_officer +from ..database import Officer, db from ..schemas import ( + CreateOfficerSchema, officer_orm_to_json, + officer_to_orm, validate, ) @@ -21,9 +24,10 @@ class SearchOfficerSchema(BaseModel): - officerName: Optional[str] = None - location: Optional[str] = None + name: Optional[str] = None + agency: Optional[str] = None badgeNumber: Optional[str] = None + location: Optional[str] = None page: Optional[int] = 1 perPage: Optional[int] = 20 @@ -51,19 +55,35 @@ def search_officer(): logger = logging.getLogger("officers") try: - if body.officerName: + if body.name: names = body.officerName.split() - first_name = names[0] if len(names) > 0 else '' - last_name = names[1] if len(names) > 1 else '' - query = Officer.query.filter(or_( - Officer.first_name.ilike(f"%{first_name}%"), - Officer.last_name.ilike(f"%{last_name}%") - )) + if len(names) == 1: + query = Officer.query.filter( + or_( + Officer.first_name.ilike(f"%{body.officerName}%"), + Officer.last_name.ilike(f"%{body.officerName}%") + ) + ) + elif len(names) == 2: + query = Officer.query.filter( + or_( + Officer.first_name.ilike(f"%{names[0]}%"), + Officer.last_name.ilike(f"%{names[1]}%") + ) + ) + else: + query = Officer.query.filter( + or_( + Officer.first_name.ilike(f"%{names[0]}%"), + Officer.middle_name.ilike(f"%{names[1]}%"), + Officer.last_name.ilike(f"%{names[2]}%") + ) + ) if body.badgeNumber: officer_ids = [ result.officer_id for result in db.session.query( - agency_officer + Employment ).filter_by(badge_number=body.badgeNumber).all() ] query = Officer.query.filter(Officer.id.in_(officer_ids)).all() @@ -93,3 +113,71 @@ def search_officer(): } except Exception as e: abort(500, description=str(e)) + + +@bp.route("/create", methods=["POST"]) +@jwt_required() +@min_role_required(UserRole.CONTRIBUTOR) +@validate(json=CreateOfficerSchema) +def create_officer(): + """Create an officer profile. + """ + + try: + officer = officer_to_orm(request.context.json) + except Exception: + abort(400) + + created = officer.create() + + track_to_mp( + request, + "create_officer", + { + "first_name": officer.first_name, + "middle_name": officer.middle_name, + "last_name": officer.last_name + }, + ) + return officer_orm_to_json(created) + + +@bp.route("/", methods=["GET"]) +@jwt_required() +@min_role_required(UserRole.PUBLIC) +@validate() +def get_officer(officer_id: int): + """Get an officer profile. + """ + officer = db.session.query(Officer).get(officer_id) + if officer is None: + abort(404, description="Officer not found") + return officer_orm_to_json(officer) + + +@bp.route("/", methods=["GET"]) +@jwt_required() +@min_role_required(UserRole.PUBLIC) +@validate() +def get_all_officers(): + """Get all officers. + Accepts Query Parameters for pagination: + per_page: number of results per page + page: page number + """ + args = request.args + q_page = args.get("page", 1, type=int) + q_per_page = args.get("per_page", 20, type=int) + + all_officers = db.session.query(Officer) + pagination = all_officers.paginate( + page=q_page, per_page=q_per_page, max_per_page=100 + ) + + return { + "results": [ + officer_orm_to_json(officer) for officer in pagination.items], + "page": pagination.page, + "totalPages": pagination.pages, + "totalResults": pagination.total, + } diff --git a/backend/routes/partners.py b/backend/routes/partners.py index 78745437..f8205782 100644 --- a/backend/routes/partners.py +++ b/backend/routes/partners.py @@ -27,7 +27,7 @@ partner_to_orm, validate, AddMemberSchema, - partner_member_to_orm + partner_member_to_orm, ) @@ -69,6 +69,13 @@ def create_partner(): ) make_admin.create() + # update to UserRole contributor status + user_id = get_jwt()["sub"] + user = User.query.filter_by( + id=user_id + ).first() + user.role = UserRole.CONTRIBUTOR + track_to_mp( request, "create_partner", @@ -399,6 +406,11 @@ def role_change(): if user_found and user_found.role != "Administrator": user_found.role = body["role"] db.session.commit() + if body["role"] == "Adminstrator" or body["role"] == "Publisher": + user_instance = User.query.filter_by( + id=body["user_id"] + ).first() + user_instance.role = UserRole.CONTRIBUTOR return { "status" : "ok", "message" : "Role has been updated!" @@ -406,7 +418,8 @@ def role_change(): else: return { "status" : "Error", - "message" : "User not found in this organization" + "message" : ("User not found in this organization or can't\ + change the role of Admin") }, 400 except Exception as e: db.session.rollback diff --git a/backend/schemas.py b/backend/schemas.py index 540936e8..7a8b1972 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -7,14 +7,16 @@ from pydantic_sqlalchemy import sqlalchemy_to_pydantic from spectree import SecurityScheme, SpecTree from spectree.models import Server -from sqlalchemy.ext.declarative.api import DeclarativeMeta +from sqlalchemy.ext.declarative import DeclarativeMeta from .database import User from .database.models.action import Action from .database.models.partner import Partner, PartnerMember, MemberRole from .database.models.incident import Incident, SourceDetails from .database.models.agency import Agency -from .database.models.officer import Officer +from .database.models.officer import Officer, StateID +from .database.models.employment import Employment +from .database.models.accusation import Accusation from .database.models.investigation import Investigation from .database.models.legal_case import LegalCase from .database.models.attachment import Attachment @@ -82,7 +84,7 @@ data={ "type": "http", "scheme": "bearer", - "bearerFormat": {"JWT": []}, + "bearerFormat": "JWT", }, ), ], @@ -111,8 +113,9 @@ def validate(auth=True, **kwargs): ] _officer_list_attributes = [ - 'first_name', - 'last_name' + 'known_employers', + 'accusations', + 'state_ids', ] _partner_list_attrs = ["reported_incidents"] @@ -164,7 +167,10 @@ def schema_create(model_type: DeclarativeMeta, **kwargs) -> ModelMetaclass: _BaseCreatePartnerSchema = schema_create(Partner) _BaseCreateIncidentSchema = schema_create(Incident) -CreateOfficerSchema = schema_create(Officer) +_BaseCreateOfficerSchema = schema_create(Officer) +CreateStateIDSchema = schema_create(StateID) +CreateEmploymentSchema = schema_create(Employment) +CreateAccusationSchema = schema_create(Accusation) CreateAgencySchema = schema_create(Agency) CreateVictimSchema = schema_create(Victim) CreatePerpetratorSchema = schema_create(Perpetrator) @@ -202,6 +208,12 @@ class CreatePartnerMemberSchema(BaseModel): is_active: Optional[bool] = True +class CreateOfficerSchema(_BaseCreateOfficerSchema, _OfficerMixin): + known_employers: Optional[List[CreateEmploymentSchema]] + accusations: Optional[List[CreateAccusationSchema]] + state_ids: Optional[List[CreateStateIDSchema]] + + AddMemberSchema = sqlalchemy_to_pydantic( PartnerMember, exclude=["id", "date_joined", "partner", "user"] ) @@ -246,7 +258,9 @@ class IncidentSchema(_BaseIncidentSchema, _IncidentMixin): class OfficerSchema(_BaseOfficerSchema, _OfficerMixin): - reported_Officer: Optional[List[_BaseOfficerSchema]] + known_employers: List[CreateEmploymentSchema] + accusations: List[CreateAccusationSchema] + state_ids: List[CreateStateIDSchema] class PartnerSchema(_BasePartnerSchema, _PartnerMixin): @@ -291,8 +305,33 @@ def incident_orm_to_json(incident: Incident) -> dict[str, Any]: ) +def officer_to_orm(officer: CreateOfficerSchema) -> Officer: + """Convert the JSON officer into an ORM instance + + pydantic-sqlalchemy only handles ORM -> JSON conversion, not the other way + around. sqlalchemy won't convert nested dictionaries into the corresponding + ORM types, so we need to manually perform the JSON -> ORM conversion. We can + roll our own recursive conversion if we can get the ORM model class + associated with a schema instance. + """ + + converters = { + "state_ids": StateID, + "known_employers": Employment, + } + orm_attrs = officer.dict() + for k, v in orm_attrs.items(): + is_dict = isinstance(v, dict) + is_list = isinstance(v, list) + if is_dict: + orm_attrs[k] = converters[k](**v) + elif is_list and len(v) > 0: + orm_attrs[k] = [converters[k](**d) for d in v] + return Officer(**orm_attrs) + + def officer_orm_to_json(officer: Officer) -> dict: - return IncidentSchema.from_orm(officer).dict( + return OfficerSchema.from_orm(officer).dict( exclude_none=True, # Exclude a bunch of currently-unused empty lists ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a3e50603..2ac7688c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,3 +1,4 @@ +import psycopg.errors import psycopg2.errors import pytest from backend.api import create_app @@ -37,7 +38,7 @@ def database(): try: janitor.init() - except psycopg2.errors.lookup("42P04"): + except (psycopg2.errors.lookup("42P04"), psycopg.errors.DuplicateDatabase): pass yield diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index cb0686b0..55e3f0b5 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -71,7 +71,7 @@ def test_auth_test_header(client, example_user): json={"email": example_user.email, "password": "my_password"}, ) - client.set_cookie("localhost", "access_token_cookie", value="") + client.set_cookie(domain="localhost", key="access_token_cookie", value="") test_res = client.get( "api/v1/auth/whoami", diff --git a/backend/tests/test_incidents.py b/backend/tests/test_incidents.py index acd61011..0179169b 100644 --- a/backend/tests/test_incidents.py +++ b/backend/tests/test_incidents.py @@ -5,6 +5,8 @@ from backend.database import Incident, Partner, PrivacyStatus, User from typing import Any +member_email = "joe@partner.com" +example_password = "my_password" mock_partners = { "cpdp": {"name": "Citizens Police Data Project"}, "mpv": {"name": "Mapping Police Violence"}, @@ -50,10 +52,22 @@ "source": "Citizens Police Data Project", }, } +updated_incident = { + "domestic": { + "time_of_incident": "2021-03-14 01:05:09", + "description": "Robbery", + "perpetrators": [ + {"first_name": "Susie", "last_name": "Suserson"}, + {"first_name": "Lisa", "last_name": "Wong"}, + ], + "use_of_force": [{"item": "Injurious restraint"}], + "location": "123 Brattle St, City, State", + } + } @pytest.fixture -def example_incidents(db_session, client, contributor_access_token): +def example_incidents(db_session, client , contributor_access_token): for id, mock in mock_partners.items(): db_session.add(Partner(**mock)) db_session.commit() @@ -73,9 +87,6 @@ def example_incidents(db_session, client, contributor_access_token): def test_create_incident(db_session, example_incidents): - # TODO: test that the User actually has permission to create an - # incident for the partner - # expected = mock_incidents["domestic"] created = example_incidents["domestic"] incident_obj = ( @@ -88,7 +99,93 @@ def test_create_incident(db_session, example_incidents): 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.location == created["location"] + assert incident_obj.description == created["description"] + + +""" +test for creating a new incident and +creating same incident +""" + + +def test_create_incident_exists( + client, + partner_admin, + +): + created = {} + access_token = client.post( + "api/v1/auth/login", + json={ + "email": partner_admin.email , + "password": "my_password" + }, + ).json["access_token"] + # creating new incident + res = client.post( + "/api/v1/incidents/create", + headers={"Authorization": f"Bearer {access_token}"}, + json=mock_incidents["domestic"] + ) + created["domestic"] = res.json + domestic_instance = created["domestic"] + assert res.status_code == 200 + expected = mock_incidents["domestic"] + incident_obj = Incident.query.filter_by( + time_of_incident=expected["time_of_incident"] + ).first() + date_format = '%Y-%m-%d %H:%M:%S' + date_obj = datetime.strptime( + expected["time_of_incident"], + date_format) + assert incident_obj.time_of_incident == date_obj + for i in [0, 1]: + assert ( + incident_obj.perpetrators[i].id == + domestic_instance["perpetrators"][i]["id"] + ) + assert (incident_obj.use_of_force[0].id == + domestic_instance["use_of_force"][0]["id"]) + assert incident_obj.location == domestic_instance["location"] + assert incident_obj.description == domestic_instance["description"] + + # creating the same incident + # should not be able to create incident + res = client.post( + "/api/v1/incidents/create", + headers={"Authorization": f"Bearer {access_token}"}, + json=mock_incidents["domestic"] + ) + + assert res.status_code == 409 + + +""" +creating incident when user +does not have permission +""" + + +def test_create_incident_no_permission( + client, + example_user + +): + access_token = client.post( + "api/v1/auth/login", + json={ + "email": example_user.email, + "password": "my_password" + }, + ).json["access_token"] + # creating new incident + res = client.post( + "/api/v1/incidents/create", + headers={"Authorization": f"Bearer {access_token}"}, + json=mock_incidents["domestic"] + ) + assert res.status_code == 403 def test_get_incident(app, client, db_session, access_token): @@ -381,3 +478,115 @@ def test_delete_incident_nonexsitent_incident( headers={"Authorization": f"Bearer {access_token}"}, ) assert res.status_code == 404 + + +""" +test cases for updating incidents +""" + + +def test_update_incident( + client, + partner_publisher, +): + """ + Test to ensure existing incidents are updated + correctly after + """ + created = {} + access_token = client.post( + "api/v1/auth/login", + json={ + "email": partner_publisher.email, + "password": "my_password" + }, + ).json["access_token"] + res = client.post( + "/api/v1/incidents/create", + headers={"Authorization": f"Bearer {access_token}"}, + json=mock_incidents["domestic"] + ) + incident_obj = Incident.query.filter_by( + description="Domestic disturbance", + time_of_incident=datetime(2021, 3, 14, 1, 5, 9) + ).first() + incident_id = incident_obj.id + + res = client.patch( + f"/api/v1/incidents/update-incident/{incident_id}", + headers={"Authorization": f"Bearer {access_token}"}, + json=updated_incident["domestic"] + ) + assert res.status_code == 200 + + # make sure previous incident is deleted + deleted_incident = Incident.query.filter_by( + description="Domestic disturbance", + time_of_incident=datetime(2021, 3, 14, 1, 5, 9) + ).first() + assert deleted_incident is None + created["domestic"] = res.json + # query updated incident object from DB + incident_obj = Incident.query.filter_by( + description="Robbery", + time_of_incident=datetime(2021, 3, 14, 1, 5, 9) + ).first() + + # assertions to make sure the update has taken place + + assert incident_obj.time_of_incident == datetime(2021, 3, 14, 1, 5, 9) + for i in [0, 1]: + assert ( + incident_obj.perpetrators[i].id + == created["domestic"]["perpetrators"][i]["id"]) + assert (incident_obj.use_of_force[0].id == + created["domestic"]["use_of_force"][0]["id"]) + assert incident_obj.location == created["domestic"]["location"] + assert incident_obj.description == ( + created["domestic"]["description"]) + + +def test_update_incident_nonexistent_incident( + client, + partner_publisher +): + """ + Test to ensure error is thrown when non-existent + incident asked for update + """ + access_token = client.post( + "api/v1/auth/login", + json={ + "email": partner_publisher.email, + "password": "my_password" + }, + ).json["access_token"] + + res = client.patch( + f"/api/v1/incidents/update-incident/{999}", + headers={"Authorization": f"Bearer {access_token}"}, + json=updated_incident["domestic"] + ) + assert res.status_code == 400 + + +def test_update_incident_no_permission( + client, + example_user +): + """ + Permission is required to update + """ + access_token = client.post( + "api/v1/auth/login", + json={ + "email": example_user.email, + "password": "my_password" + }, + ).json["access_token"] + res = client.patch( + f"/api/v1/incidents/update-incident/{999}", + headers={"Authorization": f"Bearer {access_token}"}, + json=updated_incident["domestic"] + ) + assert res.status_code == 403 diff --git a/backend/tests/test_officers.py b/backend/tests/test_officers.py new file mode 100644 index 00000000..669d11df --- /dev/null +++ b/backend/tests/test_officers.py @@ -0,0 +1,531 @@ +from __future__ import annotations + +import pytest +from backend.database import ( + Officer, + Agency, + Accusation, + Incident, + Partner +) +from typing import Any + +mock_officers = { + "severe": { + "first_name": "Bad", + "last_name": "Cop", + "race": "White", + "ethnicity": "Non-Hispanic", + "gender": "M", + "known_employers": [] + }, + "light": { + "first_name": "Decent", + "last_name": "Cop", + "race": "White", + "ethnicity": "Non-Hispanic", + "gender": "M", + "known_employers": [] + }, + "none": { + "first_name": "Good", + "last_name": "Cop", + "race": "White", + "ethnicity": "Non-Hispanic", + "gender": "M", + "known_employers": [] + }, +} + +mock_agencies = { + "cpd": { + "name": "Chicago Police Department", + "website_url": "https://www.chicagopolice.org/", + "hq_address": "3510 S Michigan Ave", + "hq_city": "Chicago", + "hq_zip": "60653", + "jurisdiction": "MUNICIPAL" + }, + "nypd": { + "name": "New York Police Department", + "website_url": "https://www1.nyc.gov/site/nypd/index.page", + "hq_address": "1 Police Plaza", + "hq_city": "New York", + "hq_zip": "10038", + "jurisdiction": "MUNICIPAL" + } +} + +mock_employment = { + "severe": { + "agency": "Chicago Police Department", + "earliest_employment": "2015-03-14 00:00:00", + "badge_number": "1234", + "currently_employed": True + }, + "light": { + "agency": "Chicago Police Department", + "earliest_employment": "2018-08-12 00:00:00", + "badge_number": "5678", + "currently_employed": True + }, + "none": { + "agency": "New York Police Department", + "earliest_employment": "2019-05-03 00:00:00", + "badge_number": "1234", + "currently_employed": True + } + +} + +mock_incidents = { + "domestic": { + "time_of_incident": "2021-03-14 01:05:09", + "description": "Domestic disturbance", + "perpetrators": [ + {"first_name": "Decent", "last_name": "Cop"}, + ], + "use_of_force": [{"item": "Injurious restraint"}], + "source": "Citizens Police Data Project", + "location": "123 Right St Chicago, IL", + }, + "traffic": { + "time_of_incident": "2021-10-01 00:00:00", + "description": "Traffic stop", + "perpetrators": [ + {"first_name": "Bad", "last_name": "Cop"}, + ], + "use_of_force": [{"item": "verbalization"}], + "source": "Mapping Police Violence", + "location": "Park St and Boylston Boston", + }, + "firearm": { + "time_of_incident": "2021-10-05 00:00:00", + "description": "Robbery", + "perpetrators": [ + {"first_name": "Bad", "last_name": "Cop"}, + ], + "use_of_force": [{"item": "indirect firearm"}], + "source": "Citizens Police Data Project", + "location": "CHICAGO ILLINOIS", + }, +} + +mock_partners = { + "cpdp": {"name": "Citizens Police Data Project"} +} + +mock_accusations = { + "domestic": { + "officer": "light", + "date_created": "2023-03-14 01:05:09", + "basis": "Name Match" + }, + "traffic": { + "officer": "severe", + "date_created": "2023-10-01 00:00:00", + "basis": "Name Match" + }, + "firearm": { + "officer": "severe", + "date_created": "2023-10-05 00:00:00", + "basis": "Name Match" + }, +} + + +@pytest.fixture +def example_officers(db_session, client, contributor_access_token): + agencies = {} + for name, mock in mock_agencies.items(): + db_session.add(Agency(**mock)) + db_session.commit() + agencies[name] = db_session.query( + Agency).filter(Agency.name == mock["name"]).first() + + created = {} + for name, mock in mock_officers.items(): + mock["known_employers"].append(mock_employment[name]) + res = client.post( + "/api/v1/officers/create", + json=mock, + headers={ + "Authorization": "Bearer {0}".format(contributor_access_token) + }, + ) + assert res.status_code == 200 + created[name] = res.json + + return created, agencies + + +@pytest.fixture +def example_employment(db_session, example_officers): + for id, officer in example_officers.items(): + officer_obj = ( + db_session.query(Officer).filter(Incident.id == id).first() + ) + officer_obj.known_employers.append() + db_session.commit() + + +@pytest.fixture +def example_accusations(db_session, client, + contributor_access_token, example_officers): + officers, agencies = example_officers + incidents = {} + accusations = {} + perpetrators = {} + + for id, mock in mock_partners.items(): + db_session.add(Partner(**mock)) + db_session.commit() + + for id, mock in mock_incidents.items(): + obj = Incident(**mock) + db_session.add(obj) + db_session.commit() + incidents[id] = obj + perpetrators[id] = obj.perpetrators[0].id + + for id, mock in mock_accusations.items(): + obj = Accusation(**mock) + obj.perpetrator_id = perpetrators[id] + obj.officer_id = officers[id]["id"] + db_session.add(obj) + db_session.commit() + accusations[id] = obj + + return incidents, accusations + + +def test_create_officer(db_session, example_officers): + officers, agencies = example_officers + created = officers["severe"] + + officer_obj = ( + db_session.query(Officer).filter(Officer.id == created["id"]).first() + ) + assert officer_obj.first_name == created["first_name"] + assert officer_obj.last_name == created["last_name"] + assert officer_obj.race == created["race"] + assert officer_obj.ethnicity == created["ethnicity"] + assert len(officer_obj.known_employers) == 1 + + +def test_get_officer(app, client, db_session, access_token): + # Create an officer in the database + fname = "John" + lname = "Doe" + + obj = Officer( + first_name=fname, + last_name=lname + ) + db_session.add(obj) + db_session.commit() + + # Test that we can get it + res = client.get(f"/api/v1/officers/{obj.id}") + + assert res.status_code == 200 + assert res.json["first_name"] == fname + assert res.json["last_name"] == lname + + +""" +@pytest.mark.parametrize( + ("query", "expected_officer_names"), + [ + ( + {}, + ["severe", "light", "none"], + ), + ( + {"location": "New York"}, + ["none"], + ), + ( + { + "badgeNumber": "1234" + }, + ["severe", "none"], + ), + ( + { + "name": "Decent", + }, + ["light"], + ), + ], +) +def test_search_officers( + client, example_officers, access_token, query, expected_officer_names +): + res = client.post( + "/api/v1/officers/search", + json=query, + headers={"Authorization": "Bearer {0}".format(access_token)}, + ) + assert res.status_code == 200 + + # Match the results to the known dataset and assert that all the expected + # results are present + actual_officers = res.json["results"] + + def officer_name(officer): + return next( + ( + k + for k, v in example_officers.items() + if v["id"] == officer["id"] + ), + None, + ) + + actual_incident_names = list( + filter(None, map(officer_name, actual_officers)) + ) + assert set(actual_incident_names) == set(expected_officer_names) + + assert res.json["page"] == 1 + assert res.json["totalPages"] == 1 + assert res.json["totalResults"] == len(expected_officer_names) + """ + + +def test_get_officers(client: Any, access_token: str): + res = client.get( + "/api/v1/officers/", + headers={"Authorization ": "Bearer {0}".format(access_token)}, + ) + + assert res.status_code == 200 + assert res.json["results"] == [] + assert res.json["page"] == 1 + assert res.json["totalPages"] == 0 + assert res.json["totalResults"] == 0 + + +def test_officer_pagination(client, example_officers, access_token): + per_page = 1 + created, agencies = example_officers + expected_total_pages = len(created) + actual_ids = set() + for page in range(1, expected_total_pages + 1): + res = client.get( + "/api/v1/officers/", + query_string={"per_page": per_page, "page": page}, + headers={"Authorization": "Bearer {0}".format(access_token)}, + ) + + assert res.status_code == 200 + assert res.json["page"] == page + assert res.json["totalPages"] == expected_total_pages + assert res.json["totalResults"] == len(created) + + officers = res.json["results"] + assert len(officers) == per_page + actual_ids.add(officers[0]["id"]) + + assert actual_ids == set(i["id"] for i in created.values()) + + res = client.get( + "/api/v1/officers/", + query_string={"perPage": per_page, "page": expected_total_pages + 1}, + headers={"Authorization": "Bearer {0}".format(access_token)}, + ) + assert res.status_code == 404 + + +""" +def test_get_accusations(client: Any, access_token: str): + res = client.get( + "/api/v1/officers/", + headers={"Authorization ": "Bearer {0}".format(access_token)}, + ) + + assert res.status_code == 200 + assert res.json["results"] == [] + assert res.json["page"] == 1 + assert res.json["totalPages"] == 0 + assert res.json["totalResults"] == 0 + + +def test_get_accusations_pagination( + client: Any, + access_token: str, + example_incidents_private_public: list[Incident], +): + \""" + Test that pagination works for public incidents. + \""" + res = client.get( + "/api/v1/officers/?per_page=1", + headers={"Authorization ": "Bearer {0}".format(access_token)}, + ) + + public_incidents_count = len( + [ + i + for i in example_incidents_private_public + if i.privacy_filter == PrivacyStatus.PUBLIC + ] + ) + + assert res.status_code == 200 + assert len(res.json["results"]) == 1 + assert res.json["page"] == 1 + assert res.json["totalPages"] == public_incidents_count + assert res.json["totalResults"] == public_incidents_count + + res = client.get( + "/api/v1/officers/?per_page=1&page=2", + headers={"Authorization ": "Bearer {0}".format(access_token)}, + ) + + assert res.status_code == 200 + assert len(res.json["results"]) == 0 + assert res.json["page"] == 2 + assert res.json["totalPages"] == public_incidents_count + assert res.json["totalResults"] == public_incidents_count + + +def test_get_employers( + client: Any, + access_token: str, + example_incidents_private_public: list[Incident], +): + \""" + Test that a regular user can see public incidents. + \""" + + res = client.get( + "/api/v1/officers/", + headers={"Authorization ": "Bearer {0}".format(access_token)}, + ) + + public_incidents_count = len( + [ + i + for i in example_incidents_private_public + if i.privacy_filter == PrivacyStatus.PUBLIC + ] + ) + assert res.status_code == 200 + assert len(res.json["results"]) == public_incidents_count + assert res.json["page"] == 1 + assert res.json["totalPages"] == 1 + assert res.json["totalResults"] == public_incidents_count + + +def test_get_employers_pagination( + client: Any, + access_token: str, + example_incidents_private_public: list[Incident], +): + \""" + Test that pagination works for public incidents. + \""" + res = client.get( + "/api/v1/officers/?per_page=1", + headers={"Authorization ": "Bearer {0}".format(access_token)}, + ) + + public_incidents_count = len( + [ + i + for i in example_incidents_private_public + if i.privacy_filter == PrivacyStatus.PUBLIC + ] + ) + + assert res.status_code == 200 + assert len(res.json["results"]) == 1 + assert res.json["page"] == 1 + assert res.json["totalPages"] == public_incidents_count + assert res.json["totalResults"] == public_incidents_count + + res = client.get( + "/api/v1/officers/?per_page=1&page=2", + headers={"Authorization ": "Bearer {0}".format(access_token)}, + ) + + assert res.status_code == 200 + assert len(res.json["results"]) == 0 + assert res.json["page"] == 2 + assert res.json["totalPages"] == public_incidents_count + assert res.json["totalResults"] == public_incidents_count + + +def test_delete_officer( + client: Any, + partner_publisher: User, + example_partner: Partner, + example_incidents_private_public: list[Incident], +): + \""" + Test that a partner member can delete an incident. + \""" + + access_token = res = client.post( + "api/v1/auth/login", + json={ + "email": partner_publisher.email, + "password": "my_password", + }, + ).json["access_token"] + + # Make a request to delete the incident + res = client.delete( + f"/api/v1/officers/{example_incidents_private_public[0].id}" + + f"?partner_id={example_partner.id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert res.status_code == 204 + + # Verify that the incident is deleted + deleted_incident = Incident.query.get( + example_incidents_private_public[0].id + ) + assert deleted_incident is None + + +def test_delete_officer_no_user_role( + client: Any, + access_token: str, +): + \""" + Test that a user without atlest CONTRIBUTOR role + can't delete an incident. + \""" + # Make a request to delete the incident + res = client.delete( + "/api/v1/officers/1", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert res.status_code == 403 + + +def test_delete_officer_nonexsitent_officer( + client: Any, + partner_publisher: User, +): + \""" + Test that a partner member can't delete an incident + with a invalid incident id. + \""" + access_token = res = client.post( + "api/v1/auth/login", + json={ + "email": partner_publisher.email, + "password": "my_password", + }, + ).json["access_token"] + + # Make a request to delete the officer + res = client.delete( + f"/api/v1/officers/{999}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert res.status_code == 404 """ diff --git a/backend/tests/test_partners.py b/backend/tests/test_partners.py index 3687fef2..702061d9 100644 --- a/backend/tests/test_partners.py +++ b/backend/tests/test_partners.py @@ -703,6 +703,36 @@ def test_role_change( partner_id=example_partner.id, ).first() assert role_change.role == "Publisher" and role_change is not None + """assertion to see if UserRole is updated to Contributor + after MemberRole changed to Admin or Publisher + """ + user_instance = User.query.filter_by( + id=example_members["member2"]["user_id"] + ).first() + assert user_instance.role == UserRole.CONTRIBUTOR + + res = client.patch( + "/api/v1/partners/role_change", + headers={"Authorization": f"Bearer {access_token}"}, + json={ + "user_id" : example_members["member2"]["user_id"], + "partner_id": example_partner.id, + "role": "Administrator" + } + ) + assert res.status_code == 200 + role_change = PartnerMember.query.filter_by( + user_id=example_members["member2"]["user_id"], + partner_id=example_partner.id, + ).first() + assert role_change.role == "Administrator" and role_change is not None + """assertion to see if UserRole is updated to Contributor + after MemberRole changed to Admin or Publisher + """ + user_instance = User.query.filter_by( + id=example_members["member2"]["user_id"] + ).first() + assert user_instance.role == UserRole.CONTRIBUTOR """ diff --git a/docker-compose.yml b/docker-compose.yml index a95312ee..e01fd58d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: "3" services: db: - image: postgres:13.2 #AWS RDS latest version + image: postgres:16.1 #AWS RDS latest version env_file: - ".env" volumes: diff --git a/docs/src/setup.md b/docs/src/setup.md index 519c4645..5bc45f59 100644 --- a/docs/src/setup.md +++ b/docs/src/setup.md @@ -12,7 +12,7 @@ Install all of the following programs onto your computer: **Required:** -- [Python 3.8](https://www.python.org/downloads/) +- [Python 3.12](https://www.python.org/downloads/) - [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) (first time setup guide [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup)) - [Postgres](https://www.postgresql.org/) _(see installation instructions below)_ - _(OSX only)_ [Homebrew](https://brew.sh/) diff --git a/frontend/helpers/api/api.ts b/frontend/helpers/api/api.ts index 981e2c0d..00bd8653 100644 --- a/frontend/helpers/api/api.ts +++ b/frontend/helpers/api/api.ts @@ -59,7 +59,8 @@ export enum Rank { LIEUTENANT = "Lieutenant", CAPTAIN = "Captain", DEPUTY = "Deputy", - CHIEF = "Chief" + CHIEF = "Chief", + COMMISSIONER = "Commissioner" } export interface Officer { diff --git a/frontend/helpers/api/auth/auth.ts b/frontend/helpers/api/auth/auth.ts new file mode 100644 index 00000000..169e377d --- /dev/null +++ b/frontend/helpers/api/auth/auth.ts @@ -0,0 +1,61 @@ +import { request, AccessToken } from "../base" +import { + User, + RegisterRequest, + LoginRequest, + ForgotPassword, + ResetPasswordRequest, + ResetPasswordResponse, + WhoamiRequest +} from "./types" + +export function login(data: LoginRequest): Promise { + return request({ + url: "/auth/login", + method: "POST", + data + }).then(({ access_token }) => access_token) +} + +export function register(data: RegisterRequest): Promise { + return request({ + url: "/auth/register", + method: "POST", + data + }).then(({ access_token }) => access_token) +} + +export function forgotPassowrd(data: ForgotPassword): Promise { + return request({ + url: "/auth/forgotPassword", + method: "POST", + data + }) +} + +export function resetPassword(req: ResetPasswordRequest): Promise { + const { accessToken } = req + + return request({ + url: `/auth/resetPassword`, + method: "POST", + data: { password: req.password }, + accessToken + }) +} + +export function whoami({ accessToken }: WhoamiRequest): Promise { + return request({ + url: "/auth/whoami", + method: "GET", + accessToken + }).then(({ active, email, email_confirmed_at, first_name, last_name, phone_number, role }) => ({ + active, + email, + emailConfirmedAt: email_confirmed_at, + firstName: first_name, + lastName: last_name, + phoneNumber: phone_number, + role: role + })) +} diff --git a/frontend/helpers/api/auth/index.ts b/frontend/helpers/api/auth/index.ts new file mode 100644 index 00000000..6f7e0d0b --- /dev/null +++ b/frontend/helpers/api/auth/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./auth" diff --git a/frontend/helpers/api/auth/types.ts b/frontend/helpers/api/auth/types.ts new file mode 100644 index 00000000..e5f735ed --- /dev/null +++ b/frontend/helpers/api/auth/types.ts @@ -0,0 +1,41 @@ +import { AuthenticatedRequest } from "../base" + +export interface User { + active: boolean + role: string + email: string + emailConfirmedAt?: string + firstName?: string + lastName?: string + phoneNumber?: string +} + +export interface NewUser { + email: string + password: string + firstName?: string + lastName?: string + phoneNumber?: string +} + +export interface LoginCredentials { + email: string + password: string +} + +export interface ForgotPassword { + email: string +} + +export interface ResetPasswordRequest extends AuthenticatedRequest { + accessToken: string + password: string +} + +export interface ResetPasswordResponse { + message: string +} + +export type RegisterRequest = NewUser +export type LoginRequest = LoginCredentials +export type WhoamiRequest = AuthenticatedRequest diff --git a/frontend/helpers/api/base.ts b/frontend/helpers/api/base.ts new file mode 100644 index 00000000..43ecf4c5 --- /dev/null +++ b/frontend/helpers/api/base.ts @@ -0,0 +1,31 @@ +import axiosModule, { AxiosRequestConfig } from "axios" +import { baseURL } from "./config" + +export type AccessToken = string + +export interface AuthenticatedRequest { + accessToken: AccessToken +} + +const axios = axiosModule.create({ + baseURL, + timeout: 5000 +}) + +export function request({ + accessToken, + ...config +}: AxiosRequestConfig & { accessToken?: AccessToken }) { + let { headers, ...rest } = config + if (accessToken) { + headers = { + Authorization: `Bearer ${accessToken}`, + ...headers + } + } + + return axios({ + headers, + ...rest + }).then((response) => response.data) +} diff --git a/frontend/helpers/api/config.ts b/frontend/helpers/api/config.ts new file mode 100644 index 00000000..d0de1afb --- /dev/null +++ b/frontend/helpers/api/config.ts @@ -0,0 +1 @@ +export const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:5000/api/v1" diff --git a/frontend/helpers/api/incidents/incidents.ts b/frontend/helpers/api/incidents/incidents.ts new file mode 100644 index 00000000..1497d89b --- /dev/null +++ b/frontend/helpers/api/incidents/incidents.ts @@ -0,0 +1,27 @@ +import { AccessToken, request } from "../base" +import { Incident, IncidentSearchRequest, IncidentSearchResponse } from "./types" + +export function searchIncidents({ + accessToken, + dateStart, + dateEnd, + ...rest +}: IncidentSearchRequest): Promise { + if (dateStart) dateStart = new Date(dateStart).toISOString().slice(0, -1) + if (dateEnd) dateEnd = new Date(dateEnd).toISOString().slice(0, -1) + + return request({ + url: "/incidents/search", + method: "POST", + accessToken, + data: { dateStart, dateEnd, ...rest } + }) +} + +export async function getIncidentById(id: number, accessToken: AccessToken): Promise { + return request({ + url: `/incidents/get/${id}`, + method: "GET", + accessToken + }) +} diff --git a/frontend/helpers/api/incidents/index.ts b/frontend/helpers/api/incidents/index.ts new file mode 100644 index 00000000..4708ead6 --- /dev/null +++ b/frontend/helpers/api/incidents/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./incidents" diff --git a/frontend/helpers/api/incidents/types.ts b/frontend/helpers/api/incidents/types.ts new file mode 100644 index 00000000..184966b9 --- /dev/null +++ b/frontend/helpers/api/incidents/types.ts @@ -0,0 +1,73 @@ +import { AuthenticatedRequest } from "../base" + +export interface Source { + name?: string + id?: number + url?: string + contact_email?: string +} + +export interface Perpetrator { + first_name?: string + last_name?: string +} + +export enum Rank { + TECHNICIAN = "Technician", + OFFICER = "Officer", + DETECTIVE = "Detective", + CORPORAL = "Corporal", + SERGEANT = "Sergeant", + LIEUTENANT = "Lieutenant", + CAPTAIN = "Captain", + DEPUTY = "Deputy", + CHIEF = "Chief" +} + +export interface Officer { + id?: number + first_name?: string + last_name?: string + race?: string + ethnicity?: string + gender?: string + rank?: Rank + star?: string + date_of_birth?: Date +} + +export interface UseOfForce { + item?: string +} + +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 + perpetrators: Perpetrator[] + description?: string + use_of_force?: UseOfForce[] +} + +export interface IncidentSearchRequest extends AuthenticatedRequest { + description?: string + dateStart?: string + dateEnd?: string + location?: string + source?: string + page?: number + perPage?: number +} + +export type IncidentSearchResponse = { + results: Incident[] + page: number + totalPages: number + totalResults: number +} diff --git a/frontend/helpers/api/index.ts b/frontend/helpers/api/index.ts index 502ce65f..fe98c512 100644 --- a/frontend/helpers/api/index.ts +++ b/frontend/helpers/api/index.ts @@ -1,2 +1,5 @@ -export * from "./api" +export * from "./base" +export * from "./config" +export * from "./auth" +export * from "./incidents" export { useMockServiceWorker, apiMode } from "./mocks/browser.setup" diff --git a/frontend/helpers/providers.tsx b/frontend/helpers/providers.tsx index 295cce83..fa902519 100644 --- a/frontend/helpers/providers.tsx +++ b/frontend/helpers/providers.tsx @@ -1,12 +1,35 @@ -import { SearchProvider } from "./search" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ReactQueryDevtools } from "@tanstack/react-query-devtools" +import { Toaster } from "react-hot-toast" import { AuthProvider } from "./auth" +import { SearchProvider } from "./search" + +/** + * Query Client responsible for housing cache of + * network requests, serves as basis + * for React Query functionality + */ +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 0, + retry: false + } + } +}) /** * Wraps components in application providers, which set up contexts to provide * services to components. */ export const Providers: React.FC = ({ children }) => ( - - {children} - + <> + + + + + {children} + + + ) diff --git a/frontend/jest.config.js b/frontend/jest.config.js index a02346f5..6c7b59e9 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -9,7 +9,9 @@ const esmNodeModules = [ "delaunator", "robust-predicates", "node-fetch", - "fetch-blob" + "fetch-blob", + "copy-anything", // dependency of @tanstack/react-query-devtools + "is-what" // dependency of @tanstack/react-query-devtools ] module.exports = { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9ee69373..a6e96bf6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,8 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toggle-group": "^1.0.4", + "@tanstack/react-query": "^4.36.1", + "@tanstack/react-query-devtools": "^4.36.1", "axios": "^0.21.1", "classnames": "^2.3.1", "d3": "^7.0.0", @@ -23,6 +25,7 @@ "react-accessible-dropdown-menu-hook": "^3.1.0", "react-dom": "^17.0.2", "react-hook-form": "^7.25.0", + "react-hot-toast": "^2.4.1", "react-responsive": "^9.0.0-beta.4", "react-tooltip": "^5.25.1", "topojson-client": "^3.1.0", @@ -11402,6 +11405,75 @@ "react": ">= 0.14.0" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.11.8", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.11.8.tgz", + "integrity": "sha512-3VPh0SYMGCa5dWQEqNab87UpCMk+ANWHDP4ALs5PeEW9EpfTAbrezzaOk/OiM52IESViefkoAOYuxdoa04p6aA==", + "dependencies": { + "remove-accents": "0.4.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.36.1.tgz", + "integrity": "sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.36.1.tgz", + "integrity": "sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==", + "dependencies": { + "@tanstack/query-core": "4.36.1", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.36.1.tgz", + "integrity": "sha512-WYku83CKP3OevnYSG8Y/QO9g0rT75v1om5IvcWUwiUZJ4LanYGLVCZ8TdFG5jfsq4Ej/lu2wwDAULEUnRIMBSw==", + "dependencies": { + "@tanstack/match-sorter-utils": "^8.7.0", + "superjson": "^1.10.0", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^4.36.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "7.31.2", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz", @@ -15822,6 +15894,20 @@ "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", "dev": true }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/copy-concurrently": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", @@ -16630,10 +16716,9 @@ "dev": true }, "node_modules/csstype": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz", - "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==", - "devOptional": true + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/cyclist": { "version": "1.0.1", @@ -20589,6 +20674,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", + "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/graceful-fs": { "version": "4.2.9", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz", @@ -22198,6 +22291,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/is-whitespace-character": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz", @@ -30607,6 +30711,21 @@ "react": "^16.8.0 || ^17" } }, + "node_modules/react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-inspector": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-5.1.1.tgz", @@ -31320,6 +31439,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + }, "node_modules/remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -32944,6 +33068,17 @@ "stylis": "^3.5.0" } }, + "node_modules/superjson": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.13.3.tgz", + "integrity": "sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -34450,6 +34585,14 @@ "react": "^16.8.0 || ^17.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util": { "version": "0.12.4", "resolved": "https://registry.npmjs.org/util/-/util-0.12.4.tgz", @@ -44147,6 +44290,38 @@ } } }, + "@tanstack/match-sorter-utils": { + "version": "8.11.8", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.11.8.tgz", + "integrity": "sha512-3VPh0SYMGCa5dWQEqNab87UpCMk+ANWHDP4ALs5PeEW9EpfTAbrezzaOk/OiM52IESViefkoAOYuxdoa04p6aA==", + "requires": { + "remove-accents": "0.4.2" + } + }, + "@tanstack/query-core": { + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.36.1.tgz", + "integrity": "sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==" + }, + "@tanstack/react-query": { + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.36.1.tgz", + "integrity": "sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==", + "requires": { + "@tanstack/query-core": "4.36.1", + "use-sync-external-store": "^1.2.0" + } + }, + "@tanstack/react-query-devtools": { + "version": "4.36.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.36.1.tgz", + "integrity": "sha512-WYku83CKP3OevnYSG8Y/QO9g0rT75v1om5IvcWUwiUZJ4LanYGLVCZ8TdFG5jfsq4Ej/lu2wwDAULEUnRIMBSw==", + "requires": { + "@tanstack/match-sorter-utils": "^8.7.0", + "superjson": "^1.10.0", + "use-sync-external-store": "^1.2.0" + } + }, "@testing-library/dom": { "version": "7.31.2", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz", @@ -47825,6 +48000,14 @@ "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=", "dev": true }, + "copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "requires": { + "is-what": "^4.1.8" + } + }, "copy-concurrently": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/copy-concurrently/-/copy-concurrently-1.0.5.tgz", @@ -48459,10 +48642,9 @@ } }, "csstype": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz", - "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==", - "devOptional": true + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "cyclist": { "version": "1.0.1", @@ -51564,6 +51746,12 @@ "slash": "^3.0.0" } }, + "goober": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", + "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", + "requires": {} + }, "graceful-fs": { "version": "4.2.9", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz", @@ -52747,6 +52935,11 @@ "call-bind": "^1.0.0" } }, + "is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==" + }, "is-whitespace-character": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz", @@ -59246,6 +59439,14 @@ "integrity": "sha512-MyF4YXegIT/vfyZloTm98mpJwLUPfULdX37yPzXeijT1hePCkV8DN1IAnEufxgtqCpc7aFGRinegQwisUGZCnA==", "requires": {} }, + "react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "requires": { + "goober": "^2.1.10" + } + }, "react-inspector": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-5.1.1.tgz", @@ -59770,6 +59971,11 @@ "mdast-squeeze-paragraphs": "^4.0.0" } }, + "remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + }, "remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -61046,6 +61252,14 @@ "integrity": "sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw==", "requires": {} }, + "superjson": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.13.3.tgz", + "integrity": "sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==", + "requires": { + "copy-anything": "^3.0.2" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -62164,6 +62378,12 @@ "object-assign": "^4.1.1" } }, + "use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "requires": {} + }, "util": { "version": "0.12.4", "resolved": "https://registry.npmjs.org/util/-/util-0.12.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9b6fa86d..cbfe8f69 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,8 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toggle-group": "^1.0.4", + "@tanstack/react-query": "^4.36.1", + "@tanstack/react-query-devtools": "^4.36.1", "axios": "^0.21.1", "classnames": "^2.3.1", "d3": "^7.0.0", @@ -43,6 +45,7 @@ "react-accessible-dropdown-menu-hook": "^3.1.0", "react-dom": "^17.0.2", "react-hook-form": "^7.25.0", + "react-hot-toast": "^2.4.1", "react-responsive": "^9.0.0-beta.4", "react-tooltip": "^5.25.1", "topojson-client": "^3.1.0", diff --git a/frontend/tests/snapshots/__snapshots__/forgot.test.tsx.snap b/frontend/tests/snapshots/__snapshots__/forgot.test.tsx.snap index 8e7cac9f..e95c4736 100644 --- a/frontend/tests/snapshots/__snapshots__/forgot.test.tsx.snap +++ b/frontend/tests/snapshots/__snapshots__/forgot.test.tsx.snap @@ -2,6 +2,9 @@ exports[`renders Forgot correctly 1`] = `
+
+
@@ -85,6 +88,9 @@ Object {
, "container":
+
diff --git a/frontend/tests/snapshots/__snapshots__/login.test.tsx.snap b/frontend/tests/snapshots/__snapshots__/login.test.tsx.snap index 7b77cb77..512627f4 100644 --- a/frontend/tests/snapshots/__snapshots__/login.test.tsx.snap +++ b/frontend/tests/snapshots/__snapshots__/login.test.tsx.snap @@ -2,6 +2,9 @@ exports[`renders Login correctly 1`] = `
+
+
@@ -431,6 +434,9 @@ Object {
, "container":
+
diff --git a/frontend/tests/snapshots/__snapshots__/passport.test.tsx.snap b/frontend/tests/snapshots/__snapshots__/passport.test.tsx.snap index 70615214..1cf2088a 100644 --- a/frontend/tests/snapshots/__snapshots__/passport.test.tsx.snap +++ b/frontend/tests/snapshots/__snapshots__/passport.test.tsx.snap @@ -2,6 +2,9 @@ exports[`renders Passport correctly 1`] = `
+
+
+
+
+