feat(clients): add clients app basic structure and endpoints

This commit is contained in:
2026-03-11 18:43:11 +08:00
parent 5d1e1cb7cb
commit 7b6b288c41
13 changed files with 286 additions and 0 deletions

28
apps/clients/admin.py Normal file
View File

@@ -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",)

View File

@@ -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()

View File

@@ -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)

12
apps/clients/api/urls.py Normal file
View File

@@ -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)),
]

98
apps/clients/api/views.py Normal file
View File

@@ -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)

7
apps/clients/apps.py Normal file
View File

@@ -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"

View File

34
apps/clients/models.py Normal file
View File

@@ -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

View File

@@ -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