feat(projects): support members and align rate payloads
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
Reference in New Issue
Block a user