diff --git a/apps/notifications/membership_events.py b/apps/notifications/membership_events.py new file mode 100644 index 0000000..a237a07 --- /dev/null +++ b/apps/notifications/membership_events.py @@ -0,0 +1,271 @@ +from apps.notifications.services import RedisNotificationStore + + +def _actor_name(actor) -> str: + full_name = getattr(actor, "full_name", "").strip() + if full_name and full_name != "Anonymous": + return full_name + return getattr(actor, "mobile", str(actor)) + + +def _role_label(role_enum, role_value: str) -> str: + try: + return str(role_enum(role_value).label).lower() + except Exception: + pass + with_labels = getattr(role_enum, "labels", None) + if isinstance(with_labels, dict): + return str(with_labels.get(role_value, role_value)).lower() + return str(role_value).replace("_", " ").lower() + + +def _should_skip(actor, recipient) -> bool: + return not recipient or str(actor.id) == str(recipient.id) + + +def _notify_user(recipient, payload: dict) -> None: + RedisNotificationStore.add(str(recipient.id), payload) + + +def _workspace_action_url(workspace) -> str: + return f"/workspaces/{workspace.id}" + + +def _project_action_url(project) -> str: + return "/projects" + + +def notify_workspace_membership_added(*, actor, recipient, workspace, role: str) -> None: + if _should_skip(actor, recipient): + return + + actor_display = _actor_name(actor) + role_label = _role_label(recipient.workspace_memberships.model.Role, role) + _notify_user( + recipient, + { + "type": "workspace_membership_added", + "title": "Added to workspace", + "message": ( + f"{actor_display} added you to {workspace.name} as {role_label}." + ), + "level": "info", + "action_url": _workspace_action_url(workspace), + "entity_type": "workspace", + "entity_id": str(workspace.id), + "meta": { + "workspace_id": str(workspace.id), + "workspace_name": workspace.name, + "actor_id": str(actor.id), + "actor_name": actor_display, + "new_role": role, + }, + }, + ) + + +def notify_workspace_membership_role_changed( + *, actor, recipient, workspace, previous_role: str, new_role: str +) -> None: + if _should_skip(actor, recipient) or previous_role == new_role: + return + + actor_display = _actor_name(actor) + previous_role_label = _role_label(recipient.workspace_memberships.model.Role, previous_role) + new_role_label = _role_label(recipient.workspace_memberships.model.Role, new_role) + _notify_user( + recipient, + { + "type": "workspace_membership_role_changed", + "title": "Workspace role changed", + "message": ( + f"{actor_display} changed your role in {workspace.name} " + f"from {previous_role_label} to {new_role_label}." + ), + "level": "info", + "action_url": _workspace_action_url(workspace), + "entity_type": "workspace", + "entity_id": str(workspace.id), + "meta": { + "workspace_id": str(workspace.id), + "workspace_name": workspace.name, + "actor_id": str(actor.id), + "actor_name": actor_display, + "previous_role": previous_role, + "new_role": new_role, + }, + }, + ) + + +def notify_workspace_membership_deactivated(*, actor, recipient, workspace, role: str) -> None: + if _should_skip(actor, recipient): + return + + actor_display = _actor_name(actor) + _notify_user( + recipient, + { + "type": "workspace_membership_deactivated", + "title": "Workspace access deactivated", + "message": f"{actor_display} deactivated your access to {workspace.name}.", + "level": "warning", + "action_url": _workspace_action_url(workspace), + "entity_type": "workspace", + "entity_id": str(workspace.id), + "meta": { + "workspace_id": str(workspace.id), + "workspace_name": workspace.name, + "actor_id": str(actor.id), + "actor_name": actor_display, + "previous_role": role, + }, + }, + ) + + +def notify_workspace_membership_removed(*, actor, recipient, workspace, role: str) -> None: + if _should_skip(actor, recipient): + return + + actor_display = _actor_name(actor) + _notify_user( + recipient, + { + "type": "workspace_membership_removed", + "title": "Removed from workspace", + "message": f"{actor_display} removed you from {workspace.name}.", + "level": "warning", + "action_url": _workspace_action_url(workspace), + "entity_type": "workspace", + "entity_id": str(workspace.id), + "meta": { + "workspace_id": str(workspace.id), + "workspace_name": workspace.name, + "actor_id": str(actor.id), + "actor_name": actor_display, + "previous_role": role, + }, + }, + ) + + +def notify_project_membership_added(*, actor, recipient, project, role: str) -> None: + if _should_skip(actor, recipient): + return + + actor_display = _actor_name(actor) + role_label = _role_label(recipient.project_memberships.model.Role, role) + _notify_user( + recipient, + { + "type": "project_membership_added", + "title": "Added to project", + "message": f"{actor_display} added you to {project.name} as {role_label}.", + "level": "info", + "action_url": _project_action_url(project), + "entity_type": "project", + "entity_id": str(project.id), + "meta": { + "workspace_id": str(project.workspace_id), + "workspace_name": project.workspace.name, + "project_id": str(project.id), + "project_name": project.name, + "actor_id": str(actor.id), + "actor_name": actor_display, + "new_role": role, + }, + }, + ) + + +def notify_project_membership_role_changed( + *, actor, recipient, project, previous_role: str, new_role: str +) -> None: + if _should_skip(actor, recipient) or previous_role == new_role: + return + + actor_display = _actor_name(actor) + previous_role_label = _role_label(recipient.project_memberships.model.Role, previous_role) + new_role_label = _role_label(recipient.project_memberships.model.Role, new_role) + _notify_user( + recipient, + { + "type": "project_membership_role_changed", + "title": "Project role changed", + "message": ( + f"{actor_display} changed your role in {project.name} " + f"from {previous_role_label} to {new_role_label}." + ), + "level": "info", + "action_url": _project_action_url(project), + "entity_type": "project", + "entity_id": str(project.id), + "meta": { + "workspace_id": str(project.workspace_id), + "workspace_name": project.workspace.name, + "project_id": str(project.id), + "project_name": project.name, + "actor_id": str(actor.id), + "actor_name": actor_display, + "previous_role": previous_role, + "new_role": new_role, + }, + }, + ) + + +def notify_project_membership_deactivated(*, actor, recipient, project, role: str) -> None: + if _should_skip(actor, recipient): + return + + actor_display = _actor_name(actor) + _notify_user( + recipient, + { + "type": "project_membership_deactivated", + "title": "Project access deactivated", + "message": f"{actor_display} deactivated your access to {project.name}.", + "level": "warning", + "action_url": _project_action_url(project), + "entity_type": "project", + "entity_id": str(project.id), + "meta": { + "workspace_id": str(project.workspace_id), + "workspace_name": project.workspace.name, + "project_id": str(project.id), + "project_name": project.name, + "actor_id": str(actor.id), + "actor_name": actor_display, + "previous_role": role, + }, + }, + ) + + +def notify_project_membership_removed(*, actor, recipient, project, role: str) -> None: + if _should_skip(actor, recipient): + return + + actor_display = _actor_name(actor) + _notify_user( + recipient, + { + "type": "project_membership_removed", + "title": "Removed from project", + "message": f"{actor_display} removed you from {project.name}.", + "level": "warning", + "action_url": _project_action_url(project), + "entity_type": "project", + "entity_id": str(project.id), + "meta": { + "workspace_id": str(project.workspace_id), + "workspace_name": project.workspace.name, + "project_id": str(project.id), + "project_name": project.name, + "actor_id": str(actor.id), + "actor_name": actor_display, + "previous_role": role, + }, + }, + ) diff --git a/apps/notifications/tests/test_membership_events.py b/apps/notifications/tests/test_membership_events.py new file mode 100644 index 0000000..dacbd60 --- /dev/null +++ b/apps/notifications/tests/test_membership_events.py @@ -0,0 +1,297 @@ +import pytest +from rest_framework.test import APIClient + +from apps.notifications import services +from apps.notifications.services import RedisNotificationStore +from apps.notifications.tests.test_services import FakeRedis +from apps.projects.models import Project, ProjectMembership +from apps.users.models import User +from apps.workspaces.models import Workspace, WorkspaceMembership + + +@pytest.fixture() +def fake_redis(monkeypatch): + redis = FakeRedis() + monkeypatch.setattr(services, "redis_client", redis) + return redis + + +@pytest.fixture() +def api_client(): + return APIClient() + + +def _create_user(index: int) -> User: + return User.objects.create_user( + mobile=f"091200000{index:02d}", + password="secret123", + first_name=f"User{index}", + ) + + +def _notifications_for(user): + notifications, _ = RedisNotificationStore.list( + str(user.id), + paginate=False, + ) + return notifications + + +@pytest.fixture() +def owner(db): + return _create_user(1) + + +@pytest.fixture() +def member(db): + return _create_user(2) + + +@pytest.fixture() +def another_member(db): + return _create_user(3) + + +@pytest.fixture() +def third_member(db): + return _create_user(4) + + +@pytest.fixture() +def fourth_member(db): + return _create_user(5) + + +def test_workspace_create_notifies_initial_members_not_owner( + fake_redis, api_client, owner, member +): + api_client.force_authenticate(user=owner) + + response = api_client.post( + "/api/workspaces/", + { + "name": "Ops", + "description": "Workspace", + "members": [ + {"user_id": str(member.id), "role": WorkspaceMembership.Role.ADMIN} + ], + }, + format="json", + ) + + assert response.status_code == 201 + owner_notifications = _notifications_for(owner) + member_notifications = _notifications_for(member) + + assert owner_notifications == [] + assert len(member_notifications) == 1 + assert member_notifications[0]["type"] == "workspace_membership_added" + assert member_notifications[0]["meta"]["workspace_name"] == "Ops" + assert member_notifications[0]["meta"]["new_role"] == WorkspaceMembership.Role.ADMIN + + +def test_workspace_membership_crud_emits_add_role_change_deactivate_and_remove( + fake_redis, api_client, owner, member +): + workspace = Workspace.objects.create(name="Design", description="", owner=owner) + api_client.force_authenticate(user=owner) + + create_response = api_client.post( + "/api/workspace-memberships/", + { + "workspace": str(workspace.id), + "user": str(member.id), + "role": WorkspaceMembership.Role.MEMBER, + "is_active": True, + }, + format="json", + ) + assert create_response.status_code == 201 + + membership_id = create_response.data["id"] + notifications = _notifications_for(member) + assert [item["type"] for item in notifications] == ["workspace_membership_added"] + + role_response = api_client.patch( + f"/api/workspace-memberships/{membership_id}/", + {"role": WorkspaceMembership.Role.ADMIN}, + format="json", + ) + assert role_response.status_code == 200 + + deactivate_response = api_client.patch( + f"/api/workspace-memberships/{membership_id}/", + {"is_active": False}, + format="json", + ) + assert deactivate_response.status_code == 200 + + remove_response = api_client.delete(f"/api/workspace-memberships/{membership_id}/") + assert remove_response.status_code == 204 + + notifications = _notifications_for(member) + assert [item["type"] for item in notifications] == [ + "workspace_membership_removed", + "workspace_membership_deactivated", + "workspace_membership_role_changed", + "workspace_membership_added", + ] + + +def test_workspace_membership_update_skips_self_notifications( + fake_redis, api_client, owner +): + workspace = Workspace.objects.create(name="Product", description="", owner=owner) + owner_membership = WorkspaceMembership.objects.get( + workspace=workspace, + user=owner, + is_deleted=False, + ) + api_client.force_authenticate(user=owner) + + response = api_client.patch( + f"/api/workspace-memberships/{owner_membership.id}/", + {"role": WorkspaceMembership.Role.OWNER}, + format="json", + ) + + assert response.status_code == 200 + assert _notifications_for(owner) == [] + + +def test_project_create_notifies_initial_members_not_creator( + fake_redis, api_client, owner, member +): + workspace = Workspace.objects.create(name="Engineering", description="", owner=owner) + api_client.force_authenticate(user=owner) + + response = api_client.post( + "/api/projects/", + { + "workspace": str(workspace.id), + "name": "API", + "description": "", + "client": None, + "members": [ + {"user_id": str(member.id), "role": ProjectMembership.Role.MEMBER} + ], + }, + format="json", + ) + + assert response.status_code == 201 + assert _notifications_for(owner) == [] + + notifications = _notifications_for(member) + assert len(notifications) == 1 + assert notifications[0]["type"] == "project_membership_added" + assert notifications[0]["meta"]["project_name"] == "API" + + +def test_project_update_full_sync_notifies_only_real_deltas( + fake_redis, api_client, owner, member, another_member, third_member, fourth_member +): + workspace = Workspace.objects.create(name="Build", description="", owner=owner) + project = Project.objects.create(workspace=workspace, name="Platform", description="") + ProjectMembership.objects.create( + project=project, + user=owner, + role=ProjectMembership.Role.MANAGER, + is_active=True, + ) + ProjectMembership.objects.create( + project=project, + user=member, + role=ProjectMembership.Role.MEMBER, + is_active=True, + ) + ProjectMembership.objects.create( + project=project, + user=another_member, + role=ProjectMembership.Role.MEMBER, + is_active=True, + ) + ProjectMembership.objects.create( + project=project, + user=third_member, + role=ProjectMembership.Role.MEMBER, + is_active=True, + ) + + api_client.force_authenticate(user=owner) + response = api_client.patch( + f"/api/projects/{project.id}/", + { + "client": None, + "members": [ + {"user_id": str(member.id), "role": ProjectMembership.Role.MANAGER}, + {"user_id": str(third_member.id), "role": ProjectMembership.Role.MEMBER}, + {"user_id": str(fourth_member.id), "role": ProjectMembership.Role.MEMBER}, + ], + }, + format="json", + ) + + assert response.status_code == 200 + assert [item["type"] for item in _notifications_for(member)] == [ + "project_membership_role_changed" + ] + assert [item["type"] for item in _notifications_for(another_member)] == [ + "project_membership_deactivated" + ] + assert _notifications_for(third_member) == [] + assert [item["type"] for item in _notifications_for(fourth_member)] == [ + "project_membership_added" + ] + assert _notifications_for(owner) == [] + + +def test_project_membership_crud_emits_add_role_change_deactivate_and_remove( + fake_redis, api_client, owner, member +): + workspace = Workspace.objects.create(name="Data", description="", owner=owner) + project = Project.objects.create(workspace=workspace, name="Warehouse", description="") + ProjectMembership.objects.create( + project=project, + user=owner, + role=ProjectMembership.Role.MANAGER, + is_active=True, + ) + api_client.force_authenticate(user=owner) + + create_response = api_client.post( + "/api/memberships/", + { + "project_id": str(project.id), + "user_id": str(member.id), + "role": ProjectMembership.Role.MEMBER, + }, + format="json", + ) + assert create_response.status_code == 201 + membership_id = create_response.data["id"] + + role_response = api_client.patch( + f"/api/memberships/{membership_id}/", + {"role": ProjectMembership.Role.MANAGER}, + format="json", + ) + assert role_response.status_code == 200 + + deactivate_response = api_client.patch( + f"/api/memberships/{membership_id}/", + {"is_active": False}, + format="json", + ) + assert deactivate_response.status_code == 200 + + remove_response = api_client.delete(f"/api/memberships/{membership_id}/") + assert remove_response.status_code == 204 + + notifications = _notifications_for(member) + assert [item["type"] for item in notifications] == [ + "project_membership_removed", + "project_membership_deactivated", + "project_membership_role_changed", + "project_membership_added", + ] diff --git a/apps/projects/api/views.py b/apps/projects/api/views.py index e2326ae..5b7558b 100644 --- a/apps/projects/api/views.py +++ b/apps/projects/api/views.py @@ -7,13 +7,19 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action from rest_framework.filters import SearchFilter, OrderingFilter -from django_filters.rest_framework import DjangoFilterBackend - -from core.paginations.limit_offset import CustomLimitOffsetPagination - -from apps.workspaces.models import Workspace -from apps.clients.models import Client -from apps.projects.models import ( +from django_filters.rest_framework import DjangoFilterBackend + +from core.paginations.limit_offset import CustomLimitOffsetPagination + +from apps.notifications.membership_events import ( + notify_project_membership_added, + notify_project_membership_deactivated, + notify_project_membership_removed, + notify_project_membership_role_changed, +) +from apps.workspaces.models import Workspace +from apps.clients.models import Client +from apps.projects.models import ( Project, ProjectMembership, ProjectRate, @@ -111,12 +117,18 @@ class ProjectViewSet(ModelViewSet): color=serializer.validated_data.get("color", "") ) - for member in members_data: - add_project_member( - project=project, - user_id=member["user_id"], - role=member["role"] - ) + for member in members_data: + membership = add_project_member( + project=project, + user_id=member["user_id"], + role=member["role"] + ) + notify_project_membership_added( + actor=request.user, + recipient=membership.user, + project=project, + role=membership.role, + ) output_serializer = ProjectSerializer(project) return Response(output_serializer.data, status=status.HTTP_201_CREATED) @@ -144,27 +156,55 @@ class ProjectViewSet(ModelViewSet): incoming_users = {str(m['user_id']) for m in members_data} # Add or Update roles - for member in members_data: - user_id_str = str(member['user_id']) - if user_id_str in current_memberships: - # Reactivate or update role - update_project_member( - current_memberships[user_id_str], - role=member['role'], - is_active=True - ) - else: - # Add new member - add_project_member( - project=updated_project, - user_id=member['user_id'], - role=member['role'] - ) + for member in members_data: + user_id_str = str(member['user_id']) + if user_id_str in current_memberships: + membership = current_memberships[user_id_str] + previous_role = membership.role + previous_is_active = membership.is_active + updated_membership = update_project_member( + membership, + role=member['role'], + is_active=True + ) + if not previous_is_active and updated_membership.is_active: + notify_project_membership_added( + actor=request.user, + recipient=updated_membership.user, + project=updated_project, + role=updated_membership.role, + ) + elif previous_role != updated_membership.role: + notify_project_membership_role_changed( + actor=request.user, + recipient=updated_membership.user, + project=updated_project, + previous_role=previous_role, + new_role=updated_membership.role, + ) + else: + membership = add_project_member( + project=updated_project, + user_id=member['user_id'], + role=member['role'] + ) + notify_project_membership_added( + actor=request.user, + recipient=membership.user, + project=updated_project, + role=membership.role, + ) # Deactivate omitted members - for user_id_str, membership in current_memberships.items(): - if user_id_str not in incoming_users: - update_project_member(membership, is_active=False) + for user_id_str, membership in current_memberships.items(): + if user_id_str not in incoming_users and membership.is_active: + update_project_member(membership, is_active=False) + notify_project_membership_deactivated( + actor=request.user, + recipient=membership.user, + project=updated_project, + role=membership.role, + ) output_serializer = ProjectSerializer(updated_project) return Response(output_serializer.data, status=status.HTTP_200_OK) @@ -243,26 +283,65 @@ class ProjectMembershipViewSet(BaseProjectNestedViewSet): self.verify_manager_access(project_id) project = get_object_or_404(Project, id=project_id, is_deleted=False) - membership = add_project_member( - project=project, - user_id=serializer.validated_data["user_id"], - role=serializer.validated_data["role"] - ) - return Response(ProjectMembershipSerializer(membership).data, status=status.HTTP_201_CREATED) - - def update(self, request, *args, **kwargs): - membership = self.get_object() - serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False)) - serializer.is_valid(raise_exception=True) - - updated_membership = update_project_member(membership, **serializer.validated_data) - return Response(ProjectMembershipSerializer(updated_membership).data, status=status.HTTP_200_OK) - - def destroy(self, request, *args, **kwargs): - membership = self.get_object() - membership.is_deleted = True - membership.save(update_fields=["is_deleted", "updated_at"]) - return Response(status=status.HTTP_204_NO_CONTENT) + membership = add_project_member( + project=project, + user_id=serializer.validated_data["user_id"], + role=serializer.validated_data["role"] + ) + notify_project_membership_added( + actor=request.user, + recipient=membership.user, + project=project, + role=membership.role, + ) + return Response(ProjectMembershipSerializer(membership).data, status=status.HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + membership = self.get_object() + serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False)) + serializer.is_valid(raise_exception=True) + + previous_role = membership.role + previous_is_active = membership.is_active + updated_membership = update_project_member(membership, **serializer.validated_data) + if not previous_is_active and updated_membership.is_active: + notify_project_membership_added( + actor=request.user, + recipient=updated_membership.user, + project=updated_membership.project, + role=updated_membership.role, + ) + elif previous_is_active and not updated_membership.is_active: + notify_project_membership_deactivated( + actor=request.user, + recipient=updated_membership.user, + project=updated_membership.project, + role=previous_role, + ) + elif previous_role != updated_membership.role: + notify_project_membership_role_changed( + actor=request.user, + recipient=updated_membership.user, + project=updated_membership.project, + previous_role=previous_role, + new_role=updated_membership.role, + ) + return Response(ProjectMembershipSerializer(updated_membership).data, status=status.HTTP_200_OK) + + def destroy(self, request, *args, **kwargs): + membership = self.get_object() + recipient = membership.user + project = membership.project + role = membership.role + membership.is_deleted = True + membership.save(update_fields=["is_deleted", "updated_at"]) + notify_project_membership_removed( + actor=request.user, + recipient=recipient, + project=project, + role=role, + ) + return Response(status=status.HTTP_204_NO_CONTENT) class ProjectRateViewSet(BaseProjectNestedViewSet): diff --git a/apps/workspaces/api/serializers.py b/apps/workspaces/api/serializers.py index 7a942bb..82635b6 100644 --- a/apps/workspaces/api/serializers.py +++ b/apps/workspaces/api/serializers.py @@ -1,8 +1,10 @@ -from rest_framework import serializers - -from core.serializers.base import BaseModelSerializer -from apps.workspaces.models import Workspace, WorkspaceMembership -from core.serializers.mini import UserMiniSerializer +from rest_framework import serializers + +from apps.notifications.membership_events import notify_workspace_membership_added +from apps.users.models import User +from core.serializers.base import BaseModelSerializer +from apps.workspaces.models import Workspace, WorkspaceMembership +from core.serializers.mini import UserMiniSerializer class WorkspaceMemberInputSerializer(serializers.Serializer): @@ -34,26 +36,41 @@ class WorkspaceSerializer(BaseModelSerializer): ).first() return getattr(membership, "role", None) - def create(self, validated_data): - members_data = validated_data.pop('members', []) - - workspace = super().create(validated_data) - - memberships_to_create = [] - for member in members_data: - memberships_to_create.append( + def create(self, validated_data): + members_data = validated_data.pop('members', []) + + workspace = super().create(validated_data) + + memberships_to_create = [] + for member in members_data: + memberships_to_create.append( WorkspaceMembership( workspace=workspace, user_id=member['user_id'], role=member['role'], is_active=True ) - ) - - if memberships_to_create: - WorkspaceMembership.objects.bulk_create(memberships_to_create) - - return workspace + ) + + if memberships_to_create: + WorkspaceMembership.objects.bulk_create(memberships_to_create) + request = self.context.get("request") + actor = getattr(request, "user", None) + if actor and actor.is_authenticated: + users_by_id = User.objects.in_bulk( + [member["user_id"] for member in members_data] + ) + for member in members_data: + recipient = users_by_id.get(member["user_id"]) + if recipient: + notify_workspace_membership_added( + actor=actor, + recipient=recipient, + workspace=workspace, + role=member["role"], + ) + + return workspace class WorkspaceMembershipSerializer(BaseModelSerializer): diff --git a/apps/workspaces/api/views.py b/apps/workspaces/api/views.py index 96b0626..451a469 100644 --- a/apps/workspaces/api/views.py +++ b/apps/workspaces/api/views.py @@ -5,11 +5,17 @@ from rest_framework.response import Response from rest_framework.filters import OrderingFilter, SearchFilter from django_filters.rest_framework import DjangoFilterBackend from rest_framework.viewsets import ModelViewSet -from rest_framework.permissions import IsAuthenticated - -from apps.workspaces.api.permissions import IsWorkspaceOwner, IsWorkspaceAdmin -from apps.workspaces.api.serializers import WorkspaceMembershipSerializer, WorkspaceSerializer -from apps.workspaces.api.filters import WorkspaceFilter, WorkspaceMembershipFilter +from rest_framework.permissions import IsAuthenticated + +from apps.notifications.membership_events import ( + notify_workspace_membership_added, + notify_workspace_membership_deactivated, + notify_workspace_membership_removed, + notify_workspace_membership_role_changed, +) +from apps.workspaces.api.permissions import IsWorkspaceOwner, IsWorkspaceAdmin +from apps.workspaces.api.serializers import WorkspaceMembershipSerializer, WorkspaceSerializer +from apps.workspaces.api.filters import WorkspaceFilter, WorkspaceMembershipFilter from apps.workspaces.models import Workspace, WorkspaceMembership from core.paginations.limit_offset import CustomLimitOffsetPagination @@ -79,7 +85,7 @@ class WorkspaceMembershipViewSet(ModelViewSet): return [IsAuthenticated()] - def create(self, request, *args, **kwargs): + def create(self, request, *args, **kwargs): """ Overridden to check permissions manually. Because the membership object doesn't exist yet, standard DRF object-level @@ -101,4 +107,75 @@ class WorkspaceMembershipViewSet(ModelViewSet): status=status.HTTP_403_FORBIDDEN ) - return super().create(request, *args, **kwargs) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + membership = serializer.save() + notify_workspace_membership_added( + actor=request.user, + recipient=membership.user, + workspace=membership.workspace, + role=membership.role, + ) + return Response( + WorkspaceMembershipSerializer(membership, context=self.get_serializer_context()).data, + status=status.HTTP_201_CREATED, + ) + + def update(self, request, *args, **kwargs): + partial = kwargs.pop("partial", False) + membership = self.get_object() + previous_role = membership.role + previous_is_active = membership.is_active + + serializer = self.get_serializer(membership, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + updated_membership = serializer.save() + + if not previous_is_active and updated_membership.is_active: + notify_workspace_membership_added( + actor=request.user, + recipient=updated_membership.user, + workspace=updated_membership.workspace, + role=updated_membership.role, + ) + elif previous_is_active and not updated_membership.is_active: + notify_workspace_membership_deactivated( + actor=request.user, + recipient=updated_membership.user, + workspace=updated_membership.workspace, + role=previous_role, + ) + elif previous_role != updated_membership.role: + notify_workspace_membership_role_changed( + actor=request.user, + recipient=updated_membership.user, + workspace=updated_membership.workspace, + previous_role=previous_role, + new_role=updated_membership.role, + ) + + return Response( + WorkspaceMembershipSerializer( + updated_membership, + context=self.get_serializer_context(), + ).data, + status=status.HTTP_200_OK, + ) + + def partial_update(self, request, *args, **kwargs): + kwargs["partial"] = True + return self.update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + membership = self.get_object() + recipient = membership.user + workspace = membership.workspace + role = membership.role + membership.delete() + notify_workspace_membership_removed( + actor=request.user, + recipient=recipient, + workspace=workspace, + role=role, + ) + return Response(status=status.HTTP_204_NO_CONTENT)