From e7de587f591c1fe2beb4eceb0dcf9f408d958d70 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 24 Apr 2026 22:20:57 +0330 Subject: [PATCH] feat(projects): support members and align rate payloads --- apps/projects/api/serializers.py | 79 ++++++++++++++++----------- apps/projects/api/views.py | 73 +++++++++++++++++++------ apps/projects/services/memberships.py | 6 +- apps/projects/services/projects.py | 6 ++ apps/projects/services/rates.py | 30 +++++----- 5 files changed, 129 insertions(+), 65 deletions(-) diff --git a/apps/projects/api/serializers.py b/apps/projects/api/serializers.py index d718b3b..eca1822 100644 --- a/apps/projects/api/serializers.py +++ b/apps/projects/api/serializers.py @@ -6,12 +6,18 @@ from apps.projects.models import ( ProjectRate, ProjectUserRate, ) +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): - """ - Serializer for retrieving and representing project details. - """ + my_role = serializers.SerializerMethodField() + members = serializers.SerializerMethodField() + class Meta: model = Project fields = BaseModelSerializer.Meta.fields + ( @@ -21,50 +27,61 @@ class ProjectSerializer(BaseModelSerializer): "description", "is_archived", "color", + "my_role", + "members", ) 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): representation = super().to_representation(instance) - if instance.client: representation['client'] = { 'id': instance.client.id, 'name': instance.client.name } - return representation + class ProjectCreateSerializer(serializers.Serializer): - """ - Serializer for validating input data during project creation. - We use a standard Serializer here to decouple validation from the model, - keeping business logic in the service layer. - """ workspace = serializers.UUIDField() name = serializers.CharField(max_length=255) client = serializers.UUIDField(required=False, allow_null=True) description = serializers.CharField(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): - """ - Serializer for validating input data during project updates. - """ name = serializers.CharField(max_length=255, required=False) - clien = serializers.UUIDField(required=False, allow_null=True) + client = serializers.UUIDField(required=False, allow_null=True) 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) - + 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", ) @@ -93,15 +110,15 @@ class ProjectRateSerializer(BaseModelSerializer): read_only_fields = fields -class ProjectRateCreateSerializer(serializers.Serializer): - project_id = serializers.UUIDField() - amount = serializers.DecimalField(max_digits=10, decimal_places=2) - currency = serializers.CharField(max_length=3, default="USD") - - -class ProjectRateUpdateSerializer(serializers.Serializer): - amount = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) - currency = serializers.CharField(max_length=3, required=False) +class ProjectRateCreateSerializer(serializers.Serializer): + project_id = serializers.UUIDField() + hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2) + currency = serializers.CharField(max_length=3, default="USD") + + +class ProjectRateUpdateSerializer(serializers.Serializer): + hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) + currency = serializers.CharField(max_length=3, required=False) class ProjectUserRateSerializer(BaseModelSerializer): @@ -116,13 +133,13 @@ class ProjectUserRateSerializer(BaseModelSerializer): read_only_fields = fields -class ProjectUserRateCreateSerializer(serializers.Serializer): - project_id = serializers.UUIDField() - user_id = serializers.UUIDField() - hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2) +class ProjectUserRateCreateSerializer(serializers.Serializer): + project_id = serializers.UUIDField() + user_id = serializers.UUIDField() + hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2) currency = serializers.CharField(max_length=3, default="USD") -class ProjectUserRateUpdateSerializer(serializers.Serializer): - hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) - currency = serializers.CharField(max_length=3, required=False) +class ProjectUserRateUpdateSerializer(serializers.Serializer): + hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) + currency = serializers.CharField(max_length=3, required=False) diff --git a/apps/projects/api/views.py b/apps/projects/api/views.py index 8194518..e2326ae 100644 --- a/apps/projects/api/views.py +++ b/apps/projects/api/views.py @@ -95,9 +95,13 @@ class ProjectViewSet(ModelViewSet): """ serializer = self.get_serializer(data=request.data) 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) - client = get_object_or_404(Client, id=serializer.validated_data.get("client"), is_deleted=False) + client_id = serializer.validated_data.get("client") + client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None + project = create_project( user=request.user, workspace=workspace, @@ -106,6 +110,13 @@ class ProjectViewSet(ModelViewSet): description=serializer.validated_data.get("description", ""), color=serializer.validated_data.get("color", "") ) + + for member in members_data: + add_project_member( + project=project, + user_id=member["user_id"], + role=member["role"] + ) output_serializer = ProjectSerializer(project) return Response(output_serializer.data, status=status.HTTP_201_CREATED) @@ -119,11 +130,41 @@ class ProjectViewSet(ModelViewSet): serializer = self.get_serializer(data=request.data, partial=partial) serializer.is_valid(raise_exception=True) - + + members_data = serializer.validated_data.pop("members", None) + updated_project = update_project( project=project, **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: + # Reactivate or update role + update_project_member( + current_memberships[user_id_str], + role=member['role'], + is_active=True + ) + else: + # Add new member + add_project_member( + project=updated_project, + user_id=member['user_id'], + role=member['role'] + ) + + # Deactivate omitted members + for user_id_str, membership in current_memberships.items(): + if user_id_str not in incoming_users: + update_project_member(membership, is_active=False) output_serializer = ProjectSerializer(updated_project) return Response(output_serializer.data, status=status.HTTP_200_OK) @@ -247,12 +288,12 @@ class ProjectRateViewSet(BaseProjectNestedViewSet): 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) - rate = create_project_rate( - project=project, - amount=serializer.validated_data["amount"], - currency=serializer.validated_data.get("currency", "USD") - ) + project = get_object_or_404(Project, id=project_id, is_deleted=False) + rate = create_project_rate( + project=project, + hourly_rate=serializer.validated_data["hourly_rate"], + currency=serializer.validated_data.get("currency", "USD") + ) return Response(ProjectRateSerializer(rate).data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): @@ -293,13 +334,13 @@ class ProjectUserRateViewSet(BaseProjectNestedViewSet): 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) - user_rate = create_project_user_rate( - project=project, - user_id=serializer.validated_data["user_id"], - amount=serializer.validated_data["amount"], - currency=serializer.validated_data.get("currency", "USD") - ) + project = get_object_or_404(Project, id=project_id, is_deleted=False) + user_rate = create_project_user_rate( + project=project, + user_id=serializer.validated_data["user_id"], + hourly_rate=serializer.validated_data["hourly_rate"], + currency=serializer.validated_data.get("currency", "USD") + ) return Response(ProjectUserRateSerializer(user_rate).data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): diff --git a/apps/projects/services/memberships.py b/apps/projects/services/memberships.py index eb6dbb6..d60f647 100644 --- a/apps/projects/services/memberships.py +++ b/apps/projects/services/memberships.py @@ -1,16 +1,16 @@ from rest_framework.exceptions import ValidationError from apps.projects.models import ProjectMembership -def add_project_member(project, user, role): +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=user, is_deleted=False).exists(): + 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=user, + user_id=user_id, role=role, is_active=True ) diff --git a/apps/projects/services/projects.py b/apps/projects/services/projects.py index ef1820c..f71c128 100644 --- a/apps/projects/services/projects.py +++ b/apps/projects/services/projects.py @@ -1,6 +1,8 @@ from django.db import transaction +from django.shortcuts import get_object_or_404 from rest_framework.exceptions import ValidationError, PermissionDenied +from apps.clients.models import Client from apps.projects.models import Project, ProjectMembership from apps.workspaces.models import WorkspaceMembership @@ -53,6 +55,10 @@ def update_project(project, **kwargs): if Project.objects.filter(workspace=project.workspace, name=kwargs["name"], is_deleted=False).exists(): raise ValidationError({"name": "A project with this name already exists in the workspace."}) + client_id = kwargs.pop("client") + client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None + kwargs["client"] = client + for field, value in kwargs.items(): if hasattr(project, field) and getattr(project, field) != value: setattr(project, field, value) diff --git a/apps/projects/services/rates.py b/apps/projects/services/rates.py index a3b4b10..bd81027 100644 --- a/apps/projects/services/rates.py +++ b/apps/projects/services/rates.py @@ -1,11 +1,11 @@ -from apps.projects.models import ProjectRate, ProjectUserRate - -def create_project_rate(project, amount, currency="USD"): - return ProjectRate.objects.create( - project=project, - amount=amount, - currency=currency - ) +from apps.projects.models import ProjectRate, ProjectUserRate + +def create_project_rate(project, hourly_rate, currency="USD"): + return ProjectRate.objects.create( + project=project, + hourly_rate=hourly_rate, + currency=currency + ) def update_project_rate(rate_instance, **kwargs): update_fields = [] @@ -20,13 +20,13 @@ def update_project_rate(rate_instance, **kwargs): return rate_instance -def create_project_user_rate(project, user, amount, currency="USD"): - return ProjectUserRate.objects.create( - project=project, - user=user, - amount=amount, - currency=currency - ) +def create_project_user_rate(project, user_id, hourly_rate, currency="USD"): + return ProjectUserRate.objects.create( + project=project, + user_id=user_id, + hourly_rate=hourly_rate, + currency=currency + ) def update_project_user_rate(user_rate_instance, **kwargs): update_fields = []