feat(notifications): notify membership access changes
This commit is contained in:
271
apps/notifications/membership_events.py
Normal file
271
apps/notifications/membership_events.py
Normal 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,
|
||||
},
|
||||
},
|
||||
)
|
||||
297
apps/notifications/tests/test_membership_events.py
Normal file
297
apps/notifications/tests/test_membership_events.py
Normal 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",
|
||||
]
|
||||
@@ -11,6 +11,12 @@ 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 (
|
||||
@@ -112,11 +118,17 @@ class ProjectViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
for member in members_data:
|
||||
add_project_member(
|
||||
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)
|
||||
@@ -147,24 +159,52 @@ class ProjectViewSet(ModelViewSet):
|
||||
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],
|
||||
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:
|
||||
# Add new member
|
||||
add_project_member(
|
||||
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:
|
||||
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)
|
||||
@@ -248,6 +288,12 @@ class ProjectMembershipViewSet(BaseProjectNestedViewSet):
|
||||
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):
|
||||
@@ -255,13 +301,46 @@ class ProjectMembershipViewSet(BaseProjectNestedViewSet):
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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
|
||||
@@ -52,6 +54,21 @@ class WorkspaceSerializer(BaseModelSerializer):
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user