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.clients",
|
||||
"apps.projects",
|
||||
"apps.tags",
|
||||
]
|
||||
|
||||
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.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:
|
||||
|
||||
Reference in New Issue
Block a user