From c293e3f56ab437c0db049facbdd97d60140ebf1d Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 30 Jun 2026 18:36:52 +0800 Subject: [PATCH] feat: add ticket workflow APIs --- backend/apps/tickets/api.py | 93 +++++++++++++++++++ backend/apps/tickets/schemas.py | 52 +++++++++++ backend/apps/tickets/tests/test_ticket_api.py | 55 +++++++++++ backend/config/urls.py | 2 + 4 files changed, 202 insertions(+) create mode 100644 backend/apps/tickets/api.py create mode 100644 backend/apps/tickets/schemas.py create mode 100644 backend/apps/tickets/tests/test_ticket_api.py diff --git a/backend/apps/tickets/api.py b/backend/apps/tickets/api.py new file mode 100644 index 0000000..2fc0e45 --- /dev/null +++ b/backend/apps/tickets/api.py @@ -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, + ) diff --git a/backend/apps/tickets/schemas.py b/backend/apps/tickets/schemas.py new file mode 100644 index 0000000..dea71bb --- /dev/null +++ b/backend/apps/tickets/schemas.py @@ -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 diff --git a/backend/apps/tickets/tests/test_ticket_api.py b/backend/apps/tickets/tests/test_ticket_api.py new file mode 100644 index 0000000..aec4753 --- /dev/null +++ b/backend/apps/tickets/tests/test_ticket_api.py @@ -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() diff --git a/backend/config/urls.py b/backend/config/urls.py index 955b750..d9af2b1 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -4,10 +4,12 @@ from ninja import NinjaAPI from apps.audit.api import router as audit_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.add_router("/system", system_router) api.add_router("/audit", audit_router) +api.add_router("/tickets", tickets_router) urlpatterns = [ path("admin/", admin.site.urls),