feat: add ticket domain models

This commit is contained in:
hailin
2026-06-30 18:31:45 +08:00
parent 71be610753
commit 701f4276a2
9 changed files with 471 additions and 0 deletions

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TicketsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.tickets"

View File

@@ -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"),
),
]

View File

@@ -0,0 +1 @@

View File

@@ -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"]

View File

@@ -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

View File

@@ -0,0 +1 @@

View File

@@ -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")

View File

@@ -18,6 +18,7 @@ INSTALLED_APPS = [
"apps.accounts",
"apps.audit",
"apps.system",
"apps.tickets",
]
MIDDLEWARE = [