feat: add ticket workflow APIs
This commit is contained in:
93
backend/apps/tickets/api.py
Normal file
93
backend/apps/tickets/api.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from ninja import Router
|
||||||
|
|
||||||
|
from apps.tickets.models import Ticket, TicketEvent
|
||||||
|
from apps.tickets.schemas import TicketIn, TicketOut, TicketStatusIn, TicketUpdateIn
|
||||||
|
from apps.tickets.services import transition_ticket
|
||||||
|
|
||||||
|
router = Router(tags=["tickets"])
|
||||||
|
|
||||||
|
|
||||||
|
def next_ticket_no() -> str:
|
||||||
|
prefix = timezone.localdate().strftime("TK%Y%m%d")
|
||||||
|
count = Ticket.objects.filter(ticket_no__startswith=prefix).count() + 1
|
||||||
|
return f"{prefix}{count:04d}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response=TicketOut)
|
||||||
|
def create_ticket(request, payload: TicketIn):
|
||||||
|
assignee = None
|
||||||
|
if payload.assignee_id:
|
||||||
|
assignee = get_user_model().objects.filter(id=payload.assignee_id).first()
|
||||||
|
ticket = Ticket.objects.create(
|
||||||
|
ticket_no=next_ticket_no(),
|
||||||
|
title=payload.title,
|
||||||
|
description=payload.description,
|
||||||
|
priority=payload.priority,
|
||||||
|
impact_scope=payload.impact_scope,
|
||||||
|
environment=payload.environment,
|
||||||
|
related_system=payload.related_system,
|
||||||
|
assignee=assignee,
|
||||||
|
created_by=request.user if request.user.is_authenticated else None,
|
||||||
|
)
|
||||||
|
TicketEvent.objects.create(
|
||||||
|
ticket=ticket,
|
||||||
|
event_type="created",
|
||||||
|
to_value={"status": ticket.status},
|
||||||
|
)
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response=list[TicketOut])
|
||||||
|
def list_tickets(request, status: str | None = None, q: str | None = None):
|
||||||
|
queryset = Ticket.objects.all()
|
||||||
|
if status:
|
||||||
|
queryset = queryset.filter(status=status)
|
||||||
|
if q:
|
||||||
|
queryset = queryset.filter(Q(ticket_no__icontains=q) | Q(title__icontains=q))
|
||||||
|
return queryset[:100]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{ticket_id}", response=TicketOut)
|
||||||
|
def get_ticket(request, ticket_id: int):
|
||||||
|
return Ticket.objects.get(id=ticket_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{ticket_id}", response=TicketOut)
|
||||||
|
def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
||||||
|
ticket = Ticket.objects.get(id=ticket_id)
|
||||||
|
before = {
|
||||||
|
"title": ticket.title,
|
||||||
|
"priority": ticket.priority,
|
||||||
|
"impact_scope": ticket.impact_scope,
|
||||||
|
"environment": ticket.environment,
|
||||||
|
"related_system": ticket.related_system,
|
||||||
|
}
|
||||||
|
for field, value in payload.dict(exclude_unset=True).items():
|
||||||
|
if field == "assignee_id":
|
||||||
|
user = get_user_model().objects.filter(id=value).first() if value else None
|
||||||
|
ticket.assignee = user
|
||||||
|
else:
|
||||||
|
setattr(ticket, field, value)
|
||||||
|
ticket.save()
|
||||||
|
TicketEvent.objects.create(
|
||||||
|
ticket=ticket,
|
||||||
|
event_type="updated",
|
||||||
|
from_value=before,
|
||||||
|
to_value=payload.dict(exclude_unset=True),
|
||||||
|
operator=request.user if request.user.is_authenticated else None,
|
||||||
|
)
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{ticket_id}/status", response=TicketOut)
|
||||||
|
def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
|
||||||
|
ticket = Ticket.objects.get(id=ticket_id)
|
||||||
|
return transition_ticket(
|
||||||
|
ticket,
|
||||||
|
payload.status,
|
||||||
|
payload.remark,
|
||||||
|
request.user if request.user.is_authenticated else None,
|
||||||
|
)
|
||||||
52
backend/apps/tickets/schemas.py
Normal file
52
backend/apps/tickets/schemas.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class TicketIn(Schema):
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
priority: str = "medium"
|
||||||
|
impact_scope: str = ""
|
||||||
|
environment: str = ""
|
||||||
|
related_system: str = ""
|
||||||
|
assignee_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketUpdateIn(Schema):
|
||||||
|
title: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
priority: str | None = None
|
||||||
|
impact_scope: str | None = None
|
||||||
|
environment: str | None = None
|
||||||
|
related_system: str | None = None
|
||||||
|
assignee_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketStatusIn(Schema):
|
||||||
|
status: str
|
||||||
|
remark: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class TicketOut(Schema):
|
||||||
|
id: int
|
||||||
|
ticket_no: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
priority: str
|
||||||
|
impact_scope: str
|
||||||
|
environment: str
|
||||||
|
related_system: str
|
||||||
|
assignee_id: int | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TicketEventOut(Schema):
|
||||||
|
id: int
|
||||||
|
event_type: str
|
||||||
|
from_value: dict
|
||||||
|
to_value: dict
|
||||||
|
remark: str
|
||||||
|
created_at: datetime
|
||||||
55
backend/apps/tickets/tests/test_ticket_api.py
Normal file
55
backend/apps/tickets/tests/test_ticket_api.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from apps.tickets.models import Ticket, TicketEvent
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_ticket_returns_ticket_detail(client, db):
|
||||||
|
response = client.post(
|
||||||
|
"/api/tickets",
|
||||||
|
data={
|
||||||
|
"title": "Payment callback fails",
|
||||||
|
"description": "Callback returns HTTP 500 in production",
|
||||||
|
"priority": "high",
|
||||||
|
"impact_scope": "payment users",
|
||||||
|
"environment": "production",
|
||||||
|
"related_system": "payment",
|
||||||
|
},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["ticket_no"].startswith("TK")
|
||||||
|
assert payload["title"] == "Payment callback fails"
|
||||||
|
assert payload["status"] == "pending"
|
||||||
|
assert TicketEvent.objects.filter(ticket_id=payload["id"], event_type="created").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_tickets_filters_by_status(client, db):
|
||||||
|
Ticket.objects.create(ticket_no="TK202606300001", title="A", description="A", status="pending")
|
||||||
|
Ticket.objects.create(ticket_no="TK202606300002", title="B", description="B", status="resolved")
|
||||||
|
|
||||||
|
response = client.get("/api/tickets?status=pending")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert len(payload) == 1
|
||||||
|
assert payload[0]["ticket_no"] == "TK202606300001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_ticket_status_creates_event(client, db):
|
||||||
|
ticket = Ticket.objects.create(
|
||||||
|
ticket_no="TK202606300003",
|
||||||
|
title="A",
|
||||||
|
description="A",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/tickets/{ticket.id}/status",
|
||||||
|
data={"status": "in_progress", "remark": "Start handling"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
ticket.refresh_from_db()
|
||||||
|
assert ticket.status == "in_progress"
|
||||||
|
assert ticket.events.filter(event_type="status_change").exists()
|
||||||
@@ -4,10 +4,12 @@ from ninja import NinjaAPI
|
|||||||
|
|
||||||
from apps.audit.api import router as audit_router
|
from apps.audit.api import router as audit_router
|
||||||
from apps.system.api import router as system_router
|
from apps.system.api import router as system_router
|
||||||
|
from apps.tickets.api import router as tickets_router
|
||||||
|
|
||||||
api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0")
|
api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0")
|
||||||
api.add_router("/system", system_router)
|
api.add_router("/system", system_router)
|
||||||
api.add_router("/audit", audit_router)
|
api.add_router("/audit", audit_router)
|
||||||
|
api.add_router("/tickets", tickets_router)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
|||||||
Reference in New Issue
Block a user