Skip to content

Commit

Permalink
Implement membership revoke and versioning (#1994)
Browse files Browse the repository at this point in the history
  • Loading branch information
jadmsaadaot authored Aug 11, 2023
1 parent b6fc364 commit 0bcd106
Show file tree
Hide file tree
Showing 24 changed files with 573 additions and 82 deletions.
37 changes: 37 additions & 0 deletions met-api/migrations/versions/31041fb90d53_membership_versioning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
""" add versioning to membership table
Revision ID: 31041fb90d53
Revises: e2d5d38220d9
Create Date: 2023-08-09 14:18:45.335397
"""
from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision = '31041fb90d53'
down_revision = 'e2d5d38220d9'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('membership', sa.Column('version', sa.Integer(), nullable=True))
op.add_column('membership', sa.Column('is_latest', sa.Boolean(), nullable=True))

# Update existing rows with default values
op.execute("UPDATE membership SET version = 1")
op.execute("UPDATE membership SET is_latest = TRUE")

# Change columns to non-nullable
op.alter_column('membership', 'version', nullable=False)
op.alter_column('membership', 'is_latest', nullable=False)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('membership', 'is_latest')
op.drop_column('membership', 'version')
# ### end Alembic commands ###
46 changes: 46 additions & 0 deletions met-api/migrations/versions/e2d5d38220d9_add_revoked_membership.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
""" Add revoked_date and revoked status for membership
Revision ID: e2d5d38220d9
Revises: db737a0db061
Create Date: 2023-08-09 07:21:47.043458
"""
from datetime import datetime
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e2d5d38220d9'
down_revision = 'db737a0db061'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('membership', sa.Column('revoked_date', sa.DateTime(), nullable=True))

membership_status_codes = sa.table(
'membership_status_codes',
sa.Column('id', sa.Integer),
sa.Column('status_name', sa.String),
sa.Column('description', sa.String),
sa.Column('created_date', sa.DateTime),
sa.Column('updated_date', sa.DateTime)
)
op.execute(
membership_status_codes.insert().values(
id=3,
status_name='REVOKED',
description='Revoked Membership',
created_date=datetime.utcnow(),
updated_date=datetime.utcnow()
)
)

# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('membership', 'revoked_date')
# ### end Alembic commands ###
79 changes: 60 additions & 19 deletions met-api/src/met_api/models/membership.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from sqlalchemy import ForeignKey, and_, or_

from met_api.constants.membership_type import MembershipType
from met_api.utils.enums import MembershipStatus

from .base_model import BaseModel
from .staff_user import StaffUser
Expand All @@ -24,45 +23,87 @@ class Membership(BaseModel):
status = db.Column(
ForeignKey('membership_status_codes.id')
)
revoked_date = db.Column(db.DateTime, nullable=True)
engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=False)
user_id = db.Column(db.Integer, ForeignKey('staff_users.id'), nullable=True)
type = db.Column(db.Enum(MembershipType), nullable=False)
user = db.relationship('StaffUser', foreign_keys=[user_id], lazy='joined')
membership_status = db.relationship('MembershipStatusCode', foreign_keys=[status], lazy='select')
engagement = db.relationship('Engagement', foreign_keys=[engagement_id], lazy='select')
tenant_id = db.Column(db.Integer, db.ForeignKey('tenant.id'), nullable=True)
version = db.Column(db.Integer, nullable=False, default=1)
is_latest = db.Column(db.Boolean, nullable=False, default=True)

@classmethod
def find_by_engagement(cls, engagement_id) -> List[Membership]:
def find_by_engagement(cls, engagement_id, status=None) -> List[Membership]:
"""Get a survey."""
memberships = db.session.query(Membership) \
.filter(Membership.engagement_id == engagement_id) \
.all()
query = db.session.query(Membership) \
.filter(and_(
Membership.engagement_id == engagement_id,
Membership.status == status,
bool(Membership.is_latest)
))
if status:
query = query.filter(Membership.status == status)
memberships = query.all()
return memberships

@classmethod
def find_by_user_id(cls, user_external_id) -> List[Membership]:
def find_by_user_id(cls, user_external_id, status=None) -> List[Membership]:
"""Get memberships by user id."""
memberships = db.session.query(Membership) \
query = db.session.query(Membership) \
.join(StaffUser, StaffUser.id == Membership.user_id) \
.filter(and_(StaffUser.external_id == user_external_id,
or_(Membership.type == MembershipType.TEAM_MEMBER,
Membership.type == MembershipType.REVIEWER)
)
) \
.all()
.filter(
and_(
StaffUser.external_id == user_external_id,
or_(
Membership.type == MembershipType.TEAM_MEMBER,
Membership.type == MembershipType.REVIEWER
),
bool(Membership.is_latest)))
if status:
query = query.filter(Membership.status == status)

memberships = query.all()
return memberships

@classmethod
def find_by_engagement_and_user_id(cls, eng_id, userid, status=MembershipStatus.ACTIVE.value) \
-> List[Membership]:
def find_by_engagement_and_user_id(cls, eng_id, userid, status=None) \
-> Membership:
"""Get a survey."""
memberships = db.session.query(Membership) \
query = db.session.query(Membership) \
.join(StaffUser, StaffUser.id == Membership.user_id) \
.filter(and_(Membership.engagement_id == eng_id,
Membership.user_id == userid,
Membership.status == status
bool(Membership.is_latest)
)
)
if status:
query = query.filter(Membership.status == status)
membership = query.first()
return membership

@classmethod
def create_new_version(cls, engagement_id, user_id, new_membership: dict) -> Membership:
"""Create new version of membership."""
latest_membership = db.session.query(Membership) \
.filter(and_(Membership.engagement_id == engagement_id,
Membership.user_id == user_id,
bool(Membership.is_latest)
)
) \
.all()
return memberships
.first()
latest_membership.is_latest = False
latest_membership.save()

new_membership: Membership = Membership(
engagement_id=engagement_id,
user_id=user_id,
status=new_membership.get('status'),
type=new_membership.get('type'),
revoked_date=new_membership.get('revoked_date', None),
is_latest=True,
version=latest_membership.version + 1
)
new_membership.save()
return new_membership
2 changes: 1 addition & 1 deletion met-api/src/met_api/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@
API.add_namespace(ENGAGEMENT_METADATA_API)
API.add_namespace(SHAPEFILE_API)
API.add_namespace(TENANT_API)
API.add_namespace(WIDGET_DOCUMENTS_API, path='/widgets/<string:widget_id>/documents')
API.add_namespace(ENGAGEMENT_MEMBERS_API, path='/engagements/<string:engagement_id>/members')
API.add_namespace(WIDGET_DOCUMENTS_API, path='/widgets/<string:widget_id>/documents')
API.add_namespace(WIDGET_EVENTS_API, path='/widgets/<int:widget_id>/events')
API.add_namespace(WIDGET_SUBSCRIBE_API, path='/widgets/<int:widget_id>/subscribe')
API.add_namespace(WIDGET_MAPS_API, path='/widgets/<int:widget_id>/maps')
Expand Down
18 changes: 18 additions & 0 deletions met-api/src/met_api/resources/engagement_members.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,21 @@ def get(engagement_id, user_id): # pylint: disable=unused-argument
return jsonify(MembershipSchema().dump(members, many=True)), HTTPStatus.OK
except BusinessException as err:
return {'message': err.error}, err.status_code


@cors_preflight('PATCH,OPTIONS')
@API.route('/<user_id>/status')
class RevokeMembership(Resource):
"""Resource for revoking engagement membership for a user."""

@staticmethod
@cross_origin(origins=allowedorigins())
@_jwt.has_one_of_roles([Role.EDIT_MEMBERS.value])
def patch(engagement_id, user_id):
"""Update membership status."""
try:
action = request.get_json().get('action', str)
membership = MembershipService.update_membership_status(engagement_id, user_id, action)
return MembershipSchema().dump(membership), HTTPStatus.OK
except ValueError as err:
return str(err), HTTPStatus.BAD_REQUEST
3 changes: 1 addition & 2 deletions met-api/src/met_api/schemas/memberships.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ class Meta: # pylint: disable=too-few-public-methods
unknown = EXCLUDE

id = fields.Int(data_key='id')
status = fields.Str(data_key='status')
created_date = fields.DateTime(data_key='created_date')
engagement_id = fields.Int(data_key='engagement_id')
status = fields.Str(data_key='status')
status = fields.Int(data_key='status')
user_id = fields.Int(data_key='user_id')
user = fields.Nested(StaffUserSchema)
type = EnumField(MembershipType, by_value=True)
8 changes: 6 additions & 2 deletions met-api/src/met_api/services/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from met_api.models.membership import Membership as MembershipModel
from met_api.models.staff_user import StaffUser as StaffUserModel
from met_api.utils.user_context import UserContext, user_context
from met_api.utils.enums import MembershipStatus


# pylint: disable=unused-argument
Expand Down Expand Up @@ -43,6 +44,9 @@ def _has_team_membership(kwargs, user_from_context, team_permitted_roles) -> boo
if not user:
return False

memberships = MembershipModel.find_by_engagement_and_user_id(eng_id, user.id)
membership = MembershipModel.find_by_engagement_and_user_id(eng_id, user.id, status=MembershipStatus.ACTIVE.value)

return any(membership.type.name in team_permitted_roles for membership in memberships)
if not membership:
return False

return membership.type.name in team_permitted_roles
18 changes: 10 additions & 8 deletions met-api/src/met_api/services/comment_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from met_api.services.document_generation_service import DocumentGenerationService
from met_api.utils.roles import Role
from met_api.utils.token_info import TokenInfo
from met_api.utils.enums import MembershipStatus


class CommentService:
Expand Down Expand Up @@ -63,15 +64,16 @@ def can_view_unapproved_comments(survey_id: int) -> bool:
return False

user = StaffUserModel.get_user_by_external_id(user_id)
if not user:
return False

memberships = MembershipModel.find_by_engagement_and_user_id(engagement.engagement_id, user.id)

# only Team member can view unapproved comments.Reviewer cant see unapproved comments.
has_team_member = any(membership.type == MembershipType.TEAM_MEMBER for membership in memberships)
if user:
membership = MembershipModel.find_by_engagement_and_user_id(
engagement.engagement_id,
user.id,
status=MembershipStatus.ACTIVE.value
)
if membership:
return membership.type == MembershipType.TEAM_MEMBER

return has_team_member
return False

@classmethod
def get_comments_paginated(cls, survey_id, pagination_options: PaginationOptions, search_text=''):
Expand Down
70 changes: 66 additions & 4 deletions met-api/src/met_api/services/membership_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Service for membership."""
from http import HTTPStatus
from datetime import datetime

from met_api.constants.membership_type import MembershipType
from met_api.models import StaffUser as StaffUserModel
Expand Down Expand Up @@ -30,7 +31,7 @@ def create_membership(engagement_id, request_json: dict):
StaffUserService.attach_groups([user_details])
# this makes sure duplicate membership doesnt happen.
# Can remove when user can have multiple roles with in same engagement.
MembershipService._validate_member(engagement_id, user_details)
# MembershipService._validate_member(engagement_id, user_details)
group_name, membership_type = MembershipService._get_membership_details(user_details)
MembershipService._add_user_group(user_details, group_name)
membership = MembershipService._create_membership_model(engagement_id, user_details, membership_type)
Expand Down Expand Up @@ -83,7 +84,11 @@ def _validate_member(engagement_id, user_details):
error='This user is already a Superuser.',
status_code=HTTPStatus.CONFLICT.value)

existing_membership = MembershipModel.find_by_engagement_and_user_id(engagement_id, user_details.get('id'))
existing_membership = MembershipModel.find_by_engagement_and_user_id(
engagement_id,
user_details.get('id'),
status=MembershipStatus.ACTIVE.value
)
if existing_membership:
raise BusinessException(
error=f'This {user_details.get("main_group", "user")} is already assigned to this engagement.',
Expand Down Expand Up @@ -112,15 +117,72 @@ def get_memberships(engagement_id):
"""Get memberships by engagement id."""
# get user to be added from request json

memberships = MembershipModel.find_by_engagement(engagement_id)
memberships = MembershipModel.find_by_engagement(engagement_id, status=MembershipStatus.ACTIVE.value)
return memberships

@staticmethod
def get_assigned_engagements(user_id):
"""Get memberships by user id."""
return MembershipModel.find_by_user_id(user_id)
return MembershipModel.find_by_user_id(user_id, status=MembershipStatus.ACTIVE.value)

@staticmethod
def get_engagements_by_user(user_id):
"""Get engagements by user id."""
return EngagementModel.get_assigned_engagements(user_id)

@staticmethod
def update_membership_status(engagement_id: int, user_id: int, action: str):
"""Update membership status."""
membership = MembershipModel.find_by_engagement_and_user_id(engagement_id, user_id)

if membership.engagement_id != int(engagement_id):
raise ValueError('Membership does not belong to this engagement.')

if not membership:
raise ValueError('Invalid Membership.')

if action == 'revoke':
return MembershipService.revoke_membership(membership)

if action == 'reinstate':
return MembershipService.reinstate_membership(membership)

raise ValueError('Invalid action.')

@staticmethod
def revoke_membership(membership: MembershipModel):
"""Revoke membership."""
if membership.status == MembershipStatus.REVOKED.value:
raise ValueError('Membership already revoked.')

new_membership_details = {
'status': MembershipStatus.REVOKED.value,
'type': membership.type,
'revoked_date': datetime.utcnow(),
}
new_membership = MembershipModel.create_new_version(
membership.engagement_id,
membership.user_id,
new_membership_details
)

return new_membership

@staticmethod
def reinstate_membership(membership: MembershipModel):
"""Reinstate membership."""
if membership.status == MembershipStatus.ACTIVE.value:
raise ValueError('Membership already active.')

new_membership_details = {
'engagement_id': membership.engagement_id,
'user_id': membership.user_id,
'status': MembershipStatus.ACTIVE.value,
'type': membership.type,
}
new_membership = MembershipModel.create_new_version(
membership.engagement_id,
membership.user_id,
new_membership_details
)
return new_membership
Loading

0 comments on commit 0bcd106

Please sign in to comment.