diff --git a/src/tickets/admin.py b/src/tickets/admin.py index 0dc5f91..76be9bc 100644 --- a/src/tickets/admin.py +++ b/src/tickets/admin.py @@ -1,5 +1,64 @@ -"""generated with djinit""" - from django.contrib import admin -# Register your models here. \ No newline at end of file +from src.tickets.models import ( + Ticket, + TicketComment, + TicketHistory, + TicketPriority, + TicketStatus, + TicketUser, +) + + +class TicketUserInline(admin.TabularInline): + model = TicketUser + extra = 1 + + +class TicketCommentInline(admin.TabularInline): + model = TicketComment + extra = 0 + readonly_fields = ("user", "comment", "created_at") + + +class TicketHistoryInline(admin.TabularInline): + model = TicketHistory + extra = 0 + readonly_fields = ("user", "status", "created_at") + + +@admin.register(Ticket) +class TicketAdmin(admin.ModelAdmin): + list_display = ("id", "uuid", "name", "project", "status", "priority", "due_date") + list_filter = ("project", "status", "priority") + search_fields = ("name", "uuid") + inlines = (TicketUserInline, TicketCommentInline, TicketHistoryInline) + + +@admin.register(TicketStatus) +class TicketStatusAdmin(admin.ModelAdmin): + list_display = ("id", "name", "project", "color", "sort_order", "is_completed") + list_filter = ("project",) + + +@admin.register(TicketPriority) +class TicketPriorityAdmin(admin.ModelAdmin): + list_display = ("id", "name", "color") + + +@admin.register(TicketComment) +class TicketCommentAdmin(admin.ModelAdmin): + list_display = ("id", "ticket", "user", "created_at") + readonly_fields = ("ticket", "user", "comment", "created_at") + + +@admin.register(TicketHistory) +class TicketHistoryAdmin(admin.ModelAdmin): + list_display = ("id", "ticket", "user", "status", "created_at") + readonly_fields = ("ticket", "user", "status", "created_at") + + +@admin.register(TicketUser) +class TicketUserAdmin(admin.ModelAdmin): + list_display = ("id", "ticket", "user") + diff --git a/src/tickets/models.py b/src/tickets/models.py index d7b9f75..8bbbff7 100644 --- a/src/tickets/models.py +++ b/src/tickets/models.py @@ -1,5 +1,186 @@ -"""generated with djinit""" +import random +import string +from django.conf import settings from django.db import models -# Create your models here. \ No newline at end of file + +def generate_ticket_uuid(prefix): + rand = "".join(random.choices(string.ascii_uppercase + string.digits, k=6)) + return f"{prefix}-{rand}" + + +class TicketPriority(models.Model): + name = models.CharField(max_length=255, unique=True) + color = models.CharField(max_length=7, default="#6B7280") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ticket_priorities" + verbose_name = "Ticket Priority" + verbose_name_plural = "Ticket Priorities" + + def __str__(self): + return self.name + + +class TicketStatus(models.Model): + project = models.ForeignKey( + "projects.Project", on_delete=models.CASCADE, related_name="ticket_statuses" + ) + name = models.CharField(max_length=255) + color = models.CharField(max_length=7, default="#10B981") + sort_order = models.IntegerField(default=0) + is_completed = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ticket_statuses" + verbose_name = "Ticket Status" + verbose_name_plural = "Ticket Statuses" + indexes = [ + models.Index(fields=["project", "sort_order"]), + models.Index(fields=["project", "is_completed"]), + ] + + def __str__(self): + return f"[{self.project.ticket_prefix}] {self.name}" + + +class Ticket(models.Model): + project = models.ForeignKey( + "projects.Project", on_delete=models.CASCADE, related_name="tickets" + ) + status = models.ForeignKey( + TicketStatus, on_delete=models.CASCADE, related_name="tickets" + ) + epic = models.ForeignKey( + "epics.Epic", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="tickets", + ) + priority = models.ForeignKey( + TicketPriority, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="tickets", + ) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + uuid = models.CharField(max_length=255, unique=True, editable=False) + start_date = models.DateField(blank=True, null=True) + due_date = models.DateField(blank=True, null=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="created_tickets", + ) + assignees = models.ManyToManyField( + settings.AUTH_USER_MODEL, + through="TicketUser", + related_name="assigned_tickets", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "tickets" + verbose_name = "Ticket" + verbose_name_plural = "Tickets" + indexes = [ + models.Index(fields=["project", "status"]), + models.Index(fields=["status", "created_at"]), + models.Index(fields=["project", "created_at"]), + models.Index(fields=["project", "updated_at"]), + models.Index(fields=["due_date"]), + models.Index(fields=["priority"]), + models.Index(fields=["created_by"]), + ] + + def __str__(self): + return f"{self.uuid} - {self.name}" + + def save(self, *args, **kwargs): + if not self.uuid: + self.uuid = generate_ticket_uuid(self.project.ticket_prefix) + super().save(*args, **kwargs) + + +class TicketUser(models.Model): + ticket = models.ForeignKey( + Ticket, on_delete=models.CASCADE, related_name="ticket_assignees" + ) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ticket_users" + verbose_name = "Ticket Assignee" + verbose_name_plural = "Ticket Assignees" + constraints = [ + models.UniqueConstraint( + fields=["ticket", "user"], name="unique_ticket_user" + ) + ] + indexes = [ + models.Index(fields=["ticket", "user"]), + models.Index(fields=["user"]), + ] + + def __str__(self): + return f"{self.user} -> {self.ticket}" + + +class TicketHistory(models.Model): + ticket = models.ForeignKey( + Ticket, on_delete=models.CASCADE, related_name="histories" + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="ticket_histories", + ) + status = models.ForeignKey( + TicketStatus, on_delete=models.CASCADE, related_name="histories" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ticket_histories" + verbose_name = "Ticket History" + verbose_name_plural = "Ticket Histories" + + def __str__(self): + return f"{self.ticket.uuid} -> {self.status.name} by {self.user}" + + +class TicketComment(models.Model): + ticket = models.ForeignKey( + Ticket, on_delete=models.CASCADE, related_name="comments" + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="ticket_comments", + ) + comment = models.TextField(help_text="Rich text HTML content") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ticket_comments" + verbose_name = "Ticket Comment" + verbose_name_plural = "Ticket Comments" + + def __str__(self): + return f"Comment by {self.user} on {self.ticket.uuid}" + diff --git a/src/tickets/serializers.py b/src/tickets/serializers.py index fbcbed8..962b913 100644 --- a/src/tickets/serializers.py +++ b/src/tickets/serializers.py @@ -1,5 +1,174 @@ -"""generated with djinit""" - +from apps.accounts.serializers import UserSerializer +from apps.epics.serializers import EpicSerializer from rest_framework import serializers -# Create your serializers here. \ No newline at end of file +from src.tickets.models import ( + Ticket, + TicketComment, + TicketHistory, + TicketPriority, + TicketStatus, + TicketUser, +) + + +class TicketPrioritySerializer(serializers.ModelSerializer): + class Meta: + model = TicketPriority + fields = ("id", "name", "color", "created_at", "updated_at") + read_only_fields = ("id", "created_at", "updated_at") + + +class TicketStatusSerializer(serializers.ModelSerializer): + class Meta: + model = TicketStatus + fields = ( + "id", + "project", + "name", + "color", + "sort_order", + "is_completed", + "created_at", + "updated_at", + ) + read_only_fields = ("id", "created_at", "updated_at") + + +class TicketUserSerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + user_id = serializers.IntegerField(write_only=True) + + class Meta: + model = TicketUser + fields = ("id", "user", "user_id", "created_at") + read_only_fields = ("id", "created_at") + + +class TicketCommentSerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + + class Meta: + model = TicketComment + fields = ("id", "ticket", "user", "comment", "created_at", "updated_at") + read_only_fields = ("id", "user", "created_at", "updated_at") + + +class TicketHistorySerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + status = TicketStatusSerializer(read_only=True) + + class Meta: + model = TicketHistory + fields = ("id", "ticket", "user", "status", "created_at") + read_only_fields = ("id", "created_at") + + +class TicketListSerializer(serializers.ModelSerializer): + status = TicketStatusSerializer(read_only=True) + priority = TicketPrioritySerializer(read_only=True) + epic = EpicSerializer(read_only=True) + assignees = UserSerializer(many=True, read_only=True) + creator = UserSerializer(source="created_by", read_only=True) + + class Meta: + model = Ticket + fields = ( + "id", + "uuid", + "project", + "status", + "epic", + "priority", + "name", + "description", + "start_date", + "due_date", + "creator", + "assignees", + "created_at", + "updated_at", + ) + read_only_fields = ("id", "uuid", "created_at", "updated_at") + + +class TicketDetailSerializer(serializers.ModelSerializer): + status = TicketStatusSerializer(read_only=True) + priority = TicketPrioritySerializer(read_only=True) + epic = EpicSerializer(read_only=True) + assignees = UserSerializer(many=True, read_only=True) + creator = UserSerializer(source="created_by", read_only=True) + comments = TicketCommentSerializer(many=True, read_only=True) + histories = TicketHistorySerializer(many=True, read_only=True) + + class Meta: + model = Ticket + fields = ( + "id", + "uuid", + "project", + "status", + "epic", + "priority", + "name", + "description", + "start_date", + "due_date", + "creator", + "assignees", + "comments", + "histories", + "created_at", + "updated_at", + ) + read_only_fields = ("id", "uuid", "created_at", "updated_at") + + +class TicketWriteSerializer(serializers.ModelSerializer): + assignee_ids = serializers.ListField( + child=serializers.IntegerField(), write_only=True, required=False + ) + + class Meta: + model = Ticket + fields = ( + "project", + "status", + "epic", + "priority", + "name", + "description", + "start_date", + "due_date", + "assignee_ids", + ) + + def create(self, validated_data): + assignee_ids = validated_data.pop("assignee_ids", []) + user = self.context["request"].user + ticket = Ticket.objects.create(**validated_data, created_by=user) + for uid in assignee_ids: + TicketUser.objects.get_or_create(ticket=ticket, user_id=uid) + return ticket + + def update(self, instance, validated_data): + assignee_ids = validated_data.pop("assignee_ids", None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + old_status_id = instance.status_id + instance.save() + + if assignee_ids is not None: + instance.ticket_assignees.all().delete() + for uid in assignee_ids: + TicketUser.objects.get_or_create(ticket=instance, user_id=uid) + + if "status" in validated_data and old_status_id != instance.status_id: + TicketHistory.objects.create( + ticket=instance, + user=self.context["request"].user, + status=instance.status, + ) + + return instance + diff --git a/src/tickets/urls.py b/src/tickets/urls.py index 8020533..a9d7410 100644 --- a/src/tickets/urls.py +++ b/src/tickets/urls.py @@ -1,7 +1,26 @@ -"""generated with djinit""" - from django.urls import path +from src.tickets import views + urlpatterns = [ - # Add your URL patterns here -] \ No newline at end of file + path("", views.TicketListCreateView.as_view(), name="ticket-list"), + path("priorities/", views.PriorityListView.as_view(), name="priority-list"), + path( + "priorities//", + views.PriorityDetailView.as_view(), + name="priority-detail", + ), + path("/", views.TicketDetailView.as_view(), name="ticket-detail"), + path("/move/", views.move_ticket_view, name="ticket-move"), + path( + "/comments/", + views.TicketCommentListCreateView.as_view(), + name="ticket-comment-list", + ), + path( + "/histories/", + views.TicketHistoryListView.as_view(), + name="ticket-history-list", + ), +] + diff --git a/src/tickets/views.py b/src/tickets/views.py index 6630beb..5062b24 100644 --- a/src/tickets/views.py +++ b/src/tickets/views.py @@ -1,5 +1,136 @@ -"""generated with djinit""" +from django.db import models +from rest_framework import generics, permissions +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response -from rest_framework import viewsets +from src.tickets.models import ( + Ticket, + TicketComment, + TicketHistory, + TicketPriority, +) +from src.tickets.serializers import ( + TicketCommentSerializer, + TicketDetailSerializer, + TicketHistorySerializer, + TicketListSerializer, + TicketPrioritySerializer, + TicketWriteSerializer, +) + + +class TicketListCreateView(generics.ListCreateAPIView): + permission_classes = (permissions.IsAuthenticated,) + + def get_serializer_class(self): + if self.request.method == "POST": + return TicketWriteSerializer + return TicketListSerializer + + def get_queryset(self): + qs = Ticket.objects.select_related( + "status", "priority", "epic", "created_by" + ).prefetch_related("assignees") + project_id = self.request.query_params.get("project") + status_id = self.request.query_params.get("status") + epic_id = self.request.query_params.get("epic") + priority_id = self.request.query_params.get("priority") + assignee_id = self.request.query_params.get("assignee") + + if project_id: + qs = qs.filter(project_id=project_id) + if status_id: + qs = qs.filter(status_id=status_id) + if epic_id: + qs = qs.filter(epic_id=epic_id) + if priority_id: + qs = qs.filter(priority_id=priority_id) + if assignee_id: + qs = qs.filter(ticket_assignees__user_id=assignee_id) + + user = self.request.user + if not user.is_superuser: + qs = qs.filter( + models.Q(project__members=user) | models.Q(assignees=user) + ).distinct() + + return qs.order_by("-created_at") + + +class TicketDetailView(generics.RetrieveUpdateDestroyAPIView): + permission_classes = (permissions.IsAuthenticated,) + + def get_serializer_class(self): + if self.request.method in ("PUT", "PATCH"): + return TicketWriteSerializer + return TicketDetailSerializer + + def get_queryset(self): + return Ticket.objects.select_related( + "status", "priority", "epic", "created_by" + ).prefetch_related("assignees", "comments__user", "histories__user__status") + + +class TicketCommentListCreateView(generics.ListCreateAPIView): + serializer_class = TicketCommentSerializer + permission_classes = (permissions.IsAuthenticated,) + + def get_queryset(self): + return ( + TicketComment.objects.filter(ticket_id=self.kwargs["ticket_pk"]) + .select_related("user") + .order_by("created_at") + ) + + def perform_create(self, serializer): + serializer.save( + ticket_id=self.kwargs["ticket_pk"], + user=self.request.user, + ) + + +class TicketHistoryListView(generics.ListAPIView): + serializer_class = TicketHistorySerializer + permission_classes = (permissions.IsAuthenticated,) + + def get_queryset(self): + return ( + TicketHistory.objects.filter(ticket_id=self.kwargs["ticket_pk"]) + .select_related("user", "status") + .order_by("-created_at") + ) + + +@api_view(["PATCH"]) +@permission_classes([permissions.IsAuthenticated]) +def move_ticket_view(request, pk): + ticket = generics.get_object_or_404(Ticket, pk=pk) + new_status_id = request.data.get("status_id") + new_sort = request.data.get("sort_order") + + if new_status_id: + old_status_id = ticket.status_id + ticket.status_id = new_status_id + ticket.save() + if old_status_id != int(new_status_id): + TicketHistory.objects.create( + ticket=ticket, + user=request.user, + status_id=new_status_id, + ) + + return Response(TicketListSerializer(ticket).data) + + +# Priority and Status list views (global) +class PriorityListView(generics.ListCreateAPIView): + queryset = TicketPriority.objects.all() + serializer_class = TicketPrioritySerializer + permission_classes = (permissions.IsAuthenticated,) + + +class PriorityDetailView(generics.RetrieveUpdateDestroyAPIView): + queryset = TicketPriority.objects.all() + serializer_class = TicketPrioritySerializer + permission_classes = (permissions.IsAuthenticated,) -# Create your views here. \ No newline at end of file