feat(tags): add tags app's basic structure and endpoint

This commit is contained in:
2026-03-11 19:17:20 +08:00
parent 7152ab9aca
commit 4d66293804
10 changed files with 265 additions and 0 deletions

27
apps/tags/admin.py Normal file
View 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",)

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

View File

34
apps/tags/models.py Normal file
View 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

View 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