From 0bcd1068811d391a668debbf819ac097907c3820 Mon Sep 17 00:00:00 2001 From: jadmsaadaot <91914654+jadmsaadaot@users.noreply.github.com> Date: Fri, 11 Aug 2023 12:53:08 -0700 Subject: [PATCH] Implement membership revoke and versioning (#1994) --- .../31041fb90d53_membership_versioning.py | 37 ++++++ .../e2d5d38220d9_add_revoked_membership.py | 46 +++++++ met-api/src/met_api/models/membership.py | 79 +++++++++--- met-api/src/met_api/resources/__init__.py | 2 +- .../met_api/resources/engagement_members.py | 18 +++ met-api/src/met_api/schemas/memberships.py | 3 +- met-api/src/met_api/services/authorization.py | 8 +- .../src/met_api/services/comment_service.py | 18 +-- .../met_api/services/membership_service.py | 70 ++++++++++- met-api/src/met_api/utils/enums.py | 1 + .../unit/api/test_engagement_membership.py | 115 +++++++++++++++++- met-api/tests/unit/api/test_survey.py | 4 +- met-api/tests/utilities/factory_utils.py | 2 + met-web/src/apiManager/endpoints/index.ts | 1 + met-web/src/components/common/Table/types.ts | 2 +- .../form/EngagementFormTabs/FormTabs.tsx | 2 +- .../EngagementFormTabs/TeamMemberListing.tsx | 30 ----- .../UserManagement/ActionsDropDown.tsx | 84 +++++++++++++ .../AddTeamMemberModal.tsx | 4 +- .../EngagementUserManagement.tsx | 6 +- .../UserManagement/TeamMemberListing.tsx | 67 ++++++++++ .../form/EngagementFormTabs/index.tsx | 2 +- met-web/src/models/engagementTeamMember.ts | 22 +++- .../src/services/membershipService/index.tsx | 32 ++++- 24 files changed, 573 insertions(+), 82 deletions(-) create mode 100644 met-api/migrations/versions/31041fb90d53_membership_versioning.py create mode 100644 met-api/migrations/versions/e2d5d38220d9_add_revoked_membership.py delete mode 100644 met-web/src/components/engagement/form/EngagementFormTabs/TeamMemberListing.tsx create mode 100644 met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/ActionsDropDown.tsx rename met-web/src/components/engagement/form/EngagementFormTabs/{ => UserManagement}/AddTeamMemberModal.tsx (98%) rename met-web/src/components/engagement/form/EngagementFormTabs/{ => UserManagement}/EngagementUserManagement.tsx (94%) create mode 100644 met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/TeamMemberListing.tsx diff --git a/met-api/migrations/versions/31041fb90d53_membership_versioning.py b/met-api/migrations/versions/31041fb90d53_membership_versioning.py new file mode 100644 index 000000000..c2e206ec8 --- /dev/null +++ b/met-api/migrations/versions/31041fb90d53_membership_versioning.py @@ -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 ### diff --git a/met-api/migrations/versions/e2d5d38220d9_add_revoked_membership.py b/met-api/migrations/versions/e2d5d38220d9_add_revoked_membership.py new file mode 100644 index 000000000..63b769715 --- /dev/null +++ b/met-api/migrations/versions/e2d5d38220d9_add_revoked_membership.py @@ -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 ### diff --git a/met-api/src/met_api/models/membership.py b/met-api/src/met_api/models/membership.py index ce27d0768..47dd3aee9 100644 --- a/met-api/src/met_api/models/membership.py +++ b/met-api/src/met_api/models/membership.py @@ -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 @@ -24,6 +23,7 @@ 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) @@ -31,38 +31,79 @@ class Membership(BaseModel): 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 diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index dcbe3d883..e17b14771 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -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//documents') API.add_namespace(ENGAGEMENT_MEMBERS_API, path='/engagements//members') +API.add_namespace(WIDGET_DOCUMENTS_API, path='/widgets//documents') API.add_namespace(WIDGET_EVENTS_API, path='/widgets//events') API.add_namespace(WIDGET_SUBSCRIBE_API, path='/widgets//subscribe') API.add_namespace(WIDGET_MAPS_API, path='/widgets//maps') diff --git a/met-api/src/met_api/resources/engagement_members.py b/met-api/src/met_api/resources/engagement_members.py index b7a9d8b8b..025f2d0f4 100644 --- a/met-api/src/met_api/resources/engagement_members.py +++ b/met-api/src/met_api/resources/engagement_members.py @@ -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('//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 diff --git a/met-api/src/met_api/schemas/memberships.py b/met-api/src/met_api/schemas/memberships.py index 100a4a73e..af2202468 100644 --- a/met-api/src/met_api/schemas/memberships.py +++ b/met-api/src/met_api/schemas/memberships.py @@ -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) diff --git a/met-api/src/met_api/services/authorization.py b/met-api/src/met_api/services/authorization.py index 31144e870..b4b4316d8 100644 --- a/met-api/src/met_api/services/authorization.py +++ b/met-api/src/met_api/services/authorization.py @@ -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 @@ -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 diff --git a/met-api/src/met_api/services/comment_service.py b/met-api/src/met_api/services/comment_service.py index 2422543cf..fd20c9603 100644 --- a/met-api/src/met_api/services/comment_service.py +++ b/met-api/src/met_api/services/comment_service.py @@ -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: @@ -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=''): diff --git a/met-api/src/met_api/services/membership_service.py b/met-api/src/met_api/services/membership_service.py index 8a06c8a1b..2a8629dcf 100644 --- a/met-api/src/met_api/services/membership_service.py +++ b/met-api/src/met_api/services/membership_service.py @@ -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 @@ -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) @@ -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.', @@ -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 diff --git a/met-api/src/met_api/utils/enums.py b/met-api/src/met_api/utils/enums.py index 19ab8e55e..ec46934b0 100644 --- a/met-api/src/met_api/utils/enums.py +++ b/met-api/src/met_api/utils/enums.py @@ -35,6 +35,7 @@ class MembershipStatus(Enum): ACTIVE = 1 INACTIVE = 2 + REVOKED = 3 class GeneratedDocumentTypes(IntEnum): diff --git a/met-api/tests/unit/api/test_engagement_membership.py b/met-api/tests/unit/api/test_engagement_membership.py index 15ee6baa5..5c4a0c392 100644 --- a/met-api/tests/unit/api/test_engagement_membership.py +++ b/met-api/tests/unit/api/test_engagement_membership.py @@ -10,7 +10,8 @@ from met_api.constants.membership_type import MembershipType from met_api.utils.enums import ContentType, KeycloakGroupName, MembershipStatus from tests.utilities.factory_scenarios import TestJwtClaims -from tests.utilities.factory_utils import factory_auth_header, factory_engagement_model, factory_staff_user_model +from tests.utilities.factory_utils import ( + factory_auth_header, factory_engagement_model, factory_membership_model, factory_staff_user_model) memberships_url = '/api/engagements/{}/members' @@ -45,7 +46,7 @@ def test_create_engagement_membership_team_member(mocker, client, jwt, session): assert rv.json.get('engagement_id') == engagement.id assert rv.json.get('user_id') == staff_user.id assert rv.json.get('type') == MembershipType.TEAM_MEMBER - assert rv.json.get('status') == str(MembershipStatus.ACTIVE.value) + assert rv.json.get('status') == MembershipStatus.ACTIVE.value mock_add_user_to_group_keycloak.assert_called() mock_get_users_groups_keycloak.assert_called() @@ -79,7 +80,7 @@ def test_create_engagement_membership_reviewer(mocker, client, jwt, session): assert rv.json.get('engagement_id') == engagement.id assert rv.json.get('user_id') == staff_user.id assert rv.json.get('type') == MembershipType.REVIEWER - assert rv.json.get('status') == str(MembershipStatus.ACTIVE.value) + assert rv.json.get('status') == MembershipStatus.ACTIVE.value mock_add_user_to_group_keycloak.assert_called() mock_get_users_groups_keycloak.assert_called() @@ -98,3 +99,111 @@ def test_create_engagement_membership_unauthorized(client, jwt, session): content_type=ContentType.JSON.value ) assert rv.status_code == HTTPStatus.UNAUTHORIZED + + +def test_revoke_membership(client, jwt, session): + """Test that a membership can be revoked.""" + engagement = factory_engagement_model() + staff_user = factory_staff_user_model() + membership = factory_membership_model(user_id=staff_user.id, engagement_id=engagement.id) + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) + data = { + 'action': 'revoke' + } + + rv = client.patch( + f'/api/engagements/{engagement.id}/members/{membership.user_id}/status', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + + assert rv.status_code == HTTPStatus.OK + + +def test_reinstate_membership(client, jwt, session): + """Test that a membership can be reinstated.""" + engagement = factory_engagement_model() + staff_user = factory_staff_user_model() + membership = factory_membership_model( + user_id=staff_user.id, + engagement_id=engagement.id, + status=MembershipStatus.REVOKED.value + ) + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) + data = { + 'action': 'reinstate' + } + + rv = client.patch( + f'/api/engagements/{engagement.id}/members/{membership.user_id}/status', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + + assert rv.status_code == HTTPStatus.OK + + +def test_update_membership_status_invalid_action(client, jwt, session): + """Test that an invalid action cannot be performed on a membership.""" + engagement = factory_engagement_model() + staff_user = factory_staff_user_model() + membership = factory_membership_model(user_id=staff_user.id, engagement_id=engagement.id) + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) + data = { + 'action': 'invalid' + } + + rv = client.patch( + f'/api/engagements/{engagement.id}/members/{membership.user_id}/status', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +def test_revoke_already_revoked_membership(client, jwt, session): + """Test that an already revoked membership cannot be revoked again.""" + engagement = factory_engagement_model() + staff_user = factory_staff_user_model() + membership = factory_membership_model( + user_id=staff_user.id, + engagement_id=engagement.id, + status=MembershipStatus.REVOKED.value + ) + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) + data = { + 'action': 'revoke' + } + + rv = client.patch( + f'/api/engagements/{engagement.id}/members/{membership.user_id}/status', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +def reinstate_already_active_membership(client, jwt, session): + """Test that an already active membership cannot be activated again.""" + engagement = factory_engagement_model() + staff_user = factory_staff_user_model() + membership = factory_membership_model(user_id=staff_user.id, engagement_id=engagement.id) + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) + data = { + 'action': 'reinstate' + } + + rv = client.patch( + f'/api/engagements/{engagement.id}/members/{membership.user_id}/status', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + + assert rv.status_code == HTTPStatus.BAD_REQUEST diff --git a/met-api/tests/unit/api/test_survey.py b/met-api/tests/unit/api/test_survey.py index 3b4e5e42f..f5a7cf3a7 100644 --- a/met-api/tests/unit/api/test_survey.py +++ b/met-api/tests/unit/api/test_survey.py @@ -192,8 +192,8 @@ def test_get_survey_for_reviewer(client, jwt, session): # pylint:disable=unused # Deactivate membership membership_model: MembershipModel = MembershipModel.find_by_engagement_and_user_id(eng.id, user.id) - membership_model[0].status = MembershipStatus.INACTIVE.value - membership_model[0].commit() + membership_model.status = MembershipStatus.INACTIVE.value + membership_model.commit() rv = client.get(f'{surveys_url}{survey1.id}', headers=headers, content_type=ContentType.JSON.value) diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index b837f4301..8c72e5fbc 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -180,6 +180,8 @@ def factory_membership_model(user_id, engagement_id, member_type='TEAM_MEMBER', membership = MembershipModel(user_id=user_id, engagement_id=engagement_id, type=member_type, + is_latest=True, + version=1, status=status) membership.created_by_id = user_id diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 31cb26f15..e1b587d96 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -108,6 +108,7 @@ const Endpoints = { GET_LIST: `${AppConfig.apiUrl}/engagements/engagement_id/members`, GET_LIST_BY_USER: `${AppConfig.apiUrl}/engagements/all/members/user_id`, CREATE: `${AppConfig.apiUrl}/engagements/engagement_id/members`, + UPDATE_STATUS: `${AppConfig.apiUrl}/engagements/engagement_id/members/user_id/status`, }, Events: { CREATE: `${AppConfig.apiUrl}/widgets/widget_id/events`, diff --git a/met-web/src/components/common/Table/types.ts b/met-web/src/components/common/Table/types.ts index a7bf39bbb..26d99bae5 100644 --- a/met-web/src/components/common/Table/types.ts +++ b/met-web/src/components/common/Table/types.ts @@ -7,7 +7,7 @@ export interface HeadCell { hideSorticon?: boolean; numeric: boolean; allowSort: boolean; - renderCell?: (row: T) => string | number | JSX.Element; + renderCell?: (row: T) => string | number | JSX.Element | null; customStyle?: React.CSSProperties; align?: 'right' | 'left' | 'inherit' | 'center' | 'justify'; } diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/FormTabs.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/FormTabs.tsx index 252d266a9..519621754 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/FormTabs.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/FormTabs.tsx @@ -4,7 +4,7 @@ import TabContext from '@mui/lab/TabContext'; import EngagementForm from './EngagementForm'; import { MetTab, MetTabList, MetTabPanel } from '../StyledTabComponents'; import { EngagementFormTabValues, ENGAGEMENT_FORM_TABS } from './constants'; -import EngagementUserManagement from './EngagementUserManagement'; +import EngagementUserManagement from './UserManagement/EngagementUserManagement'; import EngagementLinks from './Links'; import EngagementSettings from './Settings'; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/TeamMemberListing.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/TeamMemberListing.tsx deleted file mode 100644 index 271e12581..000000000 --- a/met-web/src/components/engagement/form/EngagementFormTabs/TeamMemberListing.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React, { useContext } from 'react'; -import { HeadCell } from 'components/common/Table/types'; -import { Link } from 'react-router-dom'; -import { Link as MuiLink } from '@mui/material'; -import MetTable from 'components/common/Table'; -import { EngagementTabsContext } from './EngagementTabsContext'; -import { EngagementTeamMember } from 'models/engagementTeamMember'; - -const TeamMemberListing = () => { - const { teamMembers, teamMembersLoading } = useContext(EngagementTabsContext); - - const headCells: HeadCell[] = [ - { - key: 'user', - numeric: false, - disablePadding: true, - label: 'Team Members', - allowSort: false, - renderCell: (row: EngagementTeamMember) => ( - - {row.user?.last_name + ', ' + row.user?.first_name} - - ), - }, - ]; - - return ; -}; - -export default TeamMemberListing; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/ActionsDropDown.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/ActionsDropDown.tsx new file mode 100644 index 000000000..a60f0c162 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/ActionsDropDown.tsx @@ -0,0 +1,84 @@ +import React, { useMemo } from 'react'; +import { CircularProgress, MenuItem, Select } from '@mui/material'; +import { Palette } from 'styles/Theme'; +import { ENGAGEMENT_MEMBERSHIP_STATUS, EngagementTeamMember } from 'models/engagementTeamMember'; +import { reinstateMembership, revokeMembership } from 'services/membershipService'; +import { EngagementTabsContext } from '../EngagementTabsContext'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; + +interface ActionDropDownItem { + value: number; + label: string; + action?: () => void; + condition?: boolean; +} +export const ActionsDropDown = ({ membership }: { membership: EngagementTeamMember }) => { + const [loading, setLoading] = React.useState(false); + const { loadTeamMembers } = React.useContext(EngagementTabsContext); + const dispatch = useAppDispatch(); + + const handleRevoke = async () => { + try { + setLoading(true); + await revokeMembership(membership.engagement_id, membership.user_id); + loadTeamMembers(); + setLoading(false); + } catch (error) { + setLoading(false); + dispatch(openNotification({ text: 'Failed to revoke membership', severity: 'error' })); + } + }; + + const handleReinstate = async () => { + try { + setLoading(true); + await reinstateMembership(membership.engagement_id, membership.user_id); + loadTeamMembers(); + setLoading(false); + } catch (error) { + setLoading(false); + dispatch(openNotification({ text: 'Failed to reinstate membership', severity: 'error' })); + } + }; + const ITEMS: ActionDropDownItem[] = useMemo( + () => [ + { + value: 1, + label: 'Revoke', + action: handleRevoke, + condition: membership.status !== ENGAGEMENT_MEMBERSHIP_STATUS.Revoked, + }, + { + value: 2, + label: 'Reinstate', + action: handleReinstate, + condition: membership.status !== ENGAGEMENT_MEMBERSHIP_STATUS.Active, + }, + ], + [membership.id, membership.status], + ); + + if (loading) { + return ; + } + + return ( + + ); +}; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/AddTeamMemberModal.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/AddTeamMemberModal.tsx similarity index 98% rename from met-web/src/components/engagement/form/EngagementFormTabs/AddTeamMemberModal.tsx rename to met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/AddTeamMemberModal.tsx index 5cba14632..4d5d1d552 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AddTeamMemberModal.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/AddTeamMemberModal.tsx @@ -9,8 +9,8 @@ import * as yup from 'yup'; import { getUserList } from 'services/userService/api'; import { openNotification } from 'services/notificationService/notificationSlice'; import { useAppDispatch } from 'hooks'; -import { EngagementTabsContext } from './EngagementTabsContext'; -import { ActionContext } from '../ActionContext'; +import { EngagementTabsContext } from '../EngagementTabsContext'; +import { ActionContext } from '../../ActionContext'; import { addTeamMemberToEngagement } from 'services/membershipService'; import { debounce } from 'lodash'; import axios, { AxiosError } from 'axios'; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementUserManagement.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/EngagementUserManagement.tsx similarity index 94% rename from met-web/src/components/engagement/form/EngagementFormTabs/EngagementUserManagement.tsx rename to met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/EngagementUserManagement.tsx index e099fb916..471915fa1 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementUserManagement.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/EngagementUserManagement.tsx @@ -1,10 +1,10 @@ import React, { useContext } from 'react'; import { Grid } from '@mui/material'; import { MetLabel, MetPaper, PrimaryButton, MetParagraph } from 'components/common'; -import { ActionContext } from '../ActionContext'; -import TeamMemberListing from './TeamMemberListing'; -import { EngagementTabsContext } from './EngagementTabsContext'; +import { ActionContext } from '../../ActionContext'; +import { EngagementTabsContext } from '../EngagementTabsContext'; import { formatDate } from 'components/common/dateHelper'; +import TeamMemberListing from './TeamMemberListing'; const EngagementUserManagement = () => { const { savedEngagement } = useContext(ActionContext); diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/TeamMemberListing.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/TeamMemberListing.tsx new file mode 100644 index 000000000..ab6b93521 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementFormTabs/UserManagement/TeamMemberListing.tsx @@ -0,0 +1,67 @@ +import React, { useContext } from 'react'; +import { HeadCell } from 'components/common/Table/types'; +import { Link } from 'react-router-dom'; +import { Link as MuiLink } from '@mui/material'; +import MetTable from 'components/common/Table'; +import { EngagementTabsContext } from '../EngagementTabsContext'; +import { ENGAGEMENT_MEMBERSHIP_STATUS_NAME, EngagementTeamMember } from 'models/engagementTeamMember'; +import { formatDate } from 'components/common/dateHelper'; +import { ActionsDropDown } from './ActionsDropDown'; + +const TeamMemberListing = () => { + const { teamMembers, teamMembersLoading } = useContext(EngagementTabsContext); + + const headCells: HeadCell[] = [ + { + key: 'user', + numeric: false, + disablePadding: true, + label: 'Team Members', + allowSort: false, + renderCell: (row: EngagementTeamMember) => ( + + {row.user?.last_name + ', ' + row.user?.first_name} + + ), + }, + { + key: 'status', + numeric: false, + disablePadding: true, + label: 'Status', + allowSort: false, + renderCell: (row: EngagementTeamMember) => ENGAGEMENT_MEMBERSHIP_STATUS_NAME[row.status], + }, + { + key: 'created_date', + numeric: false, + disablePadding: true, + label: 'Date Added', + allowSort: false, + renderCell: (row: EngagementTeamMember) => formatDate(row.created_date), + }, + { + key: 'revoked_date', + numeric: false, + disablePadding: true, + label: 'Date Revoked', + allowSort: false, + renderCell: (row: EngagementTeamMember) => (row.revoked_date ? formatDate(row.revoked_date) : null), + }, + { + key: 'id', + numeric: false, + disablePadding: true, + label: 'Actions', + allowSort: false, + customStyle: { width: '170px' }, + renderCell: (row: EngagementTeamMember) => { + return ; + }, + }, + ]; + + return ; +}; + +export default TeamMemberListing; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/index.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/index.tsx index 8d6de7c44..7df03d41a 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/index.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import FormTabs from './FormTabs'; import { EngagementTabsContextProvider } from './EngagementTabsContext'; -import { AddTeamMemberModal } from './AddTeamMemberModal'; +import { AddTeamMemberModal } from './UserManagement/AddTeamMemberModal'; const EngagementFormTabs = () => { return ( diff --git a/met-web/src/models/engagementTeamMember.ts b/met-web/src/models/engagementTeamMember.ts index 21e48794c..42b746436 100644 --- a/met-web/src/models/engagementTeamMember.ts +++ b/met-web/src/models/engagementTeamMember.ts @@ -6,20 +6,38 @@ export type EngagementMembershipType = 1; export const ENGAGEMENT_MEMBERSHIP_TYPE: { [x: string]: EngagementMembershipType } = { TEAM_MEMBER: 1, }; + +export type MembershipStatus = 1 | 2 | 3; + export interface EngagementTeamMember { id: number; - status: string; + status: MembershipStatus; created_date: string; + revoked_date: string | null; engagement_id: number; user_id: number; user: User; type: EngagementMembershipType; } +export type MembershipStatusName = 'Active' | 'Inactive' | 'Revoked'; + +export const ENGAGEMENT_MEMBERSHIP_STATUS: { [x in MembershipStatusName]: MembershipStatus } = { + Active: 1, + Inactive: 2, + Revoked: 3, +}; + +export const ENGAGEMENT_MEMBERSHIP_STATUS_NAME: { [x in MembershipStatus]: MembershipStatusName } = { + 1: 'Active', + 2: 'Inactive', + 3: 'Revoked', +}; export const initialDefaultTeamMember: EngagementTeamMember = { id: 0, - status: '', + status: 1, created_date: Date(), + revoked_date: null, engagement_id: 0, user_id: 0, type: ENGAGEMENT_MEMBERSHIP_TYPE.TEAM_MEMBER, diff --git a/met-web/src/services/membershipService/index.tsx b/met-web/src/services/membershipService/index.tsx index a6bd20445..f99ab3931 100644 --- a/met-web/src/services/membershipService/index.tsx +++ b/met-web/src/services/membershipService/index.tsx @@ -1,6 +1,6 @@ import http from 'apiManager/httpRequestHandler'; import Endpoints from 'apiManager/endpoints'; -import { replaceUrl } from 'helper'; +import { replaceAllInURL, replaceUrl } from 'helper'; import { EngagementTeamMember } from 'models/engagementTeamMember'; interface GetTeamMembersParams { @@ -38,3 +38,33 @@ export const getMembershipsByUser = async ({ const responseData = await http.GetRequest(url); return responseData.data ?? []; }; + +export const revokeMembership = async (engagement_id: number, user_id: number): Promise => { + const url = replaceAllInURL({ + URL: Endpoints.EngagementTeamMembers.UPDATE_STATUS, + params: { + engagement_id: String(engagement_id), + user_id: String(user_id), + }, + }); + const body = { + action: 'revoke', + }; + const responseData = await http.PatchRequest(url, body); + return responseData.data; +}; + +export const reinstateMembership = async (engagement_id: number, user_id: number): Promise => { + const url = replaceAllInURL({ + URL: Endpoints.EngagementTeamMembers.UPDATE_STATUS, + params: { + engagement_id: String(engagement_id), + user_id: String(user_id), + }, + }); + const body = { + action: 'reinstate', + }; + const responseData = await http.PatchRequest(url, body); + return responseData.data; +};