feat: add ticket list visual support api

This commit is contained in:
hailin
2026-07-01 15:10:21 +08:00
parent 6a2770b966
commit 4a44f66505
4 changed files with 181 additions and 12 deletions

View File

@@ -26,7 +26,7 @@ def test_user_with_ticket_view_can_list_tickets(client, db):
response = client.get("/api/tickets") response = client.get("/api/tickets")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()[0]["ticket_no"] == "TK202607010001" assert response.json()["items"][0]["ticket_no"] == "TK202607010001"
def test_user_without_ticket_create_cannot_create_ticket(client, db): def test_user_without_ticket_create_cannot_create_ticket(client, db):

View File

@@ -1,10 +1,12 @@
import csv import csv
from math import ceil
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponse from django.http import HttpResponse
from django.utils import timezone from django.utils import timezone
from ninja import Router from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_permission from apps.accounts.api import require_permission
from apps.audit.models import OperationAuditLog from apps.audit.models import OperationAuditLog
@@ -15,6 +17,7 @@ from apps.tickets.schemas import (
TicketCommentIn, TicketCommentIn,
TicketCommentOut, TicketCommentOut,
TicketIn, TicketIn,
TicketListOut,
TicketOut, TicketOut,
TicketStatusIn, TicketStatusIn,
TicketUpdateIn, TicketUpdateIn,
@@ -30,6 +33,36 @@ def next_ticket_no() -> str:
return f"{prefix}{count:04d}" return f"{prefix}{count:04d}"
def serialize_ticket_user(user):
if user is None:
return None
return {
"id": user.id,
"username": user.username,
"display_name": user.get_full_name() or user.username,
}
def serialize_ticket(ticket: Ticket) -> dict:
return {
"id": ticket.id,
"ticket_no": ticket.ticket_no,
"title": ticket.title,
"description": ticket.description,
"status": ticket.status,
"priority": ticket.priority,
"impact_scope": ticket.impact_scope,
"environment": ticket.environment,
"related_system": ticket.related_system,
"requester_id": ticket.requester_id,
"assignee_id": ticket.assignee_id,
"requester": serialize_ticket_user(ticket.requester),
"assignee": serialize_ticket_user(ticket.assignee),
"created_at": ticket.created_at,
"updated_at": ticket.updated_at,
}
@router.post("", response=TicketOut) @router.post("", response=TicketOut)
def create_ticket(request, payload: TicketIn): def create_ticket(request, payload: TicketIn):
require_permission(request, "ticket.create") require_permission(request, "ticket.create")
@@ -45,6 +78,7 @@ def create_ticket(request, payload: TicketIn):
environment=payload.environment, environment=payload.environment,
related_system=payload.related_system, related_system=payload.related_system,
assignee=assignee, assignee=assignee,
requester=request.user if request.user.is_authenticated else None,
created_by=request.user if request.user.is_authenticated else None, created_by=request.user if request.user.is_authenticated else None,
) )
TicketEvent.objects.create( TicketEvent.objects.create(
@@ -52,18 +86,57 @@ def create_ticket(request, payload: TicketIn):
event_type="created", event_type="created",
to_value={"status": ticket.status}, to_value={"status": ticket.status},
) )
return ticket return serialize_ticket(ticket)
@router.get("", response=list[TicketOut]) @router.get("", response=TicketListOut)
def list_tickets(request, status: str | None = None, q: str | None = None): def list_tickets(
request,
page: int = 1,
page_size: int = 20,
status: str | None = None,
priority: str | None = None,
q: str | None = None,
related_system: str | None = None,
assignee_id: int | None = None,
view: str = "all",
):
require_permission(request, "ticket.view") require_permission(request, "ticket.view")
queryset = Ticket.objects.all() queryset = Ticket.objects.select_related("requester", "assignee").all()
if status: if status:
queryset = queryset.filter(status=status) queryset = queryset.filter(status=status)
if priority:
queryset = queryset.filter(priority=priority)
if q: if q:
queryset = queryset.filter(Q(ticket_no__icontains=q) | Q(title__icontains=q)) queryset = queryset.filter(
return queryset[:100] Q(ticket_no__icontains=q)
| Q(title__icontains=q)
| Q(description__icontains=q)
)
if related_system:
queryset = queryset.filter(related_system__icontains=related_system)
if assignee_id:
queryset = queryset.filter(assignee_id=assignee_id)
if view == "assigned_to_me":
queryset = queryset.filter(assignee=request.user)
if view == "created_by_me":
queryset = queryset.filter(created_by=request.user)
if view == "high_priority":
queryset = queryset.filter(priority__in=["high", "urgent"])
page = max(page, 1)
page_size = min(max(page_size, 1), 100)
total = queryset.count()
total_pages = ceil(total / page_size) if total else 0
start = (page - 1) * page_size
end = start + page_size
return {
"items": [serialize_ticket(ticket) for ticket in queryset[start:end]],
"page": page,
"page_size": page_size,
"total": total,
"total_pages": total_pages,
}
@router.get("/export") @router.get("/export")
@@ -97,7 +170,9 @@ def export_tickets(request):
@router.get("/{ticket_id}", response=TicketOut) @router.get("/{ticket_id}", response=TicketOut)
def get_ticket(request, ticket_id: int): def get_ticket(request, ticket_id: int):
require_permission(request, "ticket.view") require_permission(request, "ticket.view")
return Ticket.objects.get(id=ticket_id) return serialize_ticket(
Ticket.objects.select_related("requester", "assignee").get(id=ticket_id)
)
@router.patch("/{ticket_id}", response=TicketOut) @router.patch("/{ticket_id}", response=TicketOut)
@@ -125,19 +200,22 @@ def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
to_value=payload.dict(exclude_unset=True), to_value=payload.dict(exclude_unset=True),
operator=request.user if request.user.is_authenticated else None, operator=request.user if request.user.is_authenticated else None,
) )
return ticket return serialize_ticket(ticket)
@router.post("/{ticket_id}/status", response=TicketOut) @router.post("/{ticket_id}/status", response=TicketOut)
def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn): def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
require_permission(request, "ticket.edit") require_permission(request, "ticket.edit")
if not payload.remark.strip():
raise HttpError(400, "Transition remark is required")
ticket = Ticket.objects.get(id=ticket_id) ticket = Ticket.objects.get(id=ticket_id)
return transition_ticket( updated = transition_ticket(
ticket, ticket,
payload.status, payload.status,
payload.remark, payload.remark,
request.user if request.user.is_authenticated else None, request.user if request.user.is_authenticated else None,
) )
return serialize_ticket(updated)
@router.post("/{ticket_id}/comments", response=TicketCommentOut) @router.post("/{ticket_id}/comments", response=TicketCommentOut)

View File

@@ -28,6 +28,12 @@ class TicketStatusIn(Schema):
remark: str = "" remark: str = ""
class TicketUserOut(Schema):
id: int
username: str
display_name: str
class TicketOut(Schema): class TicketOut(Schema):
id: int id: int
ticket_no: str ticket_no: str
@@ -38,11 +44,22 @@ class TicketOut(Schema):
impact_scope: str impact_scope: str
environment: str environment: str
related_system: str related_system: str
requester_id: int | None
assignee_id: int | None assignee_id: int | None
requester: TicketUserOut | None = None
assignee: TicketUserOut | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class TicketListOut(Schema):
items: list[TicketOut]
page: int
page_size: int
total: int
total_pages: int
class TicketEventOut(Schema): class TicketEventOut(Schema):
id: int id: int
event_type: str event_type: str

View File

@@ -1,3 +1,5 @@
from django.contrib.auth import get_user_model
from apps.tickets.models import Ticket, TicketEvent from apps.tickets.models import Ticket, TicketEvent
@@ -31,8 +33,63 @@ def test_list_tickets_filters_by_status(admin_client, db):
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert len(payload) == 1 assert payload["total"] == 1
assert payload[0]["ticket_no"] == "TK202606300001" assert payload["items"][0]["ticket_no"] == "TK202606300001"
def test_list_tickets_returns_paginated_response_with_display_users(admin_client, db):
user_model = get_user_model()
requester = user_model.objects.create_user(username="requester", first_name="请求人")
assignee = user_model.objects.create_user(username="assignee", first_name="处理人")
Ticket.objects.create(
ticket_no="TK202607010100",
title="Payment failed",
description="A",
requester=requester,
assignee=assignee,
related_system="CRM",
)
response = admin_client.get("/api/tickets?page=1&page_size=20")
assert response.status_code == 200
payload = response.json()
assert payload["page"] == 1
assert payload["page_size"] == 20
assert payload["total"] == 1
assert payload["total_pages"] == 1
assert payload["items"][0]["requester"]["display_name"] == "请求人"
assert payload["items"][0]["assignee"]["display_name"] == "处理人"
def test_list_tickets_filters_by_priority_related_system_and_view(admin_client, db):
user_model = get_user_model()
current_user = user_model.objects.get(username="admin")
Ticket.objects.create(
ticket_no="TK202607010101",
title="Mine urgent CRM",
description="A",
priority="urgent",
related_system="CRM",
assignee=current_user,
created_by=current_user,
)
Ticket.objects.create(
ticket_no="TK202607010102",
title="Other medium ERP",
description="B",
priority="medium",
related_system="ERP",
)
response = admin_client.get(
"/api/tickets?priority=urgent&related_system=CRM&view=assigned_to_me"
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 1
assert payload["items"][0]["ticket_no"] == "TK202607010101"
def test_transition_ticket_status_creates_event(admin_client, db): def test_transition_ticket_status_creates_event(admin_client, db):
@@ -53,3 +110,20 @@ def test_transition_ticket_status_creates_event(admin_client, db):
ticket.refresh_from_db() ticket.refresh_from_db()
assert ticket.status == "in_progress" assert ticket.status == "in_progress"
assert ticket.events.filter(event_type="status_change").exists() assert ticket.events.filter(event_type="status_change").exists()
def test_transition_ticket_requires_non_empty_remark(admin_client, db):
ticket = Ticket.objects.create(
ticket_no="TK202607010103",
title="A",
description="A",
status="pending",
)
response = admin_client.post(
f"/api/tickets/{ticket.id}/status",
data={"status": "in_progress", "remark": ""},
content_type="application/json",
)
assert response.status_code == 400