feat(reports): refine exports and restore project access

This commit is contained in:
2026-05-14 17:06:35 +03:30
parent 77c07adec8
commit d4a52d6f3b
16 changed files with 1594 additions and 136 deletions

View File

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

View File

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

View 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')],
},
),
]

View File

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

View 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

View File

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