From 4d662938041a7ebf49bd73eeaa198d81f046bca1 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Wed, 11 Mar 2026 19:17:20 +0800 Subject: [PATCH] feat(tags): add tags app's basic structure and endpoint --- apps/tags/admin.py | 27 +++++++++ apps/tags/api/serializers.py | 36 ++++++++++++ apps/tags/api/urls.py | 13 +++++ apps/tags/api/views.py | 94 ++++++++++++++++++++++++++++++++ apps/tags/apps.py | 7 +++ apps/tags/migrations/__init__.py | 0 apps/tags/models.py | 34 ++++++++++++ apps/tags/services/tags.py | 52 ++++++++++++++++++ config/settings/base.py | 1 + config/urls.py | 1 + 10 files changed, 265 insertions(+) create mode 100644 apps/tags/admin.py create mode 100644 apps/tags/api/serializers.py create mode 100644 apps/tags/api/urls.py create mode 100644 apps/tags/api/views.py create mode 100644 apps/tags/apps.py create mode 100644 apps/tags/migrations/__init__.py create mode 100644 apps/tags/models.py create mode 100644 apps/tags/services/tags.py diff --git a/apps/tags/admin.py b/apps/tags/admin.py new file mode 100644 index 0000000..f9fd23c --- /dev/null +++ b/apps/tags/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin + +from core.admins.base import BaseAdmin +from apps.tags.models import Tag + + +@admin.register(Tag) +class TagAdmin(BaseAdmin): + list_display = ( + "id", + "name", + "workspace", + "created_at", + "is_deleted", + ) + + list_filter = ( + "workspace", + "is_deleted", + ) + + search_fields = ( + "name", + "workspace__name", + ) + + autocomplete_fields = ("workspace",) diff --git a/apps/tags/api/serializers.py b/apps/tags/api/serializers.py new file mode 100644 index 0000000..a20168a --- /dev/null +++ b/apps/tags/api/serializers.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from core.serializers.base import BaseModelSerializer +from apps.tags.models import Tag + + +class TagSerializer(BaseModelSerializer): + """ + Serializer for retrieving and representing tag details. + Inherits standard fields (id, created_at, updated_at, is_deleted). + """ + class Meta: + model = Tag + fields = BaseModelSerializer.Meta.fields + ( + "workspace", + "name", + "color", + ) + read_only_fields = fields + + +class TagCreateSerializer(serializers.Serializer): + """ + Validates input data for tag creation. + """ + workspace_id = serializers.UUIDField() + name = serializers.CharField(max_length=100) + color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="") + + +class TagUpdateSerializer(serializers.Serializer): + """ + Validates input data for tag updates. + """ + name = serializers.CharField(max_length=100, required=False) + color = serializers.CharField(max_length=7, required=False, allow_blank=True) diff --git a/apps/tags/api/urls.py b/apps/tags/api/urls.py new file mode 100644 index 0000000..0801bce --- /dev/null +++ b/apps/tags/api/urls.py @@ -0,0 +1,13 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from apps.tags.api.views import TagViewSet + +app_name = "tags" + +router = DefaultRouter() +router.register(r"tags", TagViewSet, basename="tag") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/apps/tags/api/views.py b/apps/tags/api/views.py new file mode 100644 index 0000000..d93472d --- /dev/null +++ b/apps/tags/api/views.py @@ -0,0 +1,94 @@ +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.tags.models import Tag +from apps.tags.api.serializers import ( + TagSerializer, + TagCreateSerializer, + TagUpdateSerializer +) +from apps.tags.services.tags import create_tag, update_tag + + +class TagViewSet(ModelViewSet): + """ + API endpoints for managing tags. + """ + pagination_class = CustomLimitOffsetPagination + permission_classes = [IsAuthenticated] + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = ["workspace"] + search_fields = ["name"] + ordering_fields = ["name", "created_at", "updated_at"] + ordering = ["-updated_at", "-created_at"] + + def get_queryset(self): + """ + Returns active tags from workspaces where the current user is an active member. + """ + if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated: + return Tag.objects.none() + + return Tag.objects.filter( + workspace__memberships__user=self.request.user, + workspace__memberships__is_active=True, + is_deleted=False + ).distinct() + + def get_serializer_class(self): + if self.action == "create": + return TagCreateSerializer + elif self.action in ["update", "partial_update"]: + return TagUpdateSerializer + return TagSerializer + + def create(self, request, *args, **kwargs): + """ + Creates a new tag via the service layer. + """ + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + tag = create_tag( + user=request.user, + workspace_id=serializer.validated_data["workspace_id"], + name=serializer.validated_data["name"], + color=serializer.validated_data.get("color", "") + ) + + output_serializer = TagSerializer(tag) + return Response(output_serializer.data, status=status.HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + """ + Updates a tag via the service layer. + """ + partial = kwargs.pop("partial", False) + tag = self.get_object() + + serializer = self.get_serializer(data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + + updated_tag = update_tag( + tag=tag, + **serializer.validated_data + ) + + output_serializer = TagSerializer(updated_tag) + return Response(output_serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, *args, **kwargs): + """ + Soft deletes the tag. + """ + tag = self.get_object() + tag.is_deleted = True + tag.save(update_fields=["is_deleted", "updated_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/tags/apps.py b/apps/tags/apps.py new file mode 100644 index 0000000..97d4b4f --- /dev/null +++ b/apps/tags/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class TagsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.tags" + verbose_name = "Tags" diff --git a/apps/tags/migrations/__init__.py b/apps/tags/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tags/models.py b/apps/tags/models.py new file mode 100644 index 0000000..97a733d --- /dev/null +++ b/apps/tags/models.py @@ -0,0 +1,34 @@ +from django.db import models + +from core.models.base import BaseModel +from apps.workspaces.models import Workspace + + +class Tag(BaseModel): + workspace = models.ForeignKey( + Workspace, + on_delete=models.CASCADE, + related_name="tags", + ) + + name = models.CharField(max_length=100) + + color = models.CharField(max_length=7, blank=True) + + class Meta: + db_table = "tag" + ordering = ("-updated_at", "-created_at") + indexes = [ + models.Index(fields=["id"], name="tag_id_idx"), + models.Index(fields=["workspace"], name="tag_workspace_idx"), + ] + constraints = [ + models.UniqueConstraint( + fields=["workspace", "name"], + name="unique_tag_name_per_workspace", + condition=models.Q(is_deleted=False), + ) + ] + + def __str__(self): + return self.name diff --git a/apps/tags/services/tags.py b/apps/tags/services/tags.py new file mode 100644 index 0000000..6a70169 --- /dev/null +++ b/apps/tags/services/tags.py @@ -0,0 +1,52 @@ +from rest_framework.exceptions import ValidationError, PermissionDenied + +from apps.tags.models import Tag +from apps.workspaces.models import WorkspaceMembership + + +def create_tag(user, workspace_id, name, color=""): + """ + Creates a new tag in a workspace. + Verifies the user has active membership in the workspace. + """ + workspace_member = WorkspaceMembership.objects.filter( + workspace_id=workspace_id, + user=user, + is_active=True, + is_deleted=False + ).exists() + + if not workspace_member: + raise PermissionDenied("You do not have access to this workspace.") + + if Tag.objects.filter(workspace_id=workspace_id, name=name, is_deleted=False).exists(): + raise ValidationError({"name": "A tag with this name already exists in the workspace."}) + + return Tag.objects.create( + workspace_id=workspace_id, + name=name, + color=color + ) + + +def update_tag(tag, **kwargs): + """ + Updates specific fields of an existing tag. + """ + update_fields = [] + + # Optional manual uniqueness check if name is being updated + if "name" in kwargs and kwargs["name"] != tag.name: + if Tag.objects.filter(workspace_id=tag.workspace_id, name=kwargs["name"], is_deleted=False).exists(): + raise ValidationError({"name": "A tag with this name already exists in the workspace."}) + + for field, value in kwargs.items(): + if hasattr(tag, field) and getattr(tag, field) != value: + setattr(tag, field, value) + update_fields.append(field) + + if update_fields: + update_fields.append("updated_at") + tag.save(update_fields=update_fields) + + return tag diff --git a/config/settings/base.py b/config/settings/base.py index 872c5b7..2eb501b 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -43,6 +43,7 @@ LOCAL_APPS = [ "apps.workspaces", "apps.clients", "apps.projects", + "apps.tags", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/config/urls.py b/config/urls.py index 988be6e..1f53109 100644 --- a/config/urls.py +++ b/config/urls.py @@ -19,6 +19,7 @@ urlpatterns = [ 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"), + path('api/', include('apps.tags.api.urls'), name="tags"), ] if settings.DEBUG: