feat(tickets): add Ticketing app with models, admin, serializers and views
Adds full ticketing functionality for issue tracking, including priority and status management, comments, history, and API endpoints.
This commit is contained in:
5 files changed
+574
-15
No files matched your search
+62
-3
@@ -1,5 +1,64 @@
|
||||
"""generated with djinit"""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
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")
|
||||
|
||||
+183
-2
@@ -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.
|
||||
|
||||
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}"
|
||||
|
||||
+172
-3
@@ -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.
|
||||
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
|
||||
|
||||
+23
-4
@@ -1,7 +1,26 @@
|
||||
"""generated with djinit"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from src.tickets import views
|
||||
|
||||
urlpatterns = [
|
||||
# Add your URL patterns here
|
||||
]
|
||||
path("", views.TicketListCreateView.as_view(), name="ticket-list"),
|
||||
path("priorities/", views.PriorityListView.as_view(), name="priority-list"),
|
||||
path(
|
||||
"priorities/<int:pk>/",
|
||||
views.PriorityDetailView.as_view(),
|
||||
name="priority-detail",
|
||||
),
|
||||
path("<int:pk>/", views.TicketDetailView.as_view(), name="ticket-detail"),
|
||||
path("<int:pk>/move/", views.move_ticket_view, name="ticket-move"),
|
||||
path(
|
||||
"<int:ticket_pk>/comments/",
|
||||
views.TicketCommentListCreateView.as_view(),
|
||||
name="ticket-comment-list",
|
||||
),
|
||||
path(
|
||||
"<int:ticket_pk>/histories/",
|
||||
views.TicketHistoryListView.as_view(),
|
||||
name="ticket-history-list",
|
||||
),
|
||||
]
|
||||
|
||||
+134
-3
@@ -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.
|
||||
Reference in new issue
Block a user