feat(projects): add projects app's basic structure and endpoints
This commit is contained in:
68
apps/projects/admin.py
Normal file
68
apps/projects/admin.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from core.admins.base import BaseAdmin
|
||||||
|
from apps.projects.models import Project, ProjectMembership
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMembershipInline(admin.TabularInline):
|
||||||
|
model = ProjectMembership
|
||||||
|
extra = 0
|
||||||
|
autocomplete_fields = ("user",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Project)
|
||||||
|
class ProjectAdmin(BaseAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"workspace",
|
||||||
|
"client",
|
||||||
|
"is_archived",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
list_filter = (
|
||||||
|
"workspace",
|
||||||
|
"is_archived",
|
||||||
|
"is_deleted",
|
||||||
|
)
|
||||||
|
|
||||||
|
search_fields = (
|
||||||
|
"name",
|
||||||
|
"workspace__name",
|
||||||
|
"client__name",
|
||||||
|
)
|
||||||
|
|
||||||
|
autocomplete_fields = (
|
||||||
|
"workspace",
|
||||||
|
"client",
|
||||||
|
)
|
||||||
|
|
||||||
|
inlines = (ProjectMembershipInline,)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ProjectMembership)
|
||||||
|
class ProjectMembershipAdmin(BaseAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"project",
|
||||||
|
"user",
|
||||||
|
"role",
|
||||||
|
"is_active",
|
||||||
|
)
|
||||||
|
|
||||||
|
list_filter = (
|
||||||
|
"role",
|
||||||
|
"is_active",
|
||||||
|
"is_deleted",
|
||||||
|
)
|
||||||
|
|
||||||
|
search_fields = (
|
||||||
|
"project__name",
|
||||||
|
"user__mobile",
|
||||||
|
)
|
||||||
|
|
||||||
|
autocomplete_fields = (
|
||||||
|
"project",
|
||||||
|
"user",
|
||||||
|
)
|
||||||
49
apps/projects/api/permissions.py
Normal file
49
apps/projects/api/permissions.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
from apps.projects.models import ProjectMembership
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_from_obj(obj):
|
||||||
|
"""Helper to extract the project from different model types."""
|
||||||
|
# If the object is a Project, it will have a 'workspace' attribute.
|
||||||
|
# Otherwise, it's a related model (Membership, Rate) and has a 'project' attribute.
|
||||||
|
return obj if hasattr(obj, "workspace") else obj.project
|
||||||
|
|
||||||
|
|
||||||
|
class IsProjectMember(permissions.BasePermission):
|
||||||
|
"""
|
||||||
|
Allows access only to users who have an active membership in the project.
|
||||||
|
"""
|
||||||
|
message = "شما عضو این پروژه نیستید."
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
project = get_project_from_obj(obj)
|
||||||
|
return ProjectMembership.objects.filter(
|
||||||
|
project=project,
|
||||||
|
user=request.user,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
class IsProjectManager(permissions.BasePermission):
|
||||||
|
"""
|
||||||
|
Allows access only to users who are active MANAGERs of the project.
|
||||||
|
"""
|
||||||
|
message = "فقط مدیران پروژه مجاز به انجام این عملیات هستند."
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
project = get_project_from_obj(obj)
|
||||||
|
return ProjectMembership.objects.filter(
|
||||||
|
project=project,
|
||||||
|
user=request.user,
|
||||||
|
role=ProjectMembership.Role.MANAGER,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).exists()
|
||||||
118
apps/projects/api/serializers.py
Normal file
118
apps/projects/api/serializers.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from core.serializers.base import BaseModelSerializer
|
||||||
|
from apps.projects.models import (
|
||||||
|
Project,
|
||||||
|
ProjectMembership,
|
||||||
|
ProjectRate,
|
||||||
|
ProjectUserRate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSerializer(BaseModelSerializer):
|
||||||
|
"""
|
||||||
|
Serializer for retrieving and representing project details.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = Project
|
||||||
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
|
"workspace",
|
||||||
|
"name",
|
||||||
|
"client",
|
||||||
|
"description",
|
||||||
|
"is_archived",
|
||||||
|
"color",
|
||||||
|
)
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
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_id = serializers.UUIDField()
|
||||||
|
name = serializers.CharField(max_length=255)
|
||||||
|
client_id = 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="")
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUpdateSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for validating input data during project updates.
|
||||||
|
"""
|
||||||
|
name = serializers.CharField(max_length=255, required=False)
|
||||||
|
client_id = 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)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMembershipSerializer(BaseModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ProjectMembership
|
||||||
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
|
"project",
|
||||||
|
"user",
|
||||||
|
"role",
|
||||||
|
"is_active",
|
||||||
|
)
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMembershipCreateSerializer(serializers.Serializer):
|
||||||
|
project_id = serializers.UUIDField()
|
||||||
|
user_id = serializers.UUIDField()
|
||||||
|
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMembershipUpdateSerializer(serializers.Serializer):
|
||||||
|
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, required=False)
|
||||||
|
is_active = serializers.BooleanField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectRateSerializer(BaseModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ProjectRate
|
||||||
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
|
"project",
|
||||||
|
"hourly_rate",
|
||||||
|
"currency",
|
||||||
|
)
|
||||||
|
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 ProjectUserRateSerializer(BaseModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ProjectUserRate
|
||||||
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
|
"project",
|
||||||
|
"user",
|
||||||
|
"hourly_rate",
|
||||||
|
"currency",
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
21
apps/projects/api/urls.py
Normal file
21
apps/projects/api/urls.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.projects.api.views import (
|
||||||
|
ProjectViewSet,
|
||||||
|
ProjectMembershipViewSet,
|
||||||
|
ProjectRateViewSet,
|
||||||
|
ProjectUserRateViewSet
|
||||||
|
)
|
||||||
|
|
||||||
|
app_name = "projects"
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"projects", ProjectViewSet, basename="project")
|
||||||
|
router.register(r"memberships", ProjectMembershipViewSet, basename="membership")
|
||||||
|
router.register(r"rates", ProjectRateViewSet, basename="rate")
|
||||||
|
router.register(r"user-rates", ProjectUserRateViewSet, basename="user-rate")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
313
apps/projects/api/views.py
Normal file
313
apps/projects/api/views.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
|
||||||
|
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
||||||
|
|
||||||
|
from apps.projects.models import (
|
||||||
|
Project,
|
||||||
|
ProjectMembership,
|
||||||
|
ProjectRate,
|
||||||
|
ProjectUserRate,
|
||||||
|
)
|
||||||
|
from apps.projects.api.serializers import (
|
||||||
|
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
||||||
|
ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer,
|
||||||
|
ProjectRateSerializer, ProjectRateCreateSerializer, ProjectRateUpdateSerializer,
|
||||||
|
ProjectUserRateSerializer, ProjectUserRateCreateSerializer, ProjectUserRateUpdateSerializer
|
||||||
|
)
|
||||||
|
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
||||||
|
from apps.projects.services.projects import (
|
||||||
|
create_project,
|
||||||
|
update_project,
|
||||||
|
toggle_project_archive
|
||||||
|
)
|
||||||
|
from apps.projects.services.memberships import add_project_member, update_project_member
|
||||||
|
from apps.projects.services.rates import (
|
||||||
|
create_project_rate, update_project_rate,
|
||||||
|
create_project_user_rate, update_project_user_rate
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectViewSet(ModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoints for managing projects.
|
||||||
|
"""
|
||||||
|
pagination_class = CustomLimitOffsetPagination
|
||||||
|
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
|
filterset_fields = ["workspace", "client", "is_archived"]
|
||||||
|
search_fields = ["name", "description"]
|
||||||
|
ordering_fields = ["name", "created_at", "updated_at"]
|
||||||
|
ordering = ["-updated_at", "-created_at"]
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
"""
|
||||||
|
Instantiates and returns the list of permissions that this view requires.
|
||||||
|
- Managers can update, delete, or archive.
|
||||||
|
- Members can retrieve/view.
|
||||||
|
- Any authenticated user can list (filtered to their memberships) or attempt to create.
|
||||||
|
"""
|
||||||
|
if self.action in ["update", "partial_update", "destroy", "archive"]:
|
||||||
|
permission_classes = [IsAuthenticated, IsProjectManager]
|
||||||
|
elif self.action in ["retrieve"]:
|
||||||
|
permission_classes = [IsAuthenticated, IsProjectMember]
|
||||||
|
else:
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
return [permission() for permission in permission_classes]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Returns active projects where the current user is an active member.
|
||||||
|
"""
|
||||||
|
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
|
||||||
|
return Project.objects.none()
|
||||||
|
|
||||||
|
return Project.objects.filter(
|
||||||
|
memberships__user=self.request.user,
|
||||||
|
memberships__is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
"""
|
||||||
|
Selects the appropriate serializer based on the request action.
|
||||||
|
"""
|
||||||
|
if self.action == "create":
|
||||||
|
return ProjectCreateSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return ProjectUpdateSerializer
|
||||||
|
return ProjectSerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Creates a new project using the project service layer.
|
||||||
|
"""
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
project = create_project(
|
||||||
|
user=request.user,
|
||||||
|
workspace_id=serializer.validated_data["workspace_id"],
|
||||||
|
name=serializer.validated_data["name"],
|
||||||
|
client_id=serializer.validated_data.get("client_id"),
|
||||||
|
description=serializer.validated_data.get("description", ""),
|
||||||
|
color=serializer.validated_data.get("color", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
output_serializer = ProjectSerializer(project)
|
||||||
|
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Updates an existing project using the project service layer.
|
||||||
|
"""
|
||||||
|
partial = kwargs.pop("partial", False)
|
||||||
|
project = self.get_object()
|
||||||
|
|
||||||
|
serializer = self.get_serializer(data=request.data, partial=partial)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
updated_project = update_project(
|
||||||
|
project=project,
|
||||||
|
**serializer.validated_data
|
||||||
|
)
|
||||||
|
|
||||||
|
output_serializer = ProjectSerializer(updated_project)
|
||||||
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Soft deletes a project.
|
||||||
|
"""
|
||||||
|
project = self.get_object()
|
||||||
|
project.is_deleted = True
|
||||||
|
project.save(update_fields=["is_deleted", "updated_at"])
|
||||||
|
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()
|
||||||
|
updated_project = toggle_project_archive(project)
|
||||||
|
|
||||||
|
output_serializer = ProjectSerializer(updated_project)
|
||||||
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseProjectNestedViewSet(ModelViewSet):
|
||||||
|
"""
|
||||||
|
Base ViewSet for nested project models to share common permission and queryset logic.
|
||||||
|
"""
|
||||||
|
pagination_class = CustomLimitOffsetPagination
|
||||||
|
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
||||||
|
ordering = ["-updated_at", "-created_at"]
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
if self.action in ["create", "update", "partial_update", "destroy"]:
|
||||||
|
permission_classes = [IsAuthenticated, IsProjectManager]
|
||||||
|
else:
|
||||||
|
permission_classes = [IsAuthenticated, IsProjectMember]
|
||||||
|
return [permission() for permission in permission_classes]
|
||||||
|
|
||||||
|
def verify_manager_access(self, project_id):
|
||||||
|
"""Helper to verify if the requesting user is a manager of the target project."""
|
||||||
|
is_manager = ProjectMembership.objects.filter(
|
||||||
|
project_id=project_id,
|
||||||
|
user=self.request.user,
|
||||||
|
role=ProjectMembership.Role.MANAGER,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).exists()
|
||||||
|
if not is_manager:
|
||||||
|
raise PermissionDenied("You must be a project manager to perform this action.")
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMembershipViewSet(BaseProjectNestedViewSet):
|
||||||
|
filterset_fields = ["project", "user", "role", "is_active"]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
|
||||||
|
return ProjectMembership.objects.none()
|
||||||
|
return ProjectMembership.objects.filter(
|
||||||
|
project__memberships__user=self.request.user,
|
||||||
|
project__memberships__is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create": return ProjectMembershipCreateSerializer
|
||||||
|
if self.action in ["update", "partial_update"]: return ProjectMembershipUpdateSerializer
|
||||||
|
return ProjectMembershipSerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
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)
|
||||||
|
membership = add_project_member(
|
||||||
|
project=project,
|
||||||
|
user_id=serializer.validated_data["user_id"],
|
||||||
|
role=serializer.validated_data["role"]
|
||||||
|
)
|
||||||
|
return Response(ProjectMembershipSerializer(membership).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
membership = self.get_object()
|
||||||
|
serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False))
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
updated_membership = update_project_member(membership, **serializer.validated_data)
|
||||||
|
return Response(ProjectMembershipSerializer(updated_membership).data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
membership = self.get_object()
|
||||||
|
membership.is_deleted = True
|
||||||
|
membership.save(update_fields=["is_deleted", "updated_at"])
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectRateViewSet(BaseProjectNestedViewSet):
|
||||||
|
filterset_fields = ["project", "currency"]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if not self.request.user.is_authenticated: return ProjectRate.objects.none()
|
||||||
|
return ProjectRate.objects.filter(
|
||||||
|
project__memberships__user=self.request.user,
|
||||||
|
project__memberships__is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create": return ProjectRateCreateSerializer
|
||||||
|
if self.action in ["update", "partial_update"]: return ProjectRateUpdateSerializer
|
||||||
|
return ProjectRateSerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
return Response(ProjectRateSerializer(rate).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
rate = self.get_object()
|
||||||
|
serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False))
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
updated_rate = update_project_rate(rate, **serializer.validated_data)
|
||||||
|
return Response(ProjectRateSerializer(updated_rate).data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
rate = self.get_object()
|
||||||
|
rate.is_deleted = True
|
||||||
|
rate.save(update_fields=["is_deleted", "updated_at"])
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUserRateViewSet(BaseProjectNestedViewSet):
|
||||||
|
filterset_fields = ["project", "user", "currency"]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if not self.request.user.is_authenticated: return ProjectUserRate.objects.none()
|
||||||
|
return ProjectUserRate.objects.filter(
|
||||||
|
project__memberships__user=self.request.user,
|
||||||
|
project__memberships__is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create": return ProjectUserRateCreateSerializer
|
||||||
|
if self.action in ["update", "partial_update"]: return ProjectUserRateUpdateSerializer
|
||||||
|
return ProjectUserRateSerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
return Response(ProjectUserRateSerializer(user_rate).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
user_rate = self.get_object()
|
||||||
|
serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False))
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
updated_user_rate = update_project_user_rate(user_rate, **serializer.validated_data)
|
||||||
|
return Response(ProjectUserRateSerializer(updated_user_rate).data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
user_rate = self.get_object()
|
||||||
|
user_rate.is_deleted = True
|
||||||
|
user_rate.save(update_fields=["is_deleted", "updated_at"])
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
7
apps/projects/apps.py
Normal file
7
apps/projects/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.projects"
|
||||||
|
verbose_name = "Projects"
|
||||||
0
apps/projects/migrations/__init__.py
Normal file
0
apps/projects/migrations/__init__.py
Normal file
165
apps/projects/models.py
Normal file
165
apps/projects/models.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from core.models.base import BaseModel
|
||||||
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Project(BaseModel):
|
||||||
|
workspace = models.ForeignKey(
|
||||||
|
Workspace,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="projects",
|
||||||
|
)
|
||||||
|
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
client = models.ForeignKey(
|
||||||
|
"clients.Client",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="projects",
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
is_archived = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
color = models.CharField(max_length=7, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "project"
|
||||||
|
ordering = ("-updated_at", "-created_at")
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["workspace"], name="project_workspace_idx"),
|
||||||
|
]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["workspace", "name"],
|
||||||
|
name="unique_project_name_per_workspace",
|
||||||
|
condition=models.Q(is_deleted=False),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMembership(BaseModel):
|
||||||
|
|
||||||
|
class Role(models.TextChoices):
|
||||||
|
MANAGER = "manager", "Manager"
|
||||||
|
MEMBER = "member", "Member"
|
||||||
|
|
||||||
|
project = models.ForeignKey(
|
||||||
|
Project,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="memberships",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="project_memberships",
|
||||||
|
)
|
||||||
|
|
||||||
|
role = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Role.choices,
|
||||||
|
default=Role.MEMBER,
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "project_membership"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["project"], name="project_membership_project_idx"),
|
||||||
|
models.Index(fields=["user"], name="project_membership_user_idx"),
|
||||||
|
]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["project", "user"],
|
||||||
|
name="unique_project_membership",
|
||||||
|
condition=models.Q(is_deleted=False),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} @ {self.project}"
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectRate(BaseModel):
|
||||||
|
project = models.ForeignKey(
|
||||||
|
Project,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="rates",
|
||||||
|
)
|
||||||
|
|
||||||
|
hourly_rate = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
currency = models.CharField(
|
||||||
|
max_length=3,
|
||||||
|
default="USD",
|
||||||
|
)
|
||||||
|
|
||||||
|
effective_from = models.DateTimeField()
|
||||||
|
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "project_rate"
|
||||||
|
ordering = ("-effective_from",)
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["project"], name="project_rate_project_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUserRate(BaseModel):
|
||||||
|
project = models.ForeignKey(
|
||||||
|
Project,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="user_rates",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="project_rates",
|
||||||
|
)
|
||||||
|
|
||||||
|
hourly_rate = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
currency = models.CharField(
|
||||||
|
max_length=3,
|
||||||
|
default="USD",
|
||||||
|
)
|
||||||
|
|
||||||
|
effective_from = models.DateTimeField()
|
||||||
|
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "project_user_rate"
|
||||||
|
ordering = ("-effective_from",)
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["project", "user", "effective_from"],
|
||||||
|
name="unique_project_user_rate_time",
|
||||||
|
condition=models.Q(is_deleted=False),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["project"], name="pur_project_idx"),
|
||||||
|
models.Index(fields=["user"], name="pur_user_idx"),
|
||||||
|
]
|
||||||
32
apps/projects/services/memberships.py
Normal file
32
apps/projects/services/memberships.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
from apps.projects.models import ProjectMembership
|
||||||
|
|
||||||
|
def add_project_member(project, user, 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():
|
||||||
|
raise ValidationError({"user_id": "This user is already a member of the project."})
|
||||||
|
|
||||||
|
return ProjectMembership.objects.create(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
role=role,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_project_member(membership, **kwargs):
|
||||||
|
"""
|
||||||
|
Updates a project membership (e.g., changing role or active status).
|
||||||
|
"""
|
||||||
|
update_fields = []
|
||||||
|
for field, value in kwargs.items():
|
||||||
|
if hasattr(membership, field) and getattr(membership, field) != value:
|
||||||
|
setattr(membership, field, value)
|
||||||
|
update_fields.append(field)
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
update_fields.append("updated_at")
|
||||||
|
membership.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
return membership
|
||||||
74
apps/projects/services/projects.py
Normal file
74
apps/projects/services/projects.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
from rest_framework.exceptions import ValidationError, PermissionDenied
|
||||||
|
|
||||||
|
from apps.projects.models import Project, ProjectMembership
|
||||||
|
from apps.workspaces.models import WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create_project(user, workspace, name, client=None, description="", color=""):
|
||||||
|
"""
|
||||||
|
Creates a new project and automatically assigns the creator
|
||||||
|
as an active MANAGER of that project.
|
||||||
|
"""
|
||||||
|
workspace_member = WorkspaceMembership.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not workspace_member:
|
||||||
|
raise PermissionDenied("You do not have access to this workspace.")
|
||||||
|
|
||||||
|
if Project.objects.filter(workspace=workspace, name=name, is_deleted=False).exists():
|
||||||
|
raise ValidationError({"name": "A project with this name already exists in the workspace."})
|
||||||
|
|
||||||
|
project = Project.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
name=name,
|
||||||
|
client=client,
|
||||||
|
description=description,
|
||||||
|
color=color
|
||||||
|
)
|
||||||
|
|
||||||
|
ProjectMembership.objects.create(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
role=ProjectMembership.Role.MANAGER,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def update_project(project, **kwargs):
|
||||||
|
"""
|
||||||
|
Updates specific fields of an existing project.
|
||||||
|
"""
|
||||||
|
update_fields = []
|
||||||
|
|
||||||
|
# Optional manual uniqueness check if name is being updated
|
||||||
|
if "name" in kwargs and kwargs["name"] != project.name:
|
||||||
|
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."})
|
||||||
|
|
||||||
|
for field, value in kwargs.items():
|
||||||
|
if hasattr(project, field) and getattr(project, field) != value:
|
||||||
|
setattr(project, field, value)
|
||||||
|
update_fields.append(field)
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
update_fields.append("updated_at")
|
||||||
|
project.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_project_archive(project) -> Project:
|
||||||
|
"""
|
||||||
|
Archives or unarchives a project.
|
||||||
|
"""
|
||||||
|
project.is_archived = not project.is_archived
|
||||||
|
project.save(update_fields=["is_archived", "updated_at"])
|
||||||
|
return project
|
||||||
42
apps/projects/services/rates.py
Normal file
42
apps/projects/services/rates.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_project_rate(rate_instance, **kwargs):
|
||||||
|
update_fields = []
|
||||||
|
for field, value in kwargs.items():
|
||||||
|
if hasattr(rate_instance, field) and getattr(rate_instance, field) != value:
|
||||||
|
setattr(rate_instance, field, value)
|
||||||
|
update_fields.append(field)
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
update_fields.append("updated_at")
|
||||||
|
rate_instance.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
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 update_project_user_rate(user_rate_instance, **kwargs):
|
||||||
|
update_fields = []
|
||||||
|
for field, value in kwargs.items():
|
||||||
|
if hasattr(user_rate_instance, field) and getattr(user_rate_instance, field) != value:
|
||||||
|
setattr(user_rate_instance, field, value)
|
||||||
|
update_fields.append(field)
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
update_fields.append("updated_at")
|
||||||
|
user_rate_instance.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
return user_rate_instance
|
||||||
@@ -42,6 +42,7 @@ LOCAL_APPS = [
|
|||||||
"apps.users",
|
"apps.users",
|
||||||
"apps.workspaces",
|
"apps.workspaces",
|
||||||
"apps.clients",
|
"apps.clients",
|
||||||
|
"apps.projects",
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ urlpatterns = [
|
|||||||
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
|
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
|
||||||
# Apps
|
# Apps
|
||||||
path("api/users/", include("apps.users.api.urls"), name="users"),
|
path("api/users/", include("apps.users.api.urls"), name="users"),
|
||||||
path('api/', include('apps.workspaces.api.urls')),
|
path('api/', include('apps.workspaces.api.urls'), name="workspaces"),
|
||||||
path('api/', include('apps.clients.api.urls')),
|
path('api/', include('apps.clients.api.urls'), name="clients"),
|
||||||
|
path('api/', include('apps.projects.api.urls'), name="projects"),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
Reference in New Issue
Block a user