feat(projects): support members and align rate payloads

This commit is contained in:
2026-04-24 22:20:57 +03:30
parent a44995017b
commit e7de587f59
5 changed files with 129 additions and 65 deletions

View File

@@ -6,12 +6,18 @@ from apps.projects.models import (
ProjectRate, ProjectRate,
ProjectUserRate, 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): class ProjectSerializer(BaseModelSerializer):
""" my_role = serializers.SerializerMethodField()
Serializer for retrieving and representing project details. members = serializers.SerializerMethodField()
"""
class Meta: class Meta:
model = Project model = Project
fields = BaseModelSerializer.Meta.fields + ( fields = BaseModelSerializer.Meta.fields + (
@@ -21,50 +27,61 @@ 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:
representation['client'] = { representation['client'] = {
'id': instance.client.id, 'id': instance.client.id,
'name': instance.client.name 'name': instance.client.name
} }
return representation return representation
class ProjectCreateSerializer(serializers.Serializer): 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() workspace = serializers.UUIDField()
name = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255)
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):
"""
Serializer for validating input data during project updates.
"""
name = serializers.CharField(max_length=255, required=False) 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) 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): class ProjectMembershipSerializer(BaseModelSerializer):
user_details = UserMiniSerializer(read_only=True)
class Meta: class Meta:
model = ProjectMembership model = ProjectMembership
fields = BaseModelSerializer.Meta.fields + ( fields = BaseModelSerializer.Meta.fields + (
"project", "project",
"user", "user",
"user_details",
"role", "role",
"is_active", "is_active",
) )
@@ -93,15 +110,15 @@ class ProjectRateSerializer(BaseModelSerializer):
read_only_fields = fields read_only_fields = fields
class ProjectRateCreateSerializer(serializers.Serializer): class ProjectRateCreateSerializer(serializers.Serializer):
project_id = serializers.UUIDField() project_id = serializers.UUIDField()
amount = serializers.DecimalField(max_digits=10, decimal_places=2) hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2)
currency = serializers.CharField(max_length=3, default="USD") currency = serializers.CharField(max_length=3, default="USD")
class ProjectRateUpdateSerializer(serializers.Serializer): class ProjectRateUpdateSerializer(serializers.Serializer):
amount = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
currency = serializers.CharField(max_length=3, required=False) currency = serializers.CharField(max_length=3, required=False)
class ProjectUserRateSerializer(BaseModelSerializer): class ProjectUserRateSerializer(BaseModelSerializer):
@@ -116,13 +133,13 @@ class ProjectUserRateSerializer(BaseModelSerializer):
read_only_fields = fields read_only_fields = fields
class ProjectUserRateCreateSerializer(serializers.Serializer): class ProjectUserRateCreateSerializer(serializers.Serializer):
project_id = serializers.UUIDField() project_id = serializers.UUIDField()
user_id = serializers.UUIDField() user_id = serializers.UUIDField()
hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2) hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2)
currency = serializers.CharField(max_length=3, default="USD") currency = serializers.CharField(max_length=3, default="USD")
class ProjectUserRateUpdateSerializer(serializers.Serializer): class ProjectUserRateUpdateSerializer(serializers.Serializer):
hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2, required=False) hourly_rate = serializers.DecimalField(max_digits=10, decimal_places=2, required=False)
currency = serializers.CharField(max_length=3, required=False) currency = serializers.CharField(max_length=3, required=False)

View File

@@ -95,9 +95,13 @@ 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)
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( project = create_project(
user=request.user, user=request.user,
workspace=workspace, workspace=workspace,
@@ -106,6 +110,13 @@ class ProjectViewSet(ModelViewSet):
description=serializer.validated_data.get("description", ""), description=serializer.validated_data.get("description", ""),
color=serializer.validated_data.get("color", "") 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) output_serializer = ProjectSerializer(project)
return Response(output_serializer.data, status=status.HTTP_201_CREATED) 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 = 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:
# 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) 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)
@@ -247,12 +288,12 @@ class ProjectRateViewSet(BaseProjectNestedViewSet):
project_id = serializer.validated_data["project_id"] project_id = serializer.validated_data["project_id"]
self.verify_manager_access(project_id) self.verify_manager_access(project_id)
project = get_object_or_404(Project, id=project_id, is_deleted=False) project = get_object_or_404(Project, id=project_id, is_deleted=False)
rate = create_project_rate( rate = create_project_rate(
project=project, project=project,
amount=serializer.validated_data["amount"], hourly_rate=serializer.validated_data["hourly_rate"],
currency=serializer.validated_data.get("currency", "USD") currency=serializer.validated_data.get("currency", "USD")
) )
return Response(ProjectRateSerializer(rate).data, status=status.HTTP_201_CREATED) return Response(ProjectRateSerializer(rate).data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
@@ -293,13 +334,13 @@ class ProjectUserRateViewSet(BaseProjectNestedViewSet):
project_id = serializer.validated_data["project_id"] project_id = serializer.validated_data["project_id"]
self.verify_manager_access(project_id) self.verify_manager_access(project_id)
project = get_object_or_404(Project, id=project_id, is_deleted=False) project = get_object_or_404(Project, id=project_id, is_deleted=False)
user_rate = create_project_user_rate( user_rate = create_project_user_rate(
project=project, project=project,
user_id=serializer.validated_data["user_id"], user_id=serializer.validated_data["user_id"],
amount=serializer.validated_data["amount"], hourly_rate=serializer.validated_data["hourly_rate"],
currency=serializer.validated_data.get("currency", "USD") currency=serializer.validated_data.get("currency", "USD")
) )
return Response(ProjectUserRateSerializer(user_rate).data, status=status.HTTP_201_CREATED) return Response(ProjectUserRateSerializer(user_rate).data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):

View File

@@ -1,16 +1,16 @@
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from apps.projects.models import ProjectMembership 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. 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."}) raise ValidationError({"user_id": "This user is already a member of the project."})
return ProjectMembership.objects.create( return ProjectMembership.objects.create(
project=project, project=project,
user=user, user_id=user_id,
role=role, role=role,
is_active=True is_active=True
) )

View File

@@ -1,6 +1,8 @@
from django.db import transaction from django.db import transaction
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.projects.models import Project, ProjectMembership from apps.projects.models import Project, ProjectMembership
from apps.workspaces.models import WorkspaceMembership 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(): 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."}) 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(): for field, value in kwargs.items():
if hasattr(project, field) and getattr(project, field) != value: if hasattr(project, field) and getattr(project, field) != value:
setattr(project, field, value) setattr(project, field, value)

View File

@@ -1,11 +1,11 @@
from apps.projects.models import ProjectRate, ProjectUserRate from apps.projects.models import ProjectRate, ProjectUserRate
def create_project_rate(project, amount, currency="USD"): def create_project_rate(project, hourly_rate, currency="USD"):
return ProjectRate.objects.create( return ProjectRate.objects.create(
project=project, project=project,
amount=amount, hourly_rate=hourly_rate,
currency=currency currency=currency
) )
def update_project_rate(rate_instance, **kwargs): def update_project_rate(rate_instance, **kwargs):
update_fields = [] update_fields = []
@@ -20,13 +20,13 @@ def update_project_rate(rate_instance, **kwargs):
return rate_instance return rate_instance
def create_project_user_rate(project, user, amount, currency="USD"): def create_project_user_rate(project, user_id, hourly_rate, currency="USD"):
return ProjectUserRate.objects.create( return ProjectUserRate.objects.create(
project=project, project=project,
user=user, user_id=user_id,
amount=amount, hourly_rate=hourly_rate,
currency=currency currency=currency
) )
def update_project_user_rate(user_rate_instance, **kwargs): def update_project_user_rate(user_rate_instance, **kwargs):
update_fields = [] update_fields = []