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:
sankalp committed 2026-06-25 23:11:07 +05:45
1 parent 03bda8889b
commit e220cfdaa3
5 files changed
+574 -15

No files matched your search

+62 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.