refactor(projects): remove project membership access model
This commit is contained in:
@@ -4,7 +4,6 @@ SECTION_WORKSPACE = "workspace"
|
|||||||
SECTION_WORKSPACE_MEMBERS = "workspace_members"
|
SECTION_WORKSPACE_MEMBERS = "workspace_members"
|
||||||
SECTION_CLIENTS = "clients"
|
SECTION_CLIENTS = "clients"
|
||||||
SECTION_PROJECTS = "projects"
|
SECTION_PROJECTS = "projects"
|
||||||
SECTION_PROJECT_MEMBERS = "project_members"
|
|
||||||
SECTION_TAGS = "tags"
|
SECTION_TAGS = "tags"
|
||||||
SECTION_TIME_ENTRIES = "time_entries"
|
SECTION_TIME_ENTRIES = "time_entries"
|
||||||
SECTION_RATES = "rates"
|
SECTION_RATES = "rates"
|
||||||
@@ -15,7 +14,6 @@ LOG_SECTIONS = (
|
|||||||
SECTION_WORKSPACE_MEMBERS,
|
SECTION_WORKSPACE_MEMBERS,
|
||||||
SECTION_CLIENTS,
|
SECTION_CLIENTS,
|
||||||
SECTION_PROJECTS,
|
SECTION_PROJECTS,
|
||||||
SECTION_PROJECT_MEMBERS,
|
|
||||||
SECTION_TAGS,
|
SECTION_TAGS,
|
||||||
SECTION_TIME_ENTRIES,
|
SECTION_TIME_ENTRIES,
|
||||||
SECTION_RATES,
|
SECTION_RATES,
|
||||||
@@ -48,11 +46,9 @@ SECTION_BY_MODEL_LABEL = {
|
|||||||
"workspaces.workspaceuserrate": SECTION_RATES,
|
"workspaces.workspaceuserrate": SECTION_RATES,
|
||||||
"clients.client": SECTION_CLIENTS,
|
"clients.client": SECTION_CLIENTS,
|
||||||
"projects.project": SECTION_PROJECTS,
|
"projects.project": SECTION_PROJECTS,
|
||||||
"projects.projectmembership": SECTION_PROJECT_MEMBERS,
|
|
||||||
"tags.tag": SECTION_TAGS,
|
"tags.tag": SECTION_TAGS,
|
||||||
"time_entries.timeentry": SECTION_TIME_ENTRIES,
|
"time_entries.timeentry": SECTION_TIME_ENTRIES,
|
||||||
"reports.reportexportjob": SECTION_REPORT_EXPORTS,
|
"reports.reportexportjob": SECTION_REPORT_EXPORTS,
|
||||||
}
|
}
|
||||||
|
|
||||||
TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys())
|
TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys())
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
from apps.notifications.services.membership_events import (
|
from apps.notifications.services.membership_events import (
|
||||||
notify_project_membership_added,
|
|
||||||
notify_project_membership_deactivated,
|
|
||||||
notify_project_membership_removed,
|
|
||||||
notify_project_membership_role_changed,
|
|
||||||
notify_workspace_membership_added,
|
notify_workspace_membership_added,
|
||||||
notify_workspace_membership_deactivated,
|
notify_workspace_membership_deactivated,
|
||||||
notify_workspace_membership_removed,
|
notify_workspace_membership_removed,
|
||||||
@@ -16,8 +12,4 @@ __all__ = [
|
|||||||
"notify_workspace_membership_role_changed",
|
"notify_workspace_membership_role_changed",
|
||||||
"notify_workspace_membership_deactivated",
|
"notify_workspace_membership_deactivated",
|
||||||
"notify_workspace_membership_removed",
|
"notify_workspace_membership_removed",
|
||||||
"notify_project_membership_added",
|
|
||||||
"notify_project_membership_role_changed",
|
|
||||||
"notify_project_membership_deactivated",
|
|
||||||
"notify_project_membership_removed",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ def _workspace_action_url(workspace) -> str:
|
|||||||
return f"/workspaces/{workspace.id}"
|
return f"/workspaces/{workspace.id}"
|
||||||
|
|
||||||
|
|
||||||
def _project_action_url(project) -> str:
|
|
||||||
return "/projects"
|
|
||||||
|
|
||||||
|
|
||||||
def notify_workspace_membership_added(*, actor, recipient, workspace, role: str) -> None:
|
def notify_workspace_membership_added(*, actor, recipient, workspace, role: str) -> None:
|
||||||
if _should_skip(actor, recipient):
|
if _should_skip(actor, recipient):
|
||||||
return
|
return
|
||||||
@@ -148,124 +144,3 @@ def notify_workspace_membership_removed(*, actor, recipient, workspace, role: st
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from rest_framework.test import APIClient
|
|||||||
from apps.notifications.services import store as services
|
from apps.notifications.services import store as services
|
||||||
from apps.notifications.services import RedisNotificationStore
|
from apps.notifications.services import RedisNotificationStore
|
||||||
from apps.notifications.tests.test_services import FakeRedis
|
from apps.notifications.tests.test_services import FakeRedis
|
||||||
from apps.projects.models import Project, ProjectMembership
|
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
@@ -158,140 +157,3 @@ def test_workspace_membership_update_skips_self_notifications(
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert _notifications_for(owner) == []
|
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",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from core.admins.base import BaseAdmin
|
from core.admins.base import BaseAdmin
|
||||||
from apps.projects.models import Project, ProjectMembership
|
from apps.projects.models import Project
|
||||||
|
|
||||||
|
|
||||||
class ProjectMembershipInline(admin.TabularInline):
|
|
||||||
model = ProjectMembership
|
|
||||||
extra = 0
|
|
||||||
autocomplete_fields = ("user",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Project)
|
@admin.register(Project)
|
||||||
@@ -37,32 +31,3 @@ class ProjectAdmin(BaseAdmin):
|
|||||||
"workspace",
|
"workspace",
|
||||||
"client",
|
"client",
|
||||||
)
|
)
|
||||||
|
|
||||||
inlines = (ProjectMembershipInline,)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ProjectMembership)
|
|
||||||
class ProjectMembershipAdmin(BaseAdmin):
|
|
||||||
list_display = (
|
|
||||||
"id",
|
|
||||||
"project",
|
|
||||||
"user",
|
|
||||||
"role",
|
|
||||||
"is_active",
|
|
||||||
)
|
|
||||||
|
|
||||||
list_filter = (
|
|
||||||
"role",
|
|
||||||
"is_active",
|
|
||||||
"is_deleted",
|
|
||||||
)
|
|
||||||
|
|
||||||
search_fields = (
|
|
||||||
"project__name",
|
|
||||||
"user__mobile",
|
|
||||||
)
|
|
||||||
|
|
||||||
autocomplete_fields = (
|
|
||||||
"project",
|
|
||||||
"user",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from apps.projects.models import ProjectMembership
|
|
||||||
from apps.workspaces.services import (
|
from apps.workspaces.services import (
|
||||||
PROJECTS_EDIT,
|
PROJECTS_EDIT,
|
||||||
PROJECTS_VIEW,
|
PROJECTS_VIEW,
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
has_workspace_capability,
|
||||||
has_project_capability,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +16,7 @@ def get_project_from_obj(obj):
|
|||||||
|
|
||||||
class IsProjectMember(permissions.BasePermission):
|
class IsProjectMember(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
Allows access only to users who have an active membership in the project.
|
Allows access to users who can view projects in the parent workspace.
|
||||||
"""
|
"""
|
||||||
message = "شما عضو این پروژه نیستید."
|
message = "شما عضو این پروژه نیستید."
|
||||||
|
|
||||||
@@ -27,12 +25,12 @@ class IsProjectMember(permissions.BasePermission):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
project = get_project_from_obj(obj)
|
project = get_project_from_obj(obj)
|
||||||
return has_project_capability(request.user, project, PROJECTS_VIEW)
|
return has_workspace_capability(request.user, project.workspace, PROJECTS_VIEW)
|
||||||
|
|
||||||
|
|
||||||
class IsProjectManager(permissions.BasePermission):
|
class IsProjectManager(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
Allows access only to users who are active MANAGERs of the project.
|
Allows access to users who can manage projects in the parent workspace.
|
||||||
"""
|
"""
|
||||||
message = "فقط مدیران پروژه مجاز به انجام این عملیات هستند."
|
message = "فقط مدیران پروژه مجاز به انجام این عملیات هستند."
|
||||||
|
|
||||||
@@ -41,19 +39,4 @@ class IsProjectManager(permissions.BasePermission):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
project = get_project_from_obj(obj)
|
project = get_project_from_obj(obj)
|
||||||
return has_project_capability(request.user, project, PROJECTS_EDIT)
|
return has_workspace_capability(request.user, project.workspace, PROJECTS_EDIT)
|
||||||
|
|
||||||
|
|
||||||
class CanManageProjectMembers(permissions.BasePermission):
|
|
||||||
message = "Only authorized users can manage project memberships."
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
if not request.user or not request.user.is_authenticated:
|
|
||||||
return False
|
|
||||||
|
|
||||||
project = get_project_from_obj(obj)
|
|
||||||
return has_project_capability(
|
|
||||||
request.user,
|
|
||||||
project,
|
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from core.serializers.base import BaseModelSerializer
|
from core.serializers.base import BaseModelSerializer
|
||||||
from apps.projects.models import (
|
from apps.projects.models import Project
|
||||||
Project,
|
|
||||||
ProjectMembership,
|
|
||||||
)
|
|
||||||
from core.serializers.mini import UserMiniSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectMemberInputSerializer(serializers.Serializer):
|
|
||||||
user_id = serializers.UUIDField()
|
|
||||||
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, default=ProjectMembership.Role.MEMBER)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectSerializer(BaseModelSerializer):
|
class ProjectSerializer(BaseModelSerializer):
|
||||||
my_role = serializers.SerializerMethodField()
|
|
||||||
members = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Project
|
model = Project
|
||||||
fields = BaseModelSerializer.Meta.fields + (
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
@@ -25,25 +13,9 @@ class ProjectSerializer(BaseModelSerializer):
|
|||||||
"description",
|
"description",
|
||||||
"is_archived",
|
"is_archived",
|
||||||
"color",
|
"color",
|
||||||
"my_role",
|
|
||||||
"members",
|
|
||||||
)
|
)
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
def get_my_role(self, obj):
|
|
||||||
request = self.context.get("request")
|
|
||||||
if not request or not request.user.is_authenticated:
|
|
||||||
return None
|
|
||||||
membership = obj.memberships.filter(user=request.user, is_active=True, is_deleted=False).first()
|
|
||||||
return getattr(membership, "role", None)
|
|
||||||
|
|
||||||
def get_members(self, obj):
|
|
||||||
"""
|
|
||||||
Returns active project members in the response
|
|
||||||
"""
|
|
||||||
active_memberships = obj.memberships.filter(is_active=True, is_deleted=False)
|
|
||||||
return ProjectMembershipSerializer(active_memberships, many=True).data
|
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
representation = super().to_representation(instance)
|
representation = super().to_representation(instance)
|
||||||
if instance.client:
|
if instance.client:
|
||||||
@@ -60,7 +32,6 @@ class ProjectCreateSerializer(serializers.Serializer):
|
|||||||
client = serializers.UUIDField(required=False, allow_null=True)
|
client = serializers.UUIDField(required=False, allow_null=True)
|
||||||
description = serializers.CharField(required=False, allow_blank=True, default="")
|
description = serializers.CharField(required=False, allow_blank=True, default="")
|
||||||
color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="")
|
color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="")
|
||||||
members = ProjectMemberInputSerializer(many=True, required=False)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdateSerializer(serializers.Serializer):
|
class ProjectUpdateSerializer(serializers.Serializer):
|
||||||
@@ -69,29 +40,3 @@ class ProjectUpdateSerializer(serializers.Serializer):
|
|||||||
description = serializers.CharField(required=False, allow_blank=True)
|
description = serializers.CharField(required=False, allow_blank=True)
|
||||||
color = serializers.CharField(max_length=7, required=False, allow_blank=True)
|
color = serializers.CharField(max_length=7, required=False, allow_blank=True)
|
||||||
is_archived = serializers.BooleanField(required=False)
|
is_archived = serializers.BooleanField(required=False)
|
||||||
members = ProjectMemberInputSerializer(many=True, required=False)
|
|
||||||
|
|
||||||
class ProjectMembershipSerializer(BaseModelSerializer):
|
|
||||||
user_details = UserMiniSerializer(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ProjectMembership
|
|
||||||
fields = BaseModelSerializer.Meta.fields + (
|
|
||||||
"project",
|
|
||||||
"user",
|
|
||||||
"user_details",
|
|
||||||
"role",
|
|
||||||
"is_active",
|
|
||||||
)
|
|
||||||
read_only_fields = fields
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectMembershipCreateSerializer(serializers.Serializer):
|
|
||||||
project_id = serializers.UUIDField()
|
|
||||||
user_id = serializers.UUIDField()
|
|
||||||
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectMembershipUpdateSerializer(serializers.Serializer):
|
|
||||||
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, required=False)
|
|
||||||
is_active = serializers.BooleanField(required=False)
|
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from apps.projects.api.views import (
|
from apps.projects.api.views import ProjectViewSet
|
||||||
ProjectViewSet,
|
|
||||||
ProjectMembershipViewSet,
|
|
||||||
)
|
|
||||||
|
|
||||||
app_name = "projects"
|
app_name = "projects"
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r"projects", ProjectViewSet, basename="project")
|
router.register(r"projects", ProjectViewSet, basename="project")
|
||||||
router.register(r"memberships", ProjectMembershipViewSet, basename="membership")
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from django.shortcuts import get_object_or_404
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.exceptions import PermissionDenied
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||||
@@ -11,21 +10,11 @@ from django_filters.rest_framework import DjangoFilterBackend
|
|||||||
|
|
||||||
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
||||||
|
|
||||||
from apps.notifications.services 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 Project
|
||||||
Project,
|
|
||||||
ProjectMembership,
|
|
||||||
)
|
|
||||||
from apps.projects.api.serializers import (
|
from apps.projects.api.serializers import (
|
||||||
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
||||||
ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer,
|
|
||||||
)
|
)
|
||||||
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
||||||
from apps.projects.services.projects import (
|
from apps.projects.services.projects import (
|
||||||
@@ -33,17 +22,12 @@ from apps.projects.services.projects import (
|
|||||||
update_project,
|
update_project,
|
||||||
toggle_project_archive
|
toggle_project_archive
|
||||||
)
|
)
|
||||||
from apps.projects.services.memberships import add_project_member, update_project_member
|
|
||||||
from apps.workspaces.services import (
|
from apps.workspaces.services import (
|
||||||
PROJECTS_ARCHIVE,
|
PROJECTS_ARCHIVE,
|
||||||
PROJECTS_CREATE,
|
PROJECTS_CREATE,
|
||||||
PROJECTS_DELETE,
|
PROJECTS_DELETE,
|
||||||
PROJECTS_EDIT,
|
PROJECTS_EDIT,
|
||||||
PROJECT_MEMBERS_ADD,
|
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
|
||||||
PROJECT_MEMBERS_REMOVE,
|
|
||||||
can_delete_workspace_object,
|
can_delete_workspace_object,
|
||||||
has_project_capability,
|
|
||||||
has_workspace_capability,
|
has_workspace_capability,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,9 +47,9 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
"""
|
"""
|
||||||
Instantiates and returns the list of permissions that this view requires.
|
Instantiates and returns the list of permissions that this view requires.
|
||||||
- Managers can update, delete, or archive.
|
- Workspace-authorized users can update, delete, or archive.
|
||||||
- Members can retrieve/view.
|
- Workspace members can retrieve/view.
|
||||||
- Any authenticated user can list (filtered to their memberships) or attempt to create.
|
- Any authenticated user can list their workspace projects or attempt to create.
|
||||||
"""
|
"""
|
||||||
if self.action in ["update", "partial_update", "destroy", "archive"]:
|
if self.action in ["update", "partial_update", "destroy", "archive"]:
|
||||||
permission_classes = [IsAuthenticated, IsProjectManager]
|
permission_classes = [IsAuthenticated, IsProjectManager]
|
||||||
@@ -78,7 +62,7 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""
|
||||||
Returns active projects where the current user is an active member.
|
Returns active projects in workspaces where the current user is an active member.
|
||||||
"""
|
"""
|
||||||
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
|
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
|
||||||
return Project.objects.none()
|
return Project.objects.none()
|
||||||
@@ -106,8 +90,6 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
members_data = serializer.validated_data.pop("members", [])
|
|
||||||
|
|
||||||
workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False)
|
workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False)
|
||||||
if not has_workspace_capability(request.user, workspace, PROJECTS_CREATE):
|
if not has_workspace_capability(request.user, workspace, PROJECTS_CREATE):
|
||||||
return Response(
|
return Response(
|
||||||
@@ -126,19 +108,6 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
color=serializer.validated_data.get("color", "")
|
color=serializer.validated_data.get("color", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
output_serializer = ProjectSerializer(project)
|
||||||
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
@@ -152,69 +121,11 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
serializer = self.get_serializer(data=request.data, partial=partial)
|
serializer = self.get_serializer(data=request.data, partial=partial)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
members_data = serializer.validated_data.pop("members", None)
|
|
||||||
|
|
||||||
updated_project = update_project(
|
updated_project = update_project(
|
||||||
project=project,
|
project=project,
|
||||||
**serializer.validated_data
|
**serializer.validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Full sync of project members if array is provided
|
|
||||||
if members_data is not None:
|
|
||||||
current_memberships = {str(m.user_id): m for m in updated_project.memberships.filter(is_deleted=False)}
|
|
||||||
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:
|
|
||||||
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 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)
|
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)
|
||||||
|
|
||||||
@@ -242,123 +153,3 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
class BaseProjectNestedViewSet(ModelViewSet):
|
|
||||||
"""
|
|
||||||
Base ViewSet for nested project models to share common permission and queryset logic.
|
|
||||||
"""
|
|
||||||
pagination_class = CustomLimitOffsetPagination
|
|
||||||
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
|
||||||
ordering = ["-updated_at", "-created_at"]
|
|
||||||
|
|
||||||
def get_permissions(self):
|
|
||||||
if self.action in ["create", "update", "partial_update", "destroy"]:
|
|
||||||
permission_classes = [IsAuthenticated, IsProjectManager]
|
|
||||||
else:
|
|
||||||
permission_classes = [IsAuthenticated, IsProjectMember]
|
|
||||||
return [permission() for permission in permission_classes]
|
|
||||||
|
|
||||||
def verify_manager_access(self, project_id):
|
|
||||||
"""Helper to verify if the requesting user is a manager of the target project."""
|
|
||||||
project = get_object_or_404(Project, id=project_id, is_deleted=False)
|
|
||||||
if not has_project_capability(self.request.user, project, PROJECT_MEMBERS_ADD):
|
|
||||||
raise PermissionDenied("You must be a project manager to perform this action.")
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectMembershipViewSet(BaseProjectNestedViewSet):
|
|
||||||
filterset_fields = ["project", "user", "role", "is_active"]
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
|
|
||||||
return ProjectMembership.objects.none()
|
|
||||||
return ProjectMembership.objects.filter(
|
|
||||||
project__memberships__user=self.request.user,
|
|
||||||
project__memberships__is_active=True,
|
|
||||||
is_deleted=False
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
|
||||||
if self.action == "create": return ProjectMembershipCreateSerializer
|
|
||||||
if self.action in ["update", "partial_update"]: return ProjectMembershipUpdateSerializer
|
|
||||||
return ProjectMembershipSerializer
|
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
project_id = serializer.validated_data["project_id"]
|
|
||||||
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"]
|
|
||||||
)
|
|
||||||
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()
|
|
||||||
if not has_project_capability(
|
|
||||||
request.user,
|
|
||||||
membership.project,
|
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
|
||||||
):
|
|
||||||
raise PermissionDenied("You do not have permission to update project members.")
|
|
||||||
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()
|
|
||||||
if not has_project_capability(
|
|
||||||
request.user,
|
|
||||||
membership.project,
|
|
||||||
PROJECT_MEMBERS_REMOVE,
|
|
||||||
):
|
|
||||||
raise PermissionDenied("You do not have permission to remove project members.")
|
|
||||||
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)
|
|
||||||
|
|||||||
13
apps/projects/migrations/0002_remove_projectmembership.py
Normal file
13
apps/projects/migrations/0002_remove_projectmembership.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("projects", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="ProjectMembership",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from apps.logs.services import build_workspace_log_metadata
|
from apps.logs.services import build_workspace_log_metadata
|
||||||
from apps.logs.services.constants import SECTION_PROJECTS, SECTION_PROJECT_MEMBERS
|
from apps.logs.services.constants import SECTION_PROJECTS
|
||||||
from core.models.base import BaseModel
|
from core.models.base import BaseModel
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
@@ -59,64 +59,6 @@ class Project(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProjectMembership(BaseModel):
|
|
||||||
|
|
||||||
class Role(models.TextChoices):
|
|
||||||
MANAGER = "manager", "Manager"
|
|
||||||
MEMBER = "member", "Member"
|
|
||||||
|
|
||||||
project = models.ForeignKey(
|
|
||||||
Project,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="memberships",
|
|
||||||
)
|
|
||||||
|
|
||||||
user = models.ForeignKey(
|
|
||||||
User,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="project_memberships",
|
|
||||||
)
|
|
||||||
|
|
||||||
role = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=Role.choices,
|
|
||||||
default=Role.MEMBER,
|
|
||||||
)
|
|
||||||
|
|
||||||
is_active = models.BooleanField(default=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "project_membership"
|
|
||||||
ordering = ("-created_at",)
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["project"], name="project_membership_project_idx"),
|
|
||||||
models.Index(fields=["user"], name="project_membership_user_idx"),
|
|
||||||
]
|
|
||||||
constraints = [
|
|
||||||
models.UniqueConstraint(
|
|
||||||
fields=["project", "user"],
|
|
||||||
name="unique_project_membership",
|
|
||||||
condition=models.Q(is_deleted=False),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.user} @ {self.project}"
|
|
||||||
|
|
||||||
def get_additional_data(self):
|
|
||||||
return build_workspace_log_metadata(
|
|
||||||
section=SECTION_PROJECT_MEMBERS,
|
|
||||||
workspace_id=self.project.workspace_id,
|
|
||||||
target_id=self.id,
|
|
||||||
target_label=self.user.full_name or self.user.mobile,
|
|
||||||
extra={
|
|
||||||
"project_id": str(self.project_id),
|
|
||||||
"member_user_id": str(self.user_id),
|
|
||||||
"role": self.role,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectRate(BaseModel):
|
class ProjectRate(BaseModel):
|
||||||
project = models.ForeignKey(
|
project = models.ForeignKey(
|
||||||
Project,
|
Project,
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
from rest_framework.exceptions import ValidationError
|
|
||||||
from apps.projects.models import ProjectMembership
|
|
||||||
|
|
||||||
def add_project_member(project, user_id, role):
|
|
||||||
"""
|
|
||||||
Adds a user to a project. Ensures no duplicate active memberships exist.
|
|
||||||
"""
|
|
||||||
if ProjectMembership.objects.filter(project=project, user_id=user_id, is_deleted=False).exists():
|
|
||||||
raise ValidationError({"user_id": "This user is already a member of the project."})
|
|
||||||
|
|
||||||
return ProjectMembership.objects.create(
|
|
||||||
project=project,
|
|
||||||
user_id=user_id,
|
|
||||||
role=role,
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_project_member(membership, **kwargs):
|
|
||||||
"""
|
|
||||||
Updates a project membership (e.g., changing role or active status).
|
|
||||||
"""
|
|
||||||
update_fields = []
|
|
||||||
for field, value in kwargs.items():
|
|
||||||
if hasattr(membership, field) and getattr(membership, field) != value:
|
|
||||||
setattr(membership, field, value)
|
|
||||||
update_fields.append(field)
|
|
||||||
|
|
||||||
if update_fields:
|
|
||||||
update_fields.append("updated_at")
|
|
||||||
membership.save(update_fields=update_fields)
|
|
||||||
|
|
||||||
return membership
|
|
||||||
@@ -3,15 +3,14 @@ from django.shortcuts import get_object_or_404
|
|||||||
from rest_framework.exceptions import ValidationError, PermissionDenied
|
from rest_framework.exceptions import ValidationError, PermissionDenied
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project, ProjectMembership
|
from apps.projects.models import Project
|
||||||
from apps.workspaces.models import WorkspaceMembership
|
from apps.workspaces.models import WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create_project(user, workspace, name, client=None, description="", color=""):
|
def create_project(user, workspace, name, client=None, description="", color=""):
|
||||||
"""
|
"""
|
||||||
Creates a new project and automatically assigns the creator
|
Creates a new workspace-shared project.
|
||||||
as an active MANAGER of that project.
|
|
||||||
"""
|
"""
|
||||||
workspace_member = WorkspaceMembership.objects.filter(
|
workspace_member = WorkspaceMembership.objects.filter(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
@@ -36,13 +35,6 @@ def create_project(user, workspace, name, client=None, description="", color="")
|
|||||||
updated_by=user,
|
updated_by=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
ProjectMembership.objects.create(
|
|
||||||
project=project,
|
|
||||||
user=user,
|
|
||||||
role=ProjectMembership.Role.MANAGER,
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -132,12 +132,14 @@ class WorkspaceSerializer(BaseModelSerializer):
|
|||||||
|
|
||||||
class WorkspaceMembershipSerializer(BaseModelSerializer):
|
class WorkspaceMembershipSerializer(BaseModelSerializer):
|
||||||
user = serializers.SerializerMethodField()
|
user = serializers.SerializerMethodField()
|
||||||
|
user_id = serializers.UUIDField(write_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WorkspaceMembership
|
model = WorkspaceMembership
|
||||||
fields = BaseModelSerializer.Meta.fields + (
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
"workspace",
|
"workspace",
|
||||||
"user",
|
"user",
|
||||||
|
"user_id",
|
||||||
"role",
|
"role",
|
||||||
"is_active",
|
"is_active",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -164,7 +164,11 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
status=status.HTTP_403_FORBIDDEN,
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
payload = request.data.copy()
|
||||||
|
if payload.get("user") and not payload.get("user_id"):
|
||||||
|
payload["user_id"] = payload.get("user")
|
||||||
|
|
||||||
|
serializer = self.get_serializer(data=payload)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
membership = serializer.save()
|
membership = serializer.save()
|
||||||
notify_workspace_membership_added(
|
notify_workspace_membership_added(
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ from apps.workspaces.services.permissions import (
|
|||||||
PROJECTS_DELETE,
|
PROJECTS_DELETE,
|
||||||
PROJECTS_EDIT,
|
PROJECTS_EDIT,
|
||||||
PROJECTS_VIEW,
|
PROJECTS_VIEW,
|
||||||
PROJECT_MEMBERS_ADD,
|
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
|
||||||
PROJECT_MEMBERS_REMOVE,
|
|
||||||
PROJECT_MEMBERS_VIEW,
|
|
||||||
TAGS_CREATE,
|
TAGS_CREATE,
|
||||||
TAGS_DELETE,
|
TAGS_DELETE,
|
||||||
TAGS_EDIT,
|
TAGS_EDIT,
|
||||||
@@ -62,10 +58,6 @@ __all__ = [
|
|||||||
"PROJECTS_EDIT",
|
"PROJECTS_EDIT",
|
||||||
"PROJECTS_DELETE",
|
"PROJECTS_DELETE",
|
||||||
"PROJECTS_ARCHIVE",
|
"PROJECTS_ARCHIVE",
|
||||||
"PROJECT_MEMBERS_VIEW",
|
|
||||||
"PROJECT_MEMBERS_ADD",
|
|
||||||
"PROJECT_MEMBERS_REMOVE",
|
|
||||||
"PROJECT_MEMBERS_CHANGE_ROLE",
|
|
||||||
"TIME_ENTRIES_VIEW_OWN",
|
"TIME_ENTRIES_VIEW_OWN",
|
||||||
"TIME_ENTRIES_MANAGE_OWN",
|
"TIME_ENTRIES_MANAGE_OWN",
|
||||||
"get_workspace_membership",
|
"get_workspace_membership",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from apps.projects.models import ProjectMembership
|
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@@ -25,22 +24,9 @@ PROJECTS_CREATE = "projects.create"
|
|||||||
PROJECTS_EDIT = "projects.edit"
|
PROJECTS_EDIT = "projects.edit"
|
||||||
PROJECTS_DELETE = "projects.delete"
|
PROJECTS_DELETE = "projects.delete"
|
||||||
PROJECTS_ARCHIVE = "projects.archive"
|
PROJECTS_ARCHIVE = "projects.archive"
|
||||||
PROJECT_MEMBERS_VIEW = "project_members.view"
|
|
||||||
PROJECT_MEMBERS_ADD = "project_members.add"
|
|
||||||
PROJECT_MEMBERS_REMOVE = "project_members.remove"
|
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE = "project_members.change_role"
|
|
||||||
TIME_ENTRIES_VIEW_OWN = "time_entries.view_own"
|
TIME_ENTRIES_VIEW_OWN = "time_entries.view_own"
|
||||||
TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own"
|
TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own"
|
||||||
|
|
||||||
PROJECT_MANAGER_CAPABILITIES = {
|
|
||||||
PROJECTS_EDIT,
|
|
||||||
PROJECTS_ARCHIVE,
|
|
||||||
PROJECT_MEMBERS_VIEW,
|
|
||||||
PROJECT_MEMBERS_ADD,
|
|
||||||
PROJECT_MEMBERS_REMOVE,
|
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
|
||||||
}
|
|
||||||
|
|
||||||
WORKSPACE_ROLE_CAPABILITIES = {
|
WORKSPACE_ROLE_CAPABILITIES = {
|
||||||
WorkspaceMembership.Role.OWNER: {
|
WorkspaceMembership.Role.OWNER: {
|
||||||
WORKSPACE_VIEW,
|
WORKSPACE_VIEW,
|
||||||
@@ -64,10 +50,6 @@ WORKSPACE_ROLE_CAPABILITIES = {
|
|||||||
PROJECTS_EDIT,
|
PROJECTS_EDIT,
|
||||||
PROJECTS_DELETE,
|
PROJECTS_DELETE,
|
||||||
PROJECTS_ARCHIVE,
|
PROJECTS_ARCHIVE,
|
||||||
PROJECT_MEMBERS_VIEW,
|
|
||||||
PROJECT_MEMBERS_ADD,
|
|
||||||
PROJECT_MEMBERS_REMOVE,
|
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
|
||||||
TIME_ENTRIES_VIEW_OWN,
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
TIME_ENTRIES_MANAGE_OWN,
|
TIME_ENTRIES_MANAGE_OWN,
|
||||||
},
|
},
|
||||||
@@ -92,10 +74,6 @@ WORKSPACE_ROLE_CAPABILITIES = {
|
|||||||
PROJECTS_EDIT,
|
PROJECTS_EDIT,
|
||||||
PROJECTS_DELETE,
|
PROJECTS_DELETE,
|
||||||
PROJECTS_ARCHIVE,
|
PROJECTS_ARCHIVE,
|
||||||
PROJECT_MEMBERS_VIEW,
|
|
||||||
PROJECT_MEMBERS_ADD,
|
|
||||||
PROJECT_MEMBERS_REMOVE,
|
|
||||||
PROJECT_MEMBERS_CHANGE_ROLE,
|
|
||||||
TIME_ENTRIES_VIEW_OWN,
|
TIME_ENTRIES_VIEW_OWN,
|
||||||
TIME_ENTRIES_MANAGE_OWN,
|
TIME_ENTRIES_MANAGE_OWN,
|
||||||
},
|
},
|
||||||
@@ -149,24 +127,7 @@ def has_workspace_capability(user, workspace: Workspace, capability: str) -> boo
|
|||||||
|
|
||||||
|
|
||||||
def has_project_capability(user, project, capability: str) -> bool:
|
def has_project_capability(user, project, capability: str) -> bool:
|
||||||
if has_workspace_capability(user, project.workspace, capability):
|
return has_workspace_capability(user, project.workspace, capability)
|
||||||
return True
|
|
||||||
|
|
||||||
workspace_role = get_workspace_role(user, project.workspace)
|
|
||||||
if workspace_role not in {
|
|
||||||
WorkspaceMembership.Role.OWNER,
|
|
||||||
WorkspaceMembership.Role.ADMIN,
|
|
||||||
}:
|
|
||||||
return False
|
|
||||||
|
|
||||||
is_project_manager = ProjectMembership.objects.filter(
|
|
||||||
project=project,
|
|
||||||
user=user,
|
|
||||||
role=ProjectMembership.Role.MANAGER,
|
|
||||||
is_active=True,
|
|
||||||
is_deleted=False,
|
|
||||||
).exists()
|
|
||||||
return is_project_manager and capability in PROJECT_MANAGER_CAPABILITIES
|
|
||||||
|
|
||||||
|
|
||||||
def can_delete_workspace_object(user, obj, capability: str) -> bool:
|
def can_delete_workspace_object(user, obj, capability: str) -> bool:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from django.utils import timezone
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project, ProjectMembership
|
from apps.projects.models import Project
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
@@ -75,20 +75,7 @@ def workspace(owner, admin, member, guest):
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def project(workspace, owner, member):
|
def project(workspace, owner, member):
|
||||||
project = Project.objects.create(workspace=workspace, name="Alpha", description="")
|
return Project.objects.create(workspace=workspace, name="Alpha", 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.MANAGER,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
return project
|
|
||||||
|
|
||||||
|
|
||||||
def test_member_is_read_only_for_clients_and_projects(api_client, member, workspace, project):
|
def test_member_is_read_only_for_clients_and_projects(api_client, member, workspace, project):
|
||||||
@@ -118,16 +105,6 @@ def test_member_is_read_only_for_clients_and_projects(api_client, member, worksp
|
|||||||
)
|
)
|
||||||
archive_project_response = api_client.post(f"/api/projects/{project.id}/archive/")
|
archive_project_response = api_client.post(f"/api/projects/{project.id}/archive/")
|
||||||
delete_project_response = api_client.delete(f"/api/projects/{project.id}/")
|
delete_project_response = api_client.delete(f"/api/projects/{project.id}/")
|
||||||
membership_response = api_client.post(
|
|
||||||
"/api/memberships/",
|
|
||||||
{
|
|
||||||
"project_id": str(project.id),
|
|
||||||
"user_id": str(workspace.owner_id),
|
|
||||||
"role": ProjectMembership.Role.MEMBER,
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert client_response.status_code == 403
|
assert client_response.status_code == 403
|
||||||
assert update_client_response.status_code == 403
|
assert update_client_response.status_code == 403
|
||||||
assert delete_client_response.status_code == 403
|
assert delete_client_response.status_code == 403
|
||||||
@@ -135,7 +112,6 @@ def test_member_is_read_only_for_clients_and_projects(api_client, member, worksp
|
|||||||
assert update_project_response.status_code == 403
|
assert update_project_response.status_code == 403
|
||||||
assert archive_project_response.status_code == 403
|
assert archive_project_response.status_code == 403
|
||||||
assert delete_project_response.status_code == 403
|
assert delete_project_response.status_code == 403
|
||||||
assert membership_response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, member, workspace):
|
def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, member, workspace):
|
||||||
@@ -218,7 +194,7 @@ def test_guest_is_read_only_for_workspace_resources(api_client, owner, guest, wo
|
|||||||
assert edit_project_response.status_code == 403
|
assert edit_project_response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_member_project_manager_cannot_edit_project(api_client, member, project):
|
def test_member_cannot_edit_project(api_client, member, project):
|
||||||
api_client.force_authenticate(user=member)
|
api_client.force_authenticate(user=member)
|
||||||
|
|
||||||
response = api_client.patch(
|
response = api_client.patch(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from decimal import Decimal
|
|||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.projects.models import Project, ProjectMembership
|
from apps.projects.models import Project
|
||||||
from apps.time_entries.services.rates import resolve_rate
|
from apps.time_entries.services.rates import resolve_rate
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
@@ -39,11 +39,7 @@ def workspace(owner, admin, member):
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def project(workspace, owner, admin, member):
|
def project(workspace, owner, admin, member):
|
||||||
project = Project.objects.create(workspace=workspace, name="Billing")
|
return Project.objects.create(workspace=workspace, name="Billing")
|
||||||
ProjectMembership.objects.create(project=project, user=owner, role=ProjectMembership.Role.MANAGER, is_active=True)
|
|
||||||
ProjectMembership.objects.create(project=project, user=admin, role=ProjectMembership.Role.MANAGER, is_active=True)
|
|
||||||
ProjectMembership.objects.create(project=project, user=member, role=ProjectMembership.Role.MEMBER, is_active=True)
|
|
||||||
return project
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ AUDITLOG_INCLUDE_TRACKING_MODELS = [
|
|||||||
"serialize_data": True,
|
"serialize_data": True,
|
||||||
"serialize_auditlog_fields_only": True,
|
"serialize_auditlog_fields_only": True,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"model": "projects.ProjectMembership",
|
|
||||||
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
|
||||||
"serialize_data": True,
|
|
||||||
"serialize_auditlog_fields_only": True,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"model": "tags.Tag",
|
"model": "tags.Tag",
|
||||||
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
"exclude_fields": COMMON_EXCLUDED_FIELDS,
|
||||||
|
|||||||
Reference in New Issue
Block a user