diff --git a/apps/clients/admin.py b/apps/clients/admin.py new file mode 100644 index 0000000..1b20887 --- /dev/null +++ b/apps/clients/admin.py @@ -0,0 +1,28 @@ +from django.contrib import admin + +from core.admins.base import BaseAdmin, SoftDeleteListFilter +from apps.clients.models import Client + + +@admin.register(Client) +class ClientAdmin(BaseAdmin): + list_display = ( + "id", + "name", + "workspace", + "created_at", + "updated_at", + "is_deleted", + ) + + list_filter = ( + SoftDeleteListFilter, + "workspace", + ) + + search_fields = ( + "name", + "workspace__name", + ) + + autocomplete_fields = ("workspace",) diff --git a/apps/clients/api/permissions.py b/apps/clients/api/permissions.py new file mode 100644 index 0000000..665bd29 --- /dev/null +++ b/apps/clients/api/permissions.py @@ -0,0 +1,22 @@ +from rest_framework import permissions +from apps.workspaces.models import WorkspaceMembership + + +class IsClientWorkspaceMember(permissions.BasePermission): + """ + Allows access only to users who are active members of the workspace associated with the client. + """ + message = "شما عضو فضای کاری این مشتری نیستید." + + def has_object_permission(self, request, view, obj): + """ + Validates if the user exists in the workspace memberships for the requested client's workspace. + """ + if not request.user.is_authenticated: + return False + + return WorkspaceMembership.objects.filter( + workspace=obj.workspace, + user=request.user, + is_active=True + ).exists() diff --git a/apps/clients/api/serializers.py b/apps/clients/api/serializers.py new file mode 100644 index 0000000..95616f9 --- /dev/null +++ b/apps/clients/api/serializers.py @@ -0,0 +1,34 @@ +from rest_framework import serializers +from apps.clients.models import Client +from core.serializers.base import BaseModelSerializer + + +class ClientSerializer(BaseModelSerializer): + """ + Serializer for retrieving and representing client details. + """ + class Meta: + model = Client + fields = BaseModelSerializer.Meta.fields + ( + "workspace", + "name", + "notes", + ) + read_only_fields = fields + + +class ClientCreateSerializer(serializers.Serializer): + """ + Serializer for handling input data during client creation. + """ + workspace_id = serializers.UUIDField() + name = serializers.CharField(max_length=255) + notes = serializers.CharField(allow_blank=True, required=False, default="") + + +class ClientUpdateSerializer(serializers.Serializer): + """ + Serializer for handling input data during client updates. + """ + name = serializers.CharField(max_length=255, required=False) + notes = serializers.CharField(allow_blank=True, required=False) diff --git a/apps/clients/api/urls.py b/apps/clients/api/urls.py new file mode 100644 index 0000000..1779df3 --- /dev/null +++ b/apps/clients/api/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from apps.clients.api.views import ClientViewSet + + +router = DefaultRouter() +router.register(r"clients", ClientViewSet, basename="client") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/apps/clients/api/views.py b/apps/clients/api/views.py new file mode 100644 index 0000000..7eb9a98 --- /dev/null +++ b/apps/clients/api/views.py @@ -0,0 +1,98 @@ +from rest_framework import status +from rest_framework.viewsets import ModelViewSet +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.filters import SearchFilter, OrderingFilter +from django_filters.rest_framework import DjangoFilterBackend + +from core.paginations.limit_offset import CustomLimitOffsetPagination + +from apps.clients.models import Client +from apps.clients.api.serializers import ( + ClientSerializer, + ClientCreateSerializer, + ClientUpdateSerializer +) +from apps.clients.api.permissions import IsClientWorkspaceMember +from apps.clients.services.clients import create_client, update_client + + +class ClientViewSet(ModelViewSet): + """ + API endpoints for managing clients within workspaces. + """ + permission_classes = [IsAuthenticated, IsClientWorkspaceMember] + pagination_class = CustomLimitOffsetPagination + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = ("workspace", ) + search_fields = ("name", "notes") + ordering_fields = ("name", "created_at", "updated_at") + ordering = ("-updated_at", "-created_at") + + def get_queryset(self): + """ + Returns active clients belonging to workspaces where the current user is an active member. + """ + if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated: + return Client.objects.none() + + return Client.objects.filter( + workspace__memberships__user=self.request.user, + workspace__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 ClientCreateSerializer + elif self.action in ["update", "partial_update"]: + return ClientUpdateSerializer + return ClientSerializer + + def create(self, request, *args, **kwargs): + """ + Creates a new client using the client service. + """ + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + client = create_client( + user=request.user, + workspace_id=serializer.validated_data["workspace_id"], + name=serializer.validated_data["name"], + notes=serializer.validated_data.get("notes", "") + ) + + output_serializer = ClientSerializer(client) + return Response(output_serializer.data, status=status.HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + """ + Updates an existing client using the client service. + """ + partial = kwargs.pop("partial", False) + client = self.get_object() + + serializer = self.get_serializer(data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + + updated_client = update_client( + client=client, + name=serializer.validated_data.get("name"), + notes=serializer.validated_data.get("notes") + ) + + output_serializer = ClientSerializer(updated_client) + return Response(output_serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, *args, **kwargs): + """ + Soft deletes a client. + """ + client = self.get_object() + client.is_deleted = True + client.save(update_fields=["is_deleted", "updated_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/clients/apps.py b/apps/clients/apps.py new file mode 100644 index 0000000..a6652b5 --- /dev/null +++ b/apps/clients/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ClientsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.clients" + verbose_name = "Clients" diff --git a/apps/clients/migrations/__init__.py b/apps/clients/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/clients/models.py b/apps/clients/models.py new file mode 100644 index 0000000..d35bc43 --- /dev/null +++ b/apps/clients/models.py @@ -0,0 +1,34 @@ +from django.db import models +from apps.workspaces.models import Workspace + +from core.models.base import BaseModel + + +class Client(BaseModel): + workspace = models.ForeignKey( + Workspace, + on_delete=models.CASCADE, + related_name="clients", + ) + + name = models.CharField(max_length=255) + + notes = models.TextField(blank=True) + + class Meta: + db_table = "client" + ordering = ("-updated_at", "-created_at") + indexes = [ + models.Index(fields=["id"], name="client_id_idx"), + models.Index(fields=["workspace"], name="client_workspace_idx"), + ] + constraints = [ + models.UniqueConstraint( + fields=["workspace", "name"], + name="unique_client_name_per_workspace", + condition=models.Q(is_deleted=False), + ) + ] + + def __str__(self): + return self.name diff --git a/apps/clients/services/clients.py b/apps/clients/services/clients.py new file mode 100644 index 0000000..9df7eae --- /dev/null +++ b/apps/clients/services/clients.py @@ -0,0 +1,42 @@ +from rest_framework.exceptions import ValidationError +from apps.clients.models import Client +from apps.workspaces.models import WorkspaceMembership + + +def create_client(user, workspace_id, name, notes=""): + """ + Creates a new client after validating workspace membership and name uniqueness. + """ + is_workspace_member = WorkspaceMembership.objects.filter( + workspace_id=workspace_id, + user=user, + is_active=True + ).exists() + + if not is_workspace_member: + raise ValidationError({"workspace": "شما عضو این فضای کاری نیستید."}) + + if Client.objects.filter(workspace_id=workspace_id, name=name, is_deleted=False).exists(): + raise ValidationError({"name": "مشتری با این نام در این فضای کاری وجود دارد."}) + + return Client.objects.create( + workspace_id=workspace_id, + name=name, + notes=notes + ) + + +def update_client(client, name=None, notes=None): + """ + Updates an existing client while validating name uniqueness within the workspace. + """ + if name is not None and name != client.name: + if Client.objects.filter(workspace_id=client.workspace_id, name=name, is_deleted=False).exists(): + raise ValidationError({"name": "مشتری با این نام در این فضای کاری وجود دارد."}) + client.name = name + + if notes is not None: + client.notes = notes + + client.save(update_fields=["name", "notes", "updated_at"]) + return client diff --git a/config/settings/base.py b/config/settings/base.py index 9d20926..215d600 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -41,6 +41,7 @@ THIRD_PARTY_APPS = [ LOCAL_APPS = [ "apps.users", "apps.workspaces", + "apps.clients", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/config/urls.py b/config/urls.py index 2a21a9c..dad366a 100644 --- a/config/urls.py +++ b/config/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ # 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')), ] if settings.DEBUG: diff --git a/pyproject.toml b/pyproject.toml index c755433..525d95f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,3 +39,9 @@ known-first-party = [ quote-style = "double" indent-style = "space" line-ending = "lf" + +[tool.commitizen] +name = "cz_conventional_commits" +version = "0.1.0" +tag_format = "v$version" +update_changelog_on_bump = true \ No newline at end of file diff --git a/requirements/dev.txt b/requirements/dev.txt index 553cc47..b422df7 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -22,3 +22,4 @@ django-stubs>=5.0 # Pre-commit hooks pre-commit>=3.7 +commitizen>=4.13 \ No newline at end of file