feat(clients): add clients app basic structure and endpoints
This commit is contained in:
28
apps/clients/admin.py
Normal file
28
apps/clients/admin.py
Normal 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",)
|
||||||
22
apps/clients/api/permissions.py
Normal file
22
apps/clients/api/permissions.py
Normal 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()
|
||||||
34
apps/clients/api/serializers.py
Normal file
34
apps/clients/api/serializers.py
Normal 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
12
apps/clients/api/urls.py
Normal 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
98
apps/clients/api/views.py
Normal 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
7
apps/clients/apps.py
Normal 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"
|
||||||
0
apps/clients/migrations/__init__.py
Normal file
0
apps/clients/migrations/__init__.py
Normal file
34
apps/clients/models.py
Normal file
34
apps/clients/models.py
Normal 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
|
||||||
42
apps/clients/services/clients.py
Normal file
42
apps/clients/services/clients.py
Normal 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
|
||||||
@@ -41,6 +41,7 @@ THIRD_PARTY_APPS = [
|
|||||||
LOCAL_APPS = [
|
LOCAL_APPS = [
|
||||||
"apps.users",
|
"apps.users",
|
||||||
"apps.workspaces",
|
"apps.workspaces",
|
||||||
|
"apps.clients",
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ urlpatterns = [
|
|||||||
# 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')),
|
||||||
|
path('api/', include('apps.clients.api.urls')),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
@@ -39,3 +39,9 @@ known-first-party = [
|
|||||||
quote-style = "double"
|
quote-style = "double"
|
||||||
indent-style = "space"
|
indent-style = "space"
|
||||||
line-ending = "lf"
|
line-ending = "lf"
|
||||||
|
|
||||||
|
[tool.commitizen]
|
||||||
|
name = "cz_conventional_commits"
|
||||||
|
version = "0.1.0"
|
||||||
|
tag_format = "v$version"
|
||||||
|
update_changelog_on_bump = true
|
||||||
@@ -22,3 +22,4 @@ django-stubs>=5.0
|
|||||||
|
|
||||||
# Pre-commit hooks
|
# Pre-commit hooks
|
||||||
pre-commit>=3.7
|
pre-commit>=3.7
|
||||||
|
commitizen>=4.13
|
||||||
Reference in New Issue
Block a user