feat(tags): add tags app's basic structure and endpoint
This commit is contained in:
27
apps/tags/admin.py
Normal file
27
apps/tags/admin.py
Normal file
@@ -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",)
|
||||||
36
apps/tags/api/serializers.py
Normal file
36
apps/tags/api/serializers.py
Normal file
@@ -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)
|
||||||
13
apps/tags/api/urls.py
Normal file
13
apps/tags/api/urls.py
Normal file
@@ -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)),
|
||||||
|
]
|
||||||
94
apps/tags/api/views.py
Normal file
94
apps/tags/api/views.py
Normal file
@@ -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)
|
||||||
7
apps/tags/apps.py
Normal file
7
apps/tags/apps.py
Normal file
@@ -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"
|
||||||
0
apps/tags/migrations/__init__.py
Normal file
0
apps/tags/migrations/__init__.py
Normal file
34
apps/tags/models.py
Normal file
34
apps/tags/models.py
Normal file
@@ -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
|
||||||
52
apps/tags/services/tags.py
Normal file
52
apps/tags/services/tags.py
Normal file
@@ -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
|
||||||
@@ -43,6 +43,7 @@ LOCAL_APPS = [
|
|||||||
"apps.workspaces",
|
"apps.workspaces",
|
||||||
"apps.clients",
|
"apps.clients",
|
||||||
"apps.projects",
|
"apps.projects",
|
||||||
|
"apps.tags",
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ urlpatterns = [
|
|||||||
path('api/', include('apps.workspaces.api.urls'), name="workspaces"),
|
path('api/', include('apps.workspaces.api.urls'), name="workspaces"),
|
||||||
path('api/', include('apps.clients.api.urls'), name="clients"),
|
path('api/', include('apps.clients.api.urls'), name="clients"),
|
||||||
path('api/', include('apps.projects.api.urls'), name="projects"),
|
path('api/', include('apps.projects.api.urls'), name="projects"),
|
||||||
|
path('api/', include('apps.tags.api.urls'), name="tags"),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
Reference in New Issue
Block a user