feat(notifications): notify membership access changes

This commit is contained in:
2026-04-25 11:51:45 +03:30
parent 0ca3255270
commit 48bf4f5c19
5 changed files with 819 additions and 78 deletions

View File

@@ -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,
},
},
)

View File

@@ -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",
]

View File

@@ -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):

View File

@@ -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):

View File

@@ -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)