Skip to content

Commit

Permalink
[Feature] Expand Partner API (#299)
Browse files Browse the repository at this point in the history
* Association Object for Partner User relationship
- May decide to do the same for the rest of the assoc. tables in the future

* Add Members to Partner
- `partners/:partner_id/members/add` endpoint created
- When creating a Partner, the logged in user is automatically added as an admin

* Add "Get All Members"
- `/<int:partner_id>/members/` endpoint
- Automatically create Timestamp for new Incident entries
  • Loading branch information
DMalone87 authored Aug 21, 2023
1 parent 556d996 commit 256e1cc
Show file tree
Hide file tree
Showing 9 changed files with 496 additions and 53 deletions.
32 changes: 0 additions & 32 deletions backend/database/models/_assoc_tables.py
Original file line number Diff line number Diff line change
@@ -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'),
Expand Down
6 changes: 6 additions & 0 deletions backend/database/models/incident.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -100,6 +102,10 @@ def __repr__(self):
"""Represent instance as a unique string."""
return f"<Incident {self.id}>"

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
Expand Down
54 changes: 50 additions & 4 deletions backend/database/models/partner.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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."""
Expand Down
9 changes: 9 additions & 0 deletions backend/database/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
144 changes: 142 additions & 2 deletions backend/routes/partners.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand Down Expand Up @@ -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("/<int:partner_id>/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": "[email protected]",
"role": "ADMIN",
}
} """


@bp.route("/<int:partner_id>/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)
30 changes: 28 additions & 2 deletions backend/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Loading

0 comments on commit 256e1cc

Please sign in to comment.