From 7152ab9acaf832cddb93637c7ca97df530387974 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Wed, 11 Mar 2026 18:51:04 +0800 Subject: [PATCH] feat(projects): add projects app's basic structure and endpoints --- apps/projects/admin.py | 68 ++++++ apps/projects/api/permissions.py | 49 ++++ apps/projects/api/serializers.py | 118 ++++++++++ apps/projects/api/urls.py | 21 ++ apps/projects/api/views.py | 313 ++++++++++++++++++++++++++ apps/projects/apps.py | 7 + apps/projects/migrations/__init__.py | 0 apps/projects/models.py | 165 ++++++++++++++ apps/projects/services/memberships.py | 32 +++ apps/projects/services/projects.py | 74 ++++++ apps/projects/services/rates.py | 42 ++++ config/settings/base.py | 1 + config/urls.py | 5 +- 13 files changed, 893 insertions(+), 2 deletions(-) create mode 100644 apps/projects/admin.py create mode 100644 apps/projects/api/permissions.py create mode 100644 apps/projects/api/serializers.py create mode 100644 apps/projects/api/urls.py create mode 100644 apps/projects/api/views.py create mode 100644 apps/projects/apps.py create mode 100644 apps/projects/migrations/__init__.py create mode 100644 apps/projects/models.py create mode 100644 apps/projects/services/memberships.py create mode 100644 apps/projects/services/projects.py create mode 100644 apps/projects/services/rates.py diff --git a/apps/projects/admin.py b/apps/projects/admin.py new file mode 100644 index 0000000..0880459 --- /dev/null +++ b/apps/projects/admin.py @@ -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", + ) diff --git a/apps/projects/api/permissions.py b/apps/projects/api/permissions.py new file mode 100644 index 0000000..5699fbd --- /dev/null +++ b/apps/projects/api/permissions.py @@ -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() diff --git a/apps/projects/api/serializers.py b/apps/projects/api/serializers.py new file mode 100644 index 0000000..829fd0f --- /dev/null +++ b/apps/projects/api/serializers.py @@ -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) diff --git a/apps/projects/api/urls.py b/apps/projects/api/urls.py new file mode 100644 index 0000000..e11a8b9 --- /dev/null +++ b/apps/projects/api/urls.py @@ -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)), +] diff --git a/apps/projects/api/views.py b/apps/projects/api/views.py new file mode 100644 index 0000000..5486873 --- /dev/null +++ b/apps/projects/api/views.py @@ -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) diff --git a/apps/projects/apps.py b/apps/projects/apps.py new file mode 100644 index 0000000..2342bc5 --- /dev/null +++ b/apps/projects/apps.py @@ -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" diff --git a/apps/projects/migrations/__init__.py b/apps/projects/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/models.py b/apps/projects/models.py new file mode 100644 index 0000000..8ad5654 --- /dev/null +++ b/apps/projects/models.py @@ -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"), + ] diff --git a/apps/projects/services/memberships.py b/apps/projects/services/memberships.py new file mode 100644 index 0000000..eb6dbb6 --- /dev/null +++ b/apps/projects/services/memberships.py @@ -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 diff --git a/apps/projects/services/projects.py b/apps/projects/services/projects.py new file mode 100644 index 0000000..ef1820c --- /dev/null +++ b/apps/projects/services/projects.py @@ -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 diff --git a/apps/projects/services/rates.py b/apps/projects/services/rates.py new file mode 100644 index 0000000..a3b4b10 --- /dev/null +++ b/apps/projects/services/rates.py @@ -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 diff --git a/config/settings/base.py b/config/settings/base.py index 215d600..872c5b7 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -42,6 +42,7 @@ LOCAL_APPS = [ "apps.users", "apps.workspaces", "apps.clients", + "apps.projects", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/config/urls.py b/config/urls.py index dad366a..988be6e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -16,8 +16,9 @@ urlpatterns = [ path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), # Apps path("api/users/", include("apps.users.api.urls"), name="users"), - path('api/', include('apps.workspaces.api.urls')), - path('api/', include('apps.clients.api.urls')), + path('api/', include('apps.workspaces.api.urls'), name="workspaces"), + path('api/', include('apps.clients.api.urls'), name="clients"), + path('api/', include('apps.projects.api.urls'), name="projects"), ] if settings.DEBUG: