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

@@ -11,6 +11,12 @@ from django_filters.rest_framework import DjangoFilterBackend
from core.paginations.limit_offset import CustomLimitOffsetPagination 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.workspaces.models import Workspace
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import ( from apps.projects.models import (
@@ -112,11 +118,17 @@ class ProjectViewSet(ModelViewSet):
) )
for member in members_data: for member in members_data:
add_project_member( membership = add_project_member(
project=project, project=project,
user_id=member["user_id"], user_id=member["user_id"],
role=member["role"] role=member["role"]
) )
notify_project_membership_added(
actor=request.user,
recipient=membership.user,
project=project,
role=membership.role,
)
output_serializer = ProjectSerializer(project) output_serializer = ProjectSerializer(project)
return Response(output_serializer.data, status=status.HTTP_201_CREATED) return Response(output_serializer.data, status=status.HTTP_201_CREATED)
@@ -147,24 +159,52 @@ class ProjectViewSet(ModelViewSet):
for member in members_data: for member in members_data:
user_id_str = str(member['user_id']) user_id_str = str(member['user_id'])
if user_id_str in current_memberships: if user_id_str in current_memberships:
# Reactivate or update role membership = current_memberships[user_id_str]
update_project_member( previous_role = membership.role
current_memberships[user_id_str], previous_is_active = membership.is_active
updated_membership = update_project_member(
membership,
role=member['role'], role=member['role'],
is_active=True 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: else:
# Add new member membership = add_project_member(
add_project_member(
project=updated_project, project=updated_project,
user_id=member['user_id'], user_id=member['user_id'],
role=member['role'] role=member['role']
) )
notify_project_membership_added(
actor=request.user,
recipient=membership.user,
project=updated_project,
role=membership.role,
)
# Deactivate omitted members # Deactivate omitted members
for user_id_str, membership in current_memberships.items(): for user_id_str, membership in current_memberships.items():
if user_id_str not in incoming_users: if user_id_str not in incoming_users and membership.is_active:
update_project_member(membership, is_active=False) 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) output_serializer = ProjectSerializer(updated_project)
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
@@ -248,6 +288,12 @@ class ProjectMembershipViewSet(BaseProjectNestedViewSet):
user_id=serializer.validated_data["user_id"], user_id=serializer.validated_data["user_id"],
role=serializer.validated_data["role"] 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) return Response(ProjectMembershipSerializer(membership).data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
@@ -255,13 +301,46 @@ class ProjectMembershipViewSet(BaseProjectNestedViewSet):
serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False)) serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False))
serializer.is_valid(raise_exception=True) 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) 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) return Response(ProjectMembershipSerializer(updated_membership).data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
membership = self.get_object() membership = self.get_object()
recipient = membership.user
project = membership.project
role = membership.role
membership.is_deleted = True membership.is_deleted = True
membership.save(update_fields=["is_deleted", "updated_at"]) 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) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,5 +1,7 @@
from rest_framework import serializers 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 core.serializers.base import BaseModelSerializer
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership
from core.serializers.mini import UserMiniSerializer from core.serializers.mini import UserMiniSerializer
@@ -52,6 +54,21 @@ class WorkspaceSerializer(BaseModelSerializer):
if memberships_to_create: if memberships_to_create:
WorkspaceMembership.objects.bulk_create(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 return workspace

View File

@@ -7,6 +7,12 @@ from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated 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.permissions import IsWorkspaceOwner, IsWorkspaceAdmin
from apps.workspaces.api.serializers import WorkspaceMembershipSerializer, WorkspaceSerializer from apps.workspaces.api.serializers import WorkspaceMembershipSerializer, WorkspaceSerializer
from apps.workspaces.api.filters import WorkspaceFilter, WorkspaceMembershipFilter from apps.workspaces.api.filters import WorkspaceFilter, WorkspaceMembershipFilter
@@ -101,4 +107,75 @@ class WorkspaceMembershipViewSet(ModelViewSet):
status=status.HTTP_403_FORBIDDEN 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)