diff --git a/backend/apps/tickets/__init__.py b/backend/apps/tickets/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/tickets/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/tickets/apps.py b/backend/apps/tickets/apps.py new file mode 100644 index 0000000..dbba684 --- /dev/null +++ b/backend/apps/tickets/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TicketsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.tickets" diff --git a/backend/apps/tickets/migrations/0001_initial.py b/backend/apps/tickets/migrations/0001_initial.py new file mode 100644 index 0000000..950a183 --- /dev/null +++ b/backend/apps/tickets/migrations/0001_initial.py @@ -0,0 +1,255 @@ +# Generated by Django 5.2.15 on 2026-06-30 10:31 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Ticket", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("ticket_no", models.CharField(max_length=32, unique=True)), + ("title", models.CharField(max_length=200)), + ("description", models.TextField()), + ( + "status", + models.CharField( + choices=[ + ("pending_info", "Pending info"), + ("pending", "Pending"), + ("in_progress", "In progress"), + ("pending_confirm", "Pending confirm"), + ("resolved", "Resolved"), + ("closed", "Closed"), + ], + default="pending", + max_length=32, + ), + ), + ( + "priority", + models.CharField( + choices=[ + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("urgent", "Urgent"), + ], + default="medium", + max_length=20, + ), + ), + ("impact_scope", models.CharField(blank=True, max_length=80)), + ("environment", models.CharField(blank=True, max_length=80)), + ("related_system", models.CharField(blank=True, max_length=120)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("resolved_at", models.DateTimeField(blank=True, null=True)), + ("closed_at", models.DateTimeField(blank=True, null=True)), + ( + "assignee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="assigned_tickets", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_tickets", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "requester", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="requested_tickets", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="TicketAttachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("file_name", models.CharField(max_length=255)), + ("file_type", models.CharField(blank=True, max_length=80)), + ("mime_type", models.CharField(blank=True, max_length=120)), + ("file_size", models.PositiveBigIntegerField(default=0)), + ("storage_path", models.CharField(max_length=500)), + ("ocr_text", models.TextField(blank=True)), + ("parsed_text", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "ticket", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attachments", + to="tickets.ticket", + ), + ), + ], + ), + migrations.CreateModel( + name="TicketCategory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("name", models.CharField(max_length=120)), + ("code", models.CharField(max_length=80, unique=True)), + ("description", models.TextField(blank=True)), + ("enabled", models.BooleanField(default=True)), + ("sort_order", models.PositiveIntegerField(default=0)), + ( + "parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="tickets.ticketcategory", + ), + ), + ], + options={ + "ordering": ["sort_order", "name"], + }, + ), + migrations.AddField( + model_name="ticket", + name="category", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="tickets.ticketcategory", + ), + ), + migrations.CreateModel( + name="TicketComment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("content", models.TextField()), + ("comment_type", models.CharField(default="user", max_length=40)), + ("visibility", models.CharField(default="internal", max_length=40)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "ticket", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="tickets.ticket", + ), + ), + ], + options={ + "ordering": ["created_at"], + }, + ), + migrations.CreateModel( + name="TicketEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("event_type", models.CharField(max_length=80)), + ("from_value", models.JSONField(blank=True, default=dict)), + ("to_value", models.JSONField(blank=True, default=dict)), + ("remark", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "operator", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "ticket", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="events", + to="tickets.ticket", + ), + ), + ], + options={ + "ordering": ["created_at"], + }, + ), + migrations.AddIndex( + model_name="ticket", + index=models.Index(fields=["ticket_no"], name="tickets_tic_ticket__c7390a_idx"), + ), + migrations.AddIndex( + model_name="ticket", + index=models.Index(fields=["status", "priority"], name="tickets_tic_status_b256f6_idx"), + ), + migrations.AddIndex( + model_name="ticket", + index=models.Index(fields=["created_at"], name="tickets_tic_created_5dd600_idx"), + ), + ] diff --git a/backend/apps/tickets/migrations/__init__.py b/backend/apps/tickets/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/tickets/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/tickets/models.py b/backend/apps/tickets/models.py new file mode 100644 index 0000000..01ab19a --- /dev/null +++ b/backend/apps/tickets/models.py @@ -0,0 +1,142 @@ +from django.conf import settings +from django.db import models + + +class TicketStatus(models.TextChoices): + PENDING_INFO = "pending_info", "Pending info" + PENDING = "pending", "Pending" + IN_PROGRESS = "in_progress", "In progress" + PENDING_CONFIRM = "pending_confirm", "Pending confirm" + RESOLVED = "resolved", "Resolved" + CLOSED = "closed", "Closed" + + +class TicketPriority(models.TextChoices): + LOW = "low", "Low" + MEDIUM = "medium", "Medium" + HIGH = "high", "High" + URGENT = "urgent", "Urgent" + + +class TicketCategory(models.Model): + parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL) + name = models.CharField(max_length=120) + code = models.CharField(max_length=80, unique=True) + description = models.TextField(blank=True) + enabled = models.BooleanField(default=True) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["sort_order", "name"] + + def __str__(self) -> str: + return self.name + + +class Ticket(models.Model): + ticket_no = models.CharField(max_length=32, unique=True) + title = models.CharField(max_length=200) + description = models.TextField() + category = models.ForeignKey(TicketCategory, null=True, blank=True, on_delete=models.SET_NULL) + status = models.CharField( + max_length=32, + choices=TicketStatus.choices, + default=TicketStatus.PENDING, + ) + priority = models.CharField( + max_length=20, + choices=TicketPriority.choices, + default=TicketPriority.MEDIUM, + ) + impact_scope = models.CharField(max_length=80, blank=True) + environment = models.CharField(max_length=80, blank=True) + related_system = models.CharField(max_length=120, blank=True) + requester = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="requested_tickets", + ) + assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="assigned_tickets", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_tickets", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + resolved_at = models.DateTimeField(null=True, blank=True) + closed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["ticket_no"]), + models.Index(fields=["status", "priority"]), + models.Index(fields=["created_at"]), + ] + + def __str__(self) -> str: + return f"{self.ticket_no} {self.title}" + + +class TicketComment(models.Model): + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="comments") + content = models.TextField() + comment_type = models.CharField(max_length=40, default="user") + visibility = models.CharField(max_length=40, default="internal") + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at"] + + +class TicketAttachment(models.Model): + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="attachments") + file_name = models.CharField(max_length=255) + file_type = models.CharField(max_length=80, blank=True) + mime_type = models.CharField(max_length=120, blank=True) + file_size = models.PositiveBigIntegerField(default=0) + storage_path = models.CharField(max_length=500) + ocr_text = models.TextField(blank=True) + parsed_text = models.TextField(blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + created_at = models.DateTimeField(auto_now_add=True) + + +class TicketEvent(models.Model): + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events") + event_type = models.CharField(max_length=80) + from_value = models.JSONField(default=dict, blank=True) + to_value = models.JSONField(default=dict, blank=True) + operator = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + remark = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at"] diff --git a/backend/apps/tickets/services.py b/backend/apps/tickets/services.py new file mode 100644 index 0000000..5e5c434 --- /dev/null +++ b/backend/apps/tickets/services.py @@ -0,0 +1,52 @@ +from django.utils import timezone + +from apps.audit.models import OperationAuditLog +from apps.tickets.models import Ticket, TicketEvent + + +class TicketStatusError(ValueError): + pass + + +ALLOWED_TRANSITIONS: dict[str, set[str]] = { + "pending_info": {"pending", "closed"}, + "pending": {"in_progress", "closed"}, + "in_progress": {"pending_confirm", "resolved", "closed"}, + "pending_confirm": {"resolved", "in_progress", "closed"}, + "resolved": {"closed"}, + "closed": set(), +} + + +def validate_status_transition(from_status: str, to_status: str) -> None: + if to_status not in ALLOWED_TRANSITIONS.get(from_status, set()): + raise TicketStatusError(f"Invalid ticket status transition: {from_status} -> {to_status}") + + +def transition_ticket(ticket: Ticket, to_status: str, remark: str = "", operator=None) -> Ticket: + from_status = ticket.status + validate_status_transition(from_status, to_status) + ticket.status = to_status + if to_status == "resolved": + ticket.resolved_at = timezone.now() + if to_status == "closed": + ticket.closed_at = timezone.now() + ticket.save(update_fields=["status", "resolved_at", "closed_at", "updated_at"]) + TicketEvent.objects.create( + ticket=ticket, + event_type="status_change", + from_value={"status": from_status}, + to_value={"status": to_status}, + operator=operator, + remark=remark, + ) + OperationAuditLog.objects.create( + user=operator, + module="tickets", + action="status_transition", + target_type="ticket", + target_id=str(ticket.id), + result="success", + request_snapshot={"from": from_status, "to": to_status, "remark": remark}, + ) + return ticket diff --git a/backend/apps/tickets/tests/__init__.py b/backend/apps/tickets/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/tickets/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/tickets/tests/test_ticket_status.py b/backend/apps/tickets/tests/test_ticket_status.py new file mode 100644 index 0000000..dc94bf4 --- /dev/null +++ b/backend/apps/tickets/tests/test_ticket_status.py @@ -0,0 +1,12 @@ +import pytest + +from apps.tickets.services import TicketStatusError, validate_status_transition + + +def test_pending_ticket_can_move_to_in_progress(): + validate_status_transition("pending", "in_progress") + + +def test_resolved_ticket_cannot_move_back_to_in_progress(): + with pytest.raises(TicketStatusError, match="resolved -> in_progress"): + validate_status_transition("resolved", "in_progress") diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 1ca163c..22f9b75 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -18,6 +18,7 @@ INSTALLED_APPS = [ "apps.accounts", "apps.audit", "apps.system", + "apps.tickets", ] MIDDLEWARE = [