diff --git a/backend/database/models/_assoc_tables.py b/backend/database/models/_assoc_tables.py index 94e34df40..ccaa670e5 100644 --- a/backend/database/models/_assoc_tables.py +++ b/backend/database/models/_assoc_tables.py @@ -1,39 +1,7 @@ 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 - - -partner_user = db.Table( - 'partner_user', - db.Column('partner_id', db.Integer, db.ForeignKey('partner.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', db.Column('incident_id', db.Integer, db.ForeignKey('incident.id'), diff --git a/backend/database/models/incident.py b/backend/database/models/incident.py index 024db1734..ae3d5c9dd 100644 --- a/backend/database/models/incident.py +++ b/backend/database/models/incident.py @@ -1,5 +1,6 @@ """Define the SQL classes for Users.""" import enum +from datetime import datetime from ..core import CrudMixin, db from backend.database.models._assoc_tables import incident_agency, incident_tag @@ -63,6 +64,7 @@ class Incident(db.Model, CrudMixin): db.Integer, db.ForeignKey("partner.id")) source_details = db.relationship( "SourceDetails", backref="incident", uselist=False) + date_record_created = db.Column(db.DateTime) time_of_incident = db.Column(db.DateTime) time_confidence = db.Column(db.Integer) complaint_date = db.Column(db.Date) @@ -100,6 +102,10 @@ def __repr__(self): """Represent instance as a unique string.""" return f"" + def create(self, refresh: bool = True): + self.date_record_created = datetime.now() + return super().create(refresh) + # On the Description object: # Seems like this is based on the WITNESS standard. It also appears that the diff --git a/backend/database/models/partner.py b/backend/database/models/partner.py index 9e0366249..24eccc6b4 100644 --- a/backend/database/models/partner.py +++ b/backend/database/models/partner.py @@ -1,5 +1,51 @@ + +from sqlalchemy.ext.associationproxy import association_proxy from ..core import db, CrudMixin -from backend.database.models._assoc_tables import partner_user +from enum import Enum +from datetime import datetime + + +class MemberRole(str, 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 + + +class PartnerMember(db.Model, CrudMixin): + __tablename__ = "partner_user" + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + partner_id = db.Column(db.Integer, db.ForeignKey('partner.id'), + primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), + primary_key=True) + user = db.relationship("User", back_populates="partner_association") + partner = db.relationship("Partner", back_populates="member_association") + role = db.Column(db.Enum(MemberRole)) + date_joined = db.Column(db.DateTime) + is_active = db.Column(db.Boolean) + + def is_administrator(self): + return self.role == MemberRole.ADMIN + + def get_default_role(): + return MemberRole.SUBSCRIBER + + def create(self, refresh: bool = True): + self.date_joined = datetime.now() + return super().create(refresh) class Partner(db.Model, CrudMixin): @@ -9,9 +55,9 @@ class Partner(db.Model, CrudMixin): contact_email = db.Column(db.Text) reported_incidents = db.relationship( 'Incident', backref='source', lazy="select") - members = db.relationship( - 'User', backref='member_of', - secondary=partner_user, lazy="select") + member_association = db.relationship( + 'PartnerMember', back_populates="partner", lazy="select") + members = association_proxy("member_association", "user") def __repr__(self): """Represent instance as a unique string.""" diff --git a/backend/database/models/user.py b/backend/database/models/user.py index ff63ea9f1..ab72c569d 100644 --- a/backend/database/models/user.py +++ b/backend/database/models/user.py @@ -5,6 +5,7 @@ from flask_serialize.flask_serialize import FlaskSerialize from flask_user import UserMixin from sqlalchemy.ext.compiler import compiles +from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.types import String, TypeDecorator from ..core import CrudMixin from enum import Enum @@ -85,5 +86,13 @@ class User(db.Model, UserMixin, CrudMixin): phone_number = db.Column(db.Text) + # Data Partner Relationships + partner_association = db.relationship( + "PartnerMember", back_populates="user", lazy="select") + member_of = association_proxy("partner_association", "partner") + def verify_password(self, pw): return bcrypt.checkpw(pw.encode("utf8"), self.password.encode("utf8")) + + def get_by_email(email): + return User.query.filter(User.email == email).first() diff --git a/backend/routes/partners.py b/backend/routes/partners.py index 77d17b461..b2df88abb 100644 --- a/backend/routes/partners.py +++ b/backend/routes/partners.py @@ -1,12 +1,16 @@ from backend.auth.jwt import min_role_required -from backend.database.models.user import UserRole +from backend.database.models.user import User, UserRole from flask import Blueprint, abort, current_app, request +from flask_jwt_extended import get_jwt from flask_jwt_extended.view_decorators import jwt_required -from ..database import Partner +from ..database import Partner, PartnerMember, MemberRole, db from ..schemas import ( CreatePartnerSchema, + AddMemberSchema, partner_orm_to_json, + partner_member_orm_to_json, + partner_member_to_orm, partner_to_orm, validate, ) @@ -42,4 +46,140 @@ def create_partner(): abort(400) created = partner.create() + make_admin = PartnerMember( + partner_id=created.id, + user_id=User.get(get_jwt()["sub"]).id, + role=MemberRole.ADMIN, + ) + make_admin.create() + return partner_orm_to_json(created) + + +@bp.route("/", methods=["GET"]) +@jwt_required() +@min_role_required(UserRole.PUBLIC) +@validate() +def get_all_partners(): + """Get all partners. + 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_partners = db.session.query(Partner) + results = all_partners.paginate( + page=q_page, per_page=q_per_page, max_per_page=100 + ) + + return { + "results": [partner_orm_to_json(partner) for partner in results.items], + "page": results.page, + "totalPages": results.pages, + "totalResults": results.total, + } + + +@bp.route("//members/", methods=["GET"]) +@jwt_required() +@min_role_required(UserRole.PUBLIC) +@validate() +def get_partner_members(partner_id: int): + """Get all members of a partner. + 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) + + # partner = Partner.get(partner_id) + all_members = db.session.query(PartnerMember).filter( + PartnerMember.partner_id == partner_id + ) + results = all_members.paginate( + page=q_page, per_page=q_per_page, max_per_page=100) + + return { + "results": [ + partner_member_orm_to_json(member) + for member in results.items + ], + "page": results.page, + "totalPages": results.pages, + "totalResults": results.total, + } + + +""" This class currently doesn't work with the `partner_member_to_orm` + class AddMemberSchema(BaseModel): + user_email: str + role: Optional[MemberRole] = PartnerMember.get_default_role() + is_active: Optional[bool] = True + + class Config: + extra = "forbid" + schema_extra = { + "example": { + "user_email": "member@partner.org", + "role": "ADMIN", + } + } """ + + +@bp.route("//members/add", methods=["POST"]) +@jwt_required() +@min_role_required(UserRole.PUBLIC) +@validate(json=AddMemberSchema) +def add_member_to_partner(partner_id: int): + """Add a member to a partner. + + TODO: Allow the API to accept a user email instad of a user id + TODO: Use the partner ID from the API path instead of the request body + The `partner_member_to_orm` function seems very picky about the input. + I wasn't able to get it to accept a dict or a PartnerMember object. + + Cannot be called in production environments + """ + if current_app.env == "production": + abort(418) + + # Ensure that the user has premission to add a member to this partner. + jwt_decoded = get_jwt() + + current_user = User.get(jwt_decoded["sub"]) + association = db.session.query(PartnerMember).filter( + PartnerMember.user_id == current_user.id, + PartnerMember.partner_id == partner_id, + ).first() + + if ( + association is None + or not association.is_administrator() + or not association.partner_id == partner_id + ): + abort(403) + + # TODO: Allow the API to accept a user email instad of a user id + # user_obj = User.get_by_email(request.context.json.user_email) + # if user_obj is None: + # abort(400) + + # new_member = PartnerMember( + # partner_id=partner_id, + # user_id=user_obj.id, + # role=request.context.json.role, + # ) + + try: + partner_member = partner_member_to_orm(request.context.json) + except Exception: + abort(400) + + created = partner_member.create() + + return partner_member_orm_to_json(created) diff --git a/backend/schemas.py b/backend/schemas.py index f752a3e96..b7f1a97e4 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -10,7 +10,7 @@ from .database import User from .database.models.action import Action -from .database.models.partner import Partner +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 @@ -180,12 +180,25 @@ class CreatePartnerSchema(_BaseCreatePartnerSchema, _PartnerMixin): reported_incidents: Optional[List[_BaseCreateIncidentSchema]] +class CreatePartnerMemberSchema(BaseModel): + user_id: int + role: MemberRole + is_active: Optional[bool] = True + + +AddMemberSchema = sqlalchemy_to_pydantic( + PartnerMember, + exclude=["id", "date_joined", "partner", "user"] +) + + def schema_get(model_type: DeclarativeMeta, **kwargs) -> ModelMetaclass: return sqlalchemy_to_pydantic(model_type, **kwargs) _BasePartnerSchema = schema_get(Partner) _BaseIncidentSchema = schema_get(Incident) +PartnerMemberSchema = schema_get(PartnerMember) VictimSchema = schema_get(Victim) PerpetratorSchema = schema_get(Perpetrator) TagSchema = schema_get(Tag) @@ -257,7 +270,7 @@ def incident_orm_to_json(incident: Incident) -> dict: def partner_to_orm(partner: CreatePartnerSchema) -> Partner: - """Convert the JSON incident into an ORM instance + """Convert the JSON partner 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 @@ -282,3 +295,16 @@ def partner_orm_to_json(partner: Partner) -> dict: return PartnerSchema.from_orm(partner).dict( exclude_none=True, ) + + +def partner_member_to_orm( + partner_member: CreatePartnerMemberSchema) -> PartnerMember: + """Convert the JSON partner member into an ORM instance""" + orm_attrs = partner_member.dict() + return PartnerMember(**orm_attrs) + + +def partner_member_orm_to_json(partner_member: PartnerMember) -> dict: + return PartnerMemberSchema.from_orm(partner_member).dict( + exclude_none=True, + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 324b78942..377217c93 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,14 +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, Partner, partner_user -from backend.database.models._assoc_tables import MemberRole +from backend.database import User, UserRole, db +from backend.database import Partner, PartnerMember, MemberRole from datetime import datetime from pytest_postgresql.janitor import DatabaseJanitor from sqlalchemy import insert example_email = "test@email.com" admin_email = "admin@email.com" +p_admin_email = "admin@partner.com" contributor_email = "contributor@email.com" example_password = "my_password" @@ -96,7 +97,31 @@ def admin_user(db_session): @pytest.fixture -def contributor_user(db_session, example_partner): +def partner_admin(db_session, example_partner): + user = User( + email=p_admin_email, + password=user_manager.hash_password(example_password), + role=UserRole.CONTRIBUTOR, # This is not a system admin, + # so we can't use ADMIN here + first_name="contributor", + last_name="last", + phone_number="(012) 345-6789", + ) + db_session.add(user) + db_session.commit() + insert_statement = insert(PartnerMember).values( + partner_id=example_partner.id, user_id=user.id, + role=MemberRole.ADMIN, date_joined=datetime.now(), + is_active=True + ) + db_session.execute(insert_statement) + db_session.commit() + + return user + + +@pytest.fixture +def partner_publisher(db_session, example_partner): user = User( email=contributor_email, password=user_manager.hash_password(example_password), @@ -106,10 +131,10 @@ def contributor_user(db_session, example_partner): ) db_session.add(user) db_session.commit() - insert_statement = insert(partner_user).values( + insert_statement = insert(PartnerMember).values( partner_id=example_partner.id, user_id=user.id, - role=MemberRole.PUBLISHER, joined_at=datetime.now(), - is_active=True, is_admin=False + role=MemberRole.PUBLISHER, date_joined=datetime.now(), + is_active=True ) db_session.execute(insert_statement) db_session.commit() @@ -131,7 +156,20 @@ def access_token(client, example_user): @pytest.fixture -def contributor_access_token(client, contributor_user): +def p_admin_access_token(client, partner_admin): + res = client.post( + "api/v1/auth/login", + json={ + "email": p_admin_email, + "password": example_password, + }, + ) + assert res.status_code == 200 + return res.json["access_token"] + + +@pytest.fixture +def contributor_access_token(client, partner_publisher): res = client.post( "api/v1/auth/login", json={ diff --git a/backend/tests/test_incidents.py b/backend/tests/test_incidents.py index 813278c48..3d317ab40 100644 --- a/backend/tests/test_incidents.py +++ b/backend/tests/test_incidents.py @@ -161,7 +161,7 @@ def incident_name(incident): assert res.json["totalResults"] == len(expected_incident_names) -def test_pagination(client, example_incidents, access_token): +def test_incident_pagination(client, example_incidents, access_token): per_page = 1 expected_total_pages = len(example_incidents) actual_ids = set() diff --git a/backend/tests/test_partners.py b/backend/tests/test_partners.py index 241652c15..5ad33d6b0 100644 --- a/backend/tests/test_partners.py +++ b/backend/tests/test_partners.py @@ -1,6 +1,13 @@ import pytest -from backend.database import Partner +from backend.auth import user_manager +from backend.database import Partner, PartnerMember, MemberRole +from backend.database.models.user import User, UserRole +publisher_email = "pub@partner.com" +inactive_email = "lurker@partner.com" +admin_email = "leader@partner.com" +member_email = "joe@partner.com" +example_password = "my_password" mock_partners = { "cpdp": { @@ -20,9 +27,51 @@ } } +mock_users = { + "publisher": { + "email": publisher_email, + "password": example_password, + }, + "inactive": { + "email": inactive_email, + "password": example_password, + }, + "admin": { + "email": admin_email, + "password": example_password, + }, + "member": { + "email": member_email, + "password": example_password, + } +} + +mock_members = { + "publisher": { + "user_email": publisher_email, + "role": MemberRole.PUBLISHER, + "is_active": True + }, + "inactive": { + "user_email": inactive_email, + "role": MemberRole.PUBLISHER, + "is_active": False + }, + "admin": { + "user_email": publisher_email, + "role": MemberRole.ADMIN, + "is_active": True + }, + "member": { + "user_email": publisher_email, + "role": MemberRole.MEMBER, + "is_active": True + } +} + @pytest.fixture -def example_partners(db_session, client, access_token): +def example_partners(client, access_token): created = {} for id, mock in mock_partners.items(): @@ -37,8 +86,57 @@ def example_partners(db_session, client, access_token): return created -def test_create_partner(db_session, example_partners): - # sample = mock_partners["mpv"] +@pytest.fixture +def example_members(client, db_session, example_partner, p_admin_access_token): + created = {} + users = {} + + for id, mock in mock_users.items(): + user = User( + email=mock["email"], + password=user_manager.hash_password(example_password), + role=UserRole.PUBLIC, + first_name=id, + last_name="user", + phone_number="(278) 555-7890" + ) + db_session.add(user) + db_session.commit() + users[id] = user + + partner_obj = ( + db_session.query(Partner).filter( + Partner.name == example_partner.name + ).first() + ) + + for id, mock in mock_members.items(): + + user_obj = ( + db_session.query(User).filter( + User.email == mock["user_email"] + ).first() + ) + + req = { + "partner_id": partner_obj.id, + "user_id": user_obj.id, + "role": mock["role"], + "is_active": mock["is_active"] + } + + res = client.post( + f"/api/v1/partners/{partner_obj.id}/members/add", + json=req, + headers={"Authorization": + "Bearer {0}".format(p_admin_access_token)}, + ) + assert res.status_code == 200 + created[id] = res.json + return created + + +def test_create_partner(db_session, example_user, example_partners): created = example_partners["mpv"] partner_obj = ( @@ -46,12 +144,25 @@ def test_create_partner(db_session, example_partners): ).first() ) + user_obj = ( + db_session.query(User).filter(User.email == example_user.email).first() + ) + + association_obj = ( + db_session.query(PartnerMember).filter( + PartnerMember.partner_id == partner_obj.id, + PartnerMember.user_id == user_obj.id + ).first() + ) + assert partner_obj.name == created["name"] assert partner_obj.url == created["url"] assert partner_obj.contact_email == created["contact_email"] + assert association_obj is not None + assert association_obj.is_administrator() is True -def test_get_partner(app, client, db_session, access_token): +def test_get_partner(client, db_session, access_token): # Create a partner in the database partner_name = "Test Partner" partner_url = "https://testpartner.com" @@ -65,3 +176,102 @@ def test_get_partner(app, client, db_session, access_token): res = client.get(f"/api/v1/partners/{obj.id}") assert res.json["name"] == partner_name assert res.json["url"] == partner_url + + +def test_get_all_partners(client, example_partners): + # Create partners in the database + created = example_partners + + # Test that we can get partners + res = client.get("/api/v1/partners/") + assert res.json["results"].__len__() == created.__len__() + + +def test_partner_pagination(client, example_partners, access_token): + per_page = 1 + expected_total_pages = len(example_partners) + actual_ids = set() + for page in range(1, expected_total_pages + 1): + res = client.get( + f"/api/v1/partners/?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"] == expected_total_pages + + incidents = res.json["results"] + assert len(incidents) == per_page + actual_ids.add(incidents[0]["id"]) + + assert actual_ids == set(i["id"] for i in example_partners.values()) + + res = client.get( + ( + f"/api/v1/partners/?per_page={per_page}" + f"&page={expected_total_pages + 1}" + ), + headers={"Authorization": "Bearer {0}".format(access_token)}, + ) + assert res.status_code == 404 + + +def test_add_member_to_partner(db_session, example_members): + created = example_members["publisher"] + + partner_member_obj = ( + db_session.query(PartnerMember).filter( + PartnerMember.id == created["id"]).first() + ) + + assert partner_member_obj.partner_id == created["partner_id"] + assert partner_member_obj.user_id == created["user_id"] + assert partner_member_obj.role == created["role"] + + +def test_get_partner_members( + db_session, client, example_partner, example_user, + admin_user, access_token): + # Create partners in the database + users = [] + partner_obj = ( + db_session.query(Partner).filter( + Partner.name == example_partner.name).first() + ) + + member_obj = ( + db_session.query(User).filter( + User.email == example_user.email).first() + ) + + admin_obj = ( + db_session.query(User).filter( + User.email == admin_user.email).first() + ) + + users.append(member_obj) + users.append(admin_obj) + + for user in users: + association_obj = PartnerMember( + partner_id=partner_obj.id, + user_id=user.id + ) + db_session.add(association_obj) + db_session.commit() + + # Test that we can get partners + res = client.get( + f"/api/v1/partners/{partner_obj.id}/members/", + headers={"Authorization": + "Bearer {0}".format(access_token)} + ) + + assert res.status_code == 200 + assert res.json["results"].__len__() == users.__len__() + # assert res.json["results"][0]["user"]["email"] == member_obj.email + + +# def deactivate_partner_member(client, example_partners, access_token):