Skip to content

Commit

Permalink
284 add contributor role (#292)
Browse files Browse the repository at this point in the history
* #284 - Updated the Create endpoint to require a CONTRIBUTOR role and updates the unit tests to use a contributor user.

* Adds member list to source. Commented out.
Need to find a way to associate the user.
Also adds member roles.

* #284 - Added a check for the contributor having a source before continuing. Will now update unit tests.

* #284 - Updated the unit tests to add a source for the contributor user

* #284 - Renaming 'Source' to 'Organization' to match how the front-end refers to the concept.

* #284 - Fixed style issues.

---------

Co-authored-by: Darrell Malone Jr <[email protected]>
  • Loading branch information
mnuzzose and DMalone87 committed Aug 8, 2023
1 parent b17261a commit f5a1be9
Show file tree
Hide file tree
Showing 14 changed files with 177 additions and 57 deletions.
20 changes: 10 additions & 10 deletions alembic/dev_seeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from backend.auth import user_manager
from backend.database.models.incident import Incident
from backend.database.models.perpetrator import Perpetrator
from backend.database.models.source import Source
from backend.database.models.organization import Organization
from backend.database.models.use_of_force import UseOfForce


Expand Down Expand Up @@ -61,17 +61,17 @@ def create_user(user):
)


def create_source(source):
source_exists = (
db.session.query(Source).filter_by(id=source.id).first() is not None
def create_organization(organization):
organization_exists = (
db.session.query(Organization).filter_by(id=organization.id).first() is not None
)

if not source_exists:
source.create()
if not organization_exists:
organization.create()


create_source(
Source(
create_organization(
Organization(
id="mpv",
name="Mapping Police Violence",
url="https://mappingpoliceviolence.us",
Expand All @@ -83,11 +83,11 @@ 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(
mpv = db.session.query(Organization).filter_by(
name="Mapping Police Violence").first()
incident = Incident(
id=id,
source=mpv,
organization=mpv,
location=f"Test location {key}",
longitude=lon,
latitude=lat,
Expand Down
25 changes: 25 additions & 0 deletions backend/auth/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ def verify_roles_or_abort(min_role):
return True


def verify_contributor_has_organization_or_abort():
verify_jwt_in_request()
jwt_decoded = get_jwt()
current_user = User.get(jwt_decoded["sub"])
if (
current_user is None
or not current_user.member_of
):
abort(403)
return False
return True


def blueprint_role_required(*roles):
def decorator():
verify_roles_or_abort(roles)
Expand All @@ -45,3 +58,15 @@ def decorator(*args, **kwargs):
return decorator

return wrapper


def contributor_has_organization():
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
if verify_contributor_has_organization_or_abort():
return fn(*args, **kwargs)

return decorator

return wrapper
2 changes: 1 addition & 1 deletion backend/database/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@
from .models.use_of_force import *
from .models.user import *
from .models.victim import *
from .models.source import *
from .models.organization import *
33 changes: 33 additions & 0 deletions backend/database/models/_assoc_tables.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
from .. import db
from backend.database.models.officer import Rank
from enum import Enum


class MemberRole(Enum):
ADMIN = "Administrator"
PUBLISHER = "Publisher"
MEMBER = "Member"
SUBSCRIBER = "Subscriber"

def get_value(self):
if self == MemberRole.ADMIN:
return 1
elif self == MemberRole.PUBLISHER:
return 2
elif self == MemberRole.MEMBER:
return 3
elif self == MemberRole.SUBSCRIBER:
return 4
else:
return 5


organization_user = db.Table(
'organization_user',
db.Column('organization_id', db.String, db.ForeignKey('organization.id'),
primary_key=True),
db.Column('user_id', db.Integer, db.ForeignKey('user.id'),
primary_key=True),
db.Column('role', db.Enum(MemberRole)),
db.Column('joined_at', db.DateTime),
db.Column('is_active', db.Boolean),
db.Column('is_admin', db.Boolean)
)

incident_agency = db.Table(
'incident_agency',
Expand Down
14 changes: 7 additions & 7 deletions backend/database/models/incident.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@ 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)
organization_id = db.Column(
db.String, db.ForeignKey("organization.id"))
organization_details = db.relationship(
"OrganizationDetails", backref="incident", uselist=False)
time_of_incident = db.Column(db.DateTime)
time_confidence = db.Column(db.Integer)
complaint_date = db.Column(db.Date)
Expand Down Expand Up @@ -114,7 +114,7 @@ def __repr__(self):
# 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)
# organization_id = db.Column(db.Text)
# location = db.Column(db.Text) # TODO: location object
# # TODO: neighborhood seems like a weird identifier that may not always
# # apply in consistent ways across municipalities.
Expand All @@ -131,8 +131,8 @@ def __repr__(self):
# 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
class OrganizationDetails(db.Model):
id = db.Column(db.Integer, primary_key=True) # organization details id
incident_id = db.Column(
db.Integer, db.ForeignKey("incident.id"), nullable=False
)
Expand Down
14 changes: 14 additions & 0 deletions backend/database/models/organization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from ..core import db, CrudMixin
from backend.database.models._assoc_tables import organization_user


class Organization(db.Model, CrudMixin):
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='organization', lazy="select")
members = db.relationship(
'User', backref='contributor_orgs',
secondary=organization_user, lazy="select")
10 changes: 0 additions & 10 deletions backend/database/models/source.py

This file was deleted.

4 changes: 4 additions & 0 deletions backend/database/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,9 @@ class User(db.Model, UserMixin, CrudMixin):

phone_number = db.Column(db.Text)

member_of = db.relationship(
'Organization', backref='organization', secondary='organization_user',
lazy="select")

def verify_password(self, pw):
return bcrypt.checkpw(pw.encode("utf8"), self.password.encode("utf8"))
6 changes: 3 additions & 3 deletions backend/routes/incidents.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import Optional

from backend.auth.jwt import min_role_required
from backend.auth.jwt import min_role_required, contributor_has_organization
from backend.database.models.user import UserRole
from flask import Blueprint, abort, current_app, request
from flask_jwt_extended.view_decorators import jwt_required
Expand Down Expand Up @@ -30,8 +30,8 @@ def get_incidents(incident_id: int):

@bp.route("/create", methods=["POST"])
@jwt_required()
# TODO: Require CONTRIBUTOR role
@min_role_required(UserRole.PUBLIC)
@min_role_required(UserRole.CONTRIBUTOR)
@contributor_has_organization()
@validate(json=CreateIncidentSchema)
def create_incident():
"""Create a single incident.
Expand Down
8 changes: 4 additions & 4 deletions backend/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@

from .database import User
from .database.models.action import Action
from .database.models.source import Source
from .database.models.incident import Incident, SourceDetails
from .database.models.organization import Organization
from .database.models.incident import Incident, OrganizationDetails
from .database.models.agency import Agency
from .database.models.officer import Officer
from .database.models.investigation import Investigation
Expand Down Expand Up @@ -129,13 +129,13 @@ def schema_create(model_type: DeclarativeMeta, **kwargs) -> ModelMetaclass:
return sqlalchemy_to_pydantic(model_type, exclude="id", **kwargs)


CreateSourceSchema = schema_create(Source)
CreateOrganizationSchema = schema_create(Organization)
_BaseCreateIncidentSchema = schema_create(Incident)
CreateOfficerSchema = schema_create(Officer)
CreateAgencySchema = schema_create(Agency)
CreateVictimSchema = schema_create(Victim)
CreatePerpetratorSchema = schema_create(Perpetrator)
CreateSourceDetailsSchema = schema_create(SourceDetails)
CreateOrganizationDetailsSchema = schema_create(OrganizationDetails)
CreateTagSchema = schema_create(Tag)
CreateParticipantSchema = schema_create(Participant)
CreateAttachmentSchema = schema_create(Attachment)
Expand Down
6 changes: 3 additions & 3 deletions backend/scraper/data_scrapers/load_full_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def row_to_orm(row):
row = row._make(map(nan_to_none, row))
incident_date = parse_date(row.incident_date)
incident_time = parse_date(row.incident_time)
source = row.data_source_id
organization_id = row.data_source_id
use_of_force = row.death_manner
location = orm_location(row)

Expand All @@ -78,8 +78,8 @@ def row_to_orm(row):
incident.description = row.description
if use_of_force:
incident.use_of_force = [UseOfForce(item=use_of_force)]
if source:
incident.source = source
if organization_id:
incident.organization_id = organization_id

return incident

Expand Down
17 changes: 9 additions & 8 deletions backend/scraper/data_scrapers/scraper_utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def parse_accusations(r: namedtuple, officers: List[md.Officer]):
]


def create_orm(r: namedtuple, source):
def create_orm(r: namedtuple, organization):
victim = md.Victim(
name=r.victim_name,
race=r.victim_race,
Expand All @@ -72,8 +72,8 @@ def create_orm(r: namedtuple, source):
officers = parse_officers(r)
accusations = parse_accusations(r, officers)
incident = md.Incident(
source_id=r.source_id,
source=source,
organization_id=r.organization_id,
organization=organization,
time_of_incident=r.incident_date,
location=location(r),
description=r.description,
Expand All @@ -100,12 +100,13 @@ def insert_model(instance):
db.session.commit()


def drop_existing_records(dataset, source):
def drop_existing_records(dataset, organization):
with app.app_context():
existing_source_ids = list(
existing_organization_ids = list(
s
for (s,) in db.session.query(md.Incident.source_id).filter(
md.Incident.source == source, md.Incident.source_id is not None
for (s,) in db.session.query(md.Incident.organization_id).filter(
md.Incident.organization == organization,
md.Incident.organization_id is not None
)
)
return dataset.drop(existing_source_ids)
return dataset.drop(existing_organization_ids)
54 changes: 53 additions & 1 deletion backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
from backend.api import create_app
from backend.auth import user_manager
from backend.config import TestingConfig
from backend.database import User, UserRole, db
from backend.database import User, UserRole, db, Organization, organization_user
from backend.database.models._assoc_tables import MemberRole
from datetime import datetime
from pytest_postgresql.janitor import DatabaseJanitor
from sqlalchemy import insert

example_email = "[email protected]"
admin_email = "[email protected]"
contributor_email = "[email protected]"
example_password = "my_password"


Expand Down Expand Up @@ -49,6 +53,19 @@ def client(app):
return app.test_client()


@pytest.fixture
def example_organization(db_session):
organization = Organization(
id="example_organization",
name="Example Organization",
url="www.example.com",
contact_email=contributor_email,
)
db_session.add(organization)
db_session.commit()
return organization


@pytest.fixture
def example_user(db_session):
user = User(
Expand Down Expand Up @@ -79,6 +96,28 @@ def admin_user(db_session):
return user


@pytest.fixture
def contributor_user(db_session, example_organization):
user = User(
email=contributor_email,
password=user_manager.hash_password(example_password),
role=UserRole.CONTRIBUTOR,
first_name="contributor",
last_name="last"
)
db_session.add(user)
db_session.commit()
insert_statement = insert(organization_user).values(
organization_id=example_organization.id, user_id=user.id,
role=MemberRole.PUBLISHER, joined_at=datetime.now(),
is_active=True, is_admin=False
)
db_session.execute(insert_statement)
db_session.commit()

return user


@pytest.fixture
def access_token(client, example_user):
res = client.post(
Expand All @@ -92,6 +131,19 @@ def access_token(client, example_user):
return res.json["access_token"]


@pytest.fixture
def contributor_access_token(client, contributor_user):
res = client.post(
"api/v1/auth/login",
json={
"email": contributor_email,
"password": example_password,
},
)
assert res.status_code == 200
return res.json["access_token"]


@pytest.fixture
def cli_runner(app):
return app.test_cli_runner()
Expand Down
Loading

0 comments on commit f5a1be9

Please sign in to comment.