feat(reports): refine exports and restore project access
This commit is contained in:
@@ -40,3 +40,17 @@ class ProjectUpdateSerializer(serializers.Serializer):
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
color = serializers.CharField(max_length=7, required=False, allow_blank=True)
|
||||
is_archived = serializers.BooleanField(required=False)
|
||||
|
||||
|
||||
class ProjectAccessQuerySerializer(serializers.Serializer):
|
||||
workspace = serializers.UUIDField()
|
||||
user = serializers.UUIDField()
|
||||
|
||||
|
||||
class ProjectAccessMutationSerializer(serializers.Serializer):
|
||||
workspace = serializers.UUIDField()
|
||||
user = serializers.UUIDField()
|
||||
project_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
allow_empty=False,
|
||||
)
|
||||
|
||||
@@ -15,8 +15,17 @@ from apps.clients.models import Client
|
||||
from apps.projects.models import Project
|
||||
from apps.projects.api.serializers import (
|
||||
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
||||
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
|
||||
)
|
||||
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
||||
from apps.projects.services.access import (
|
||||
build_project_access_items,
|
||||
ensure_workspace_project_access,
|
||||
filter_projects_for_user,
|
||||
get_access_managed_membership,
|
||||
grant_project_accesses,
|
||||
revoke_project_accesses,
|
||||
)
|
||||
from apps.projects.services.projects import (
|
||||
create_project,
|
||||
update_project,
|
||||
@@ -67,11 +76,10 @@ class ProjectViewSet(ModelViewSet):
|
||||
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
|
||||
return Project.objects.none()
|
||||
|
||||
queryset = Project.objects.filter(
|
||||
workspace__memberships__user=self.request.user,
|
||||
workspace__memberships__is_active=True,
|
||||
is_deleted=False
|
||||
).distinct()
|
||||
queryset = filter_projects_for_user(
|
||||
self.request.user,
|
||||
Project.objects.filter(is_deleted=False),
|
||||
)
|
||||
|
||||
client_ids = [client_id for client_id in self.request.query_params.getlist("clients") if client_id]
|
||||
if client_ids:
|
||||
@@ -150,12 +158,76 @@ class ProjectViewSet(ModelViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def archive(self, request, pk=None):
|
||||
"""
|
||||
Custom endpoint to toggle the archive status of a project.
|
||||
"""
|
||||
project = self.get_object()
|
||||
def archive(self, request, pk=None):
|
||||
"""
|
||||
Custom endpoint to toggle the archive status of a project.
|
||||
"""
|
||||
project = self.get_object()
|
||||
updated_project = toggle_project_archive(project)
|
||||
|
||||
output_serializer = ProjectSerializer(updated_project)
|
||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="access")
|
||||
def access(self, request):
|
||||
serializer = ProjectAccessQuerySerializer(data=request.query_params)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
workspace = get_object_or_404(
|
||||
Workspace,
|
||||
id=serializer.validated_data["workspace"],
|
||||
is_deleted=False,
|
||||
)
|
||||
ensure_workspace_project_access(request.user, workspace)
|
||||
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
|
||||
|
||||
return Response(
|
||||
{
|
||||
"workspace": {"id": str(workspace.id), "name": workspace.name},
|
||||
"user": {
|
||||
"id": str(membership.user_id),
|
||||
"name": membership.user.full_name or membership.user.mobile,
|
||||
"mobile": membership.user.mobile,
|
||||
"role": membership.role,
|
||||
},
|
||||
"items": build_project_access_items(workspace=workspace, target_user=membership.user),
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="access/grant")
|
||||
def grant_access(self, request):
|
||||
serializer = ProjectAccessMutationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
workspace = get_object_or_404(
|
||||
Workspace,
|
||||
id=serializer.validated_data["workspace"],
|
||||
is_deleted=False,
|
||||
)
|
||||
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
|
||||
changed = grant_project_accesses(
|
||||
actor=request.user,
|
||||
workspace=workspace,
|
||||
target_user=membership.user,
|
||||
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
|
||||
)
|
||||
return Response({"changed": changed}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="access/revoke")
|
||||
def revoke_access(self, request):
|
||||
serializer = ProjectAccessMutationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
workspace = get_object_or_404(
|
||||
Workspace,
|
||||
id=serializer.validated_data["workspace"],
|
||||
is_deleted=False,
|
||||
)
|
||||
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
|
||||
changed = revoke_project_accesses(
|
||||
actor=request.user,
|
||||
workspace=workspace,
|
||||
target_user=membership.user,
|
||||
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
|
||||
)
|
||||
return Response({"changed": changed}, status=status.HTTP_200_OK)
|
||||
|
||||
38
apps/projects/migrations/0004_projectaccess.py
Normal file
38
apps/projects/migrations/0004_projectaccess.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.12 on 2026-05-13 12:30
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0003_project_project_ws_arch_upd_idx'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProjectAccess',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('is_active', models.BooleanField(default=False)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_memberships', to='projects.project')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_accesses', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'project_access',
|
||||
'ordering': ('-created_at',),
|
||||
'indexes': [models.Index(fields=['project'], name='project_access_project_idx'), models.Index(fields=['user'], name='project_access_user_idx')],
|
||||
'constraints': [models.UniqueConstraint(condition=models.Q(('is_deleted', False)), fields=('project', 'user'), name='unique_project_access')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -130,3 +130,31 @@ class ProjectUserRate(BaseModel):
|
||||
models.Index(fields=["project"], name="pur_project_idx"),
|
||||
models.Index(fields=["user"], name="pur_user_idx"),
|
||||
]
|
||||
|
||||
|
||||
class ProjectAccess(BaseModel):
|
||||
project = models.ForeignKey(
|
||||
Project,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="access_memberships",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="project_accesses",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "project_access"
|
||||
ordering = ("-created_at",)
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "user"],
|
||||
name="unique_project_access",
|
||||
condition=models.Q(is_deleted=False),
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["project"], name="project_access_project_idx"),
|
||||
models.Index(fields=["user"], name="project_access_user_idx"),
|
||||
]
|
||||
|
||||
144
apps/projects/services/access.py
Normal file
144
apps/projects/services/access.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
|
||||
from apps.projects.models import Project, ProjectAccess
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||
from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
PROJECT_ACCESS_MANAGED_ROLES = {
|
||||
WorkspaceMembership.Role.MEMBER,
|
||||
WorkspaceMembership.Role.GUEST,
|
||||
}
|
||||
PROJECT_ACCESS_IMPLICIT_ROLES = {
|
||||
WorkspaceMembership.Role.OWNER,
|
||||
WorkspaceMembership.Role.ADMIN,
|
||||
}
|
||||
|
||||
|
||||
def user_has_implicit_project_access(user, workspace: Workspace) -> bool:
|
||||
return get_workspace_role(user, workspace) in PROJECT_ACCESS_IMPLICIT_ROLES
|
||||
|
||||
|
||||
def user_has_project_access(user, project: Project) -> bool:
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user_has_implicit_project_access(user, project.workspace):
|
||||
return True
|
||||
return ProjectAccess.objects.filter(project=project, user=user).exists()
|
||||
|
||||
|
||||
def filter_projects_for_user(user, queryset: QuerySet[Project] | None = None) -> QuerySet[Project]:
|
||||
if queryset is None:
|
||||
queryset = Project.objects.all()
|
||||
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return queryset.none()
|
||||
|
||||
return queryset.filter(
|
||||
Q(workspace__owner=user)
|
||||
| Q(
|
||||
workspace__memberships__user=user,
|
||||
workspace__memberships__is_active=True,
|
||||
workspace__memberships__role__in=PROJECT_ACCESS_IMPLICIT_ROLES,
|
||||
)
|
||||
| Q(
|
||||
workspace__memberships__user=user,
|
||||
workspace__memberships__is_active=True,
|
||||
workspace__memberships__role__in=PROJECT_ACCESS_MANAGED_ROLES,
|
||||
access_memberships__user=user,
|
||||
)
|
||||
).distinct()
|
||||
|
||||
|
||||
def ensure_project_access(user, project: Project, *, message: str = "Selected project is unavailable.") -> None:
|
||||
if not user_has_project_access(user, project):
|
||||
raise ValidationError({"project_id": message})
|
||||
|
||||
|
||||
def ensure_workspace_project_access(user, workspace: Workspace) -> None:
|
||||
if not has_workspace_capability(user, workspace, PROJECTS_EDIT):
|
||||
raise PermissionDenied("You do not have permission to manage project access in this workspace.")
|
||||
|
||||
|
||||
def get_access_managed_membership(workspace: Workspace, user_id: str) -> WorkspaceMembership:
|
||||
membership = WorkspaceMembership.objects.filter(
|
||||
workspace=workspace,
|
||||
user_id=user_id,
|
||||
is_active=True,
|
||||
is_deleted=False,
|
||||
).select_related("user").first()
|
||||
if not membership:
|
||||
raise ValidationError({"user": "Selected user is not an active member of this workspace."})
|
||||
if membership.role not in PROJECT_ACCESS_MANAGED_ROLES:
|
||||
raise ValidationError({"user": "Owners and admins have implicit access to all projects."})
|
||||
return membership
|
||||
|
||||
|
||||
def build_project_access_items(*, workspace: Workspace, target_user) -> list[dict]:
|
||||
explicit_access_ids = set(
|
||||
ProjectAccess.objects.filter(project__workspace=workspace, user=target_user).values_list("project_id", flat=True)
|
||||
)
|
||||
projects = (
|
||||
Project.objects.filter(workspace=workspace, is_deleted=False)
|
||||
.select_related("client")
|
||||
.order_by("client__name", "name")
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"color": project.color,
|
||||
"is_archived": project.is_archived,
|
||||
"client": (
|
||||
{"id": str(project.client_id), "name": project.client.name}
|
||||
if project.client_id and project.client
|
||||
else None
|
||||
),
|
||||
"has_access": str(project.id) in {str(project_id) for project_id in explicit_access_ids},
|
||||
}
|
||||
for project in projects
|
||||
]
|
||||
|
||||
|
||||
def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
|
||||
ensure_workspace_project_access(actor, workspace)
|
||||
get_access_managed_membership(workspace, str(target_user.id))
|
||||
|
||||
projects = list(Project.objects.filter(workspace=workspace, id__in=project_ids, is_deleted=False))
|
||||
if len(projects) != len(set(project_ids)):
|
||||
raise ValidationError({"project_ids": "One or more selected projects do not belong to this workspace."})
|
||||
|
||||
changed = 0
|
||||
for project in projects:
|
||||
access, created, restored = ProjectAccess.get_or_restore(project=project, user=target_user)
|
||||
if created or restored:
|
||||
access.is_active = True
|
||||
access.updated_at = timezone.now()
|
||||
access.save(update_fields=["is_active", "updated_at"])
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def revoke_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
|
||||
ensure_workspace_project_access(actor, workspace)
|
||||
get_access_managed_membership(workspace, str(target_user.id))
|
||||
|
||||
accesses = list(
|
||||
ProjectAccess.objects.filter(
|
||||
project__workspace=workspace,
|
||||
user=target_user,
|
||||
project_id__in=project_ids,
|
||||
).select_related("project")
|
||||
)
|
||||
changed = 0
|
||||
for access in accesses:
|
||||
access.delete()
|
||||
changed += 1
|
||||
return changed
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from apps.clients.models import Client
|
||||
from apps.projects.models import Project
|
||||
from apps.projects.models import Project, ProjectAccess
|
||||
from apps.users.models import User
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||
|
||||
@@ -34,16 +34,19 @@ class ProjectViewTests(APITestCase):
|
||||
client=cls.first_client,
|
||||
name="Alpha",
|
||||
)
|
||||
Project.objects.create(
|
||||
cls.second_project = Project.objects.create(
|
||||
workspace=cls.workspace,
|
||||
client=cls.second_client,
|
||||
name="Beta",
|
||||
)
|
||||
Project.objects.create(
|
||||
cls.third_project = Project.objects.create(
|
||||
workspace=cls.workspace,
|
||||
client=cls.third_client,
|
||||
name="Gamma",
|
||||
)
|
||||
cls.first_project = Project.objects.get(name="Alpha")
|
||||
ProjectAccess.objects.create(project=cls.first_project, user=cls.member)
|
||||
ProjectAccess.objects.create(project=cls.second_project, user=cls.member)
|
||||
|
||||
def test_project_list_supports_multi_client_filter(self):
|
||||
self.client.force_authenticate(user=self.member)
|
||||
@@ -68,3 +71,46 @@ class ProjectViewTests(APITestCase):
|
||||
result_ids,
|
||||
{str(self.first_client.id), str(self.second_client.id)},
|
||||
)
|
||||
|
||||
def test_project_access_list_and_mutations_require_explicit_member_access(self):
|
||||
self.client.force_authenticate(user=self.owner)
|
||||
|
||||
access_response = self.client.get(
|
||||
"/api/projects/access/",
|
||||
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
|
||||
)
|
||||
|
||||
self.assertEqual(access_response.status_code, 200)
|
||||
items = access_response.data["items"]
|
||||
gamma_item = next(item for item in items if item["id"] == str(self.third_project.id))
|
||||
self.assertFalse(gamma_item["has_access"])
|
||||
|
||||
grant_response = self.client.post(
|
||||
"/api/projects/access/grant/",
|
||||
{
|
||||
"workspace": str(self.workspace.id),
|
||||
"user": str(self.member.id),
|
||||
"project_ids": [str(self.third_project.id)],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(grant_response.status_code, 200)
|
||||
|
||||
access_response = self.client.get(
|
||||
"/api/projects/access/",
|
||||
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
|
||||
)
|
||||
gamma_item = next(item for item in access_response.data["items"] if item["id"] == str(self.third_project.id))
|
||||
self.assertTrue(gamma_item["has_access"])
|
||||
|
||||
revoke_response = self.client.post(
|
||||
"/api/projects/access/revoke/",
|
||||
{
|
||||
"workspace": str(self.workspace.id),
|
||||
"user": str(self.member.id),
|
||||
"project_ids": [str(self.first_project.id)],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(revoke_response.status_code, 200)
|
||||
self.assertFalse(ProjectAccess.objects.filter(project=self.first_project, user=self.member).exists())
|
||||
|
||||
Reference in New Issue
Block a user