feat: add ticket list visual support api
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user