diff --git a/backend/apps/accounts/tests/test_permission_enforcement.py b/backend/apps/accounts/tests/test_permission_enforcement.py index 8beae9c..888ae9e 100644 --- a/backend/apps/accounts/tests/test_permission_enforcement.py +++ b/backend/apps/accounts/tests/test_permission_enforcement.py @@ -26,7 +26,7 @@ def test_user_with_ticket_view_can_list_tickets(client, db): response = client.get("/api/tickets") 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): diff --git a/backend/apps/tickets/api.py b/backend/apps/tickets/api.py index b768fb2..8ffea76 100644 --- a/backend/apps/tickets/api.py +++ b/backend/apps/tickets/api.py @@ -1,10 +1,12 @@ import csv +from math import ceil from django.contrib.auth import get_user_model from django.db.models import Q from django.http import HttpResponse from django.utils import timezone from ninja import Router +from ninja.errors import HttpError from apps.accounts.api import require_permission from apps.audit.models import OperationAuditLog @@ -15,6 +17,7 @@ from apps.tickets.schemas import ( TicketCommentIn, TicketCommentOut, TicketIn, + TicketListOut, TicketOut, TicketStatusIn, TicketUpdateIn, @@ -30,6 +33,36 @@ def next_ticket_no() -> str: 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) def create_ticket(request, payload: TicketIn): require_permission(request, "ticket.create") @@ -45,6 +78,7 @@ def create_ticket(request, payload: TicketIn): environment=payload.environment, related_system=payload.related_system, assignee=assignee, + requester=request.user if request.user.is_authenticated else None, created_by=request.user if request.user.is_authenticated else None, ) TicketEvent.objects.create( @@ -52,18 +86,57 @@ def create_ticket(request, payload: TicketIn): event_type="created", to_value={"status": ticket.status}, ) - return ticket + return serialize_ticket(ticket) -@router.get("", response=list[TicketOut]) -def list_tickets(request, status: str | None = None, q: str | None = None): +@router.get("", response=TicketListOut) +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") - queryset = Ticket.objects.all() + queryset = Ticket.objects.select_related("requester", "assignee").all() if status: queryset = queryset.filter(status=status) + if priority: + queryset = queryset.filter(priority=priority) if q: - queryset = queryset.filter(Q(ticket_no__icontains=q) | Q(title__icontains=q)) - return queryset[:100] + queryset = queryset.filter( + 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") @@ -97,7 +170,9 @@ def export_tickets(request): @router.get("/{ticket_id}", response=TicketOut) def get_ticket(request, ticket_id: int): 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) @@ -125,19 +200,22 @@ def update_ticket(request, ticket_id: int, payload: TicketUpdateIn): to_value=payload.dict(exclude_unset=True), operator=request.user if request.user.is_authenticated else None, ) - return ticket + return serialize_ticket(ticket) @router.post("/{ticket_id}/status", response=TicketOut) def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn): require_permission(request, "ticket.edit") + if not payload.remark.strip(): + raise HttpError(400, "Transition remark is required") ticket = Ticket.objects.get(id=ticket_id) - return transition_ticket( + updated = transition_ticket( ticket, payload.status, payload.remark, request.user if request.user.is_authenticated else None, ) + return serialize_ticket(updated) @router.post("/{ticket_id}/comments", response=TicketCommentOut) diff --git a/backend/apps/tickets/schemas.py b/backend/apps/tickets/schemas.py index 29eab67..d825419 100644 --- a/backend/apps/tickets/schemas.py +++ b/backend/apps/tickets/schemas.py @@ -28,6 +28,12 @@ class TicketStatusIn(Schema): remark: str = "" +class TicketUserOut(Schema): + id: int + username: str + display_name: str + + class TicketOut(Schema): id: int ticket_no: str @@ -38,11 +44,22 @@ class TicketOut(Schema): impact_scope: str environment: str related_system: str + requester_id: int | None assignee_id: int | None + requester: TicketUserOut | None = None + assignee: TicketUserOut | None = None created_at: datetime updated_at: datetime +class TicketListOut(Schema): + items: list[TicketOut] + page: int + page_size: int + total: int + total_pages: int + + class TicketEventOut(Schema): id: int event_type: str diff --git a/backend/apps/tickets/tests/test_ticket_api.py b/backend/apps/tickets/tests/test_ticket_api.py index 7535a22..fd066a9 100644 --- a/backend/apps/tickets/tests/test_ticket_api.py +++ b/backend/apps/tickets/tests/test_ticket_api.py @@ -1,3 +1,5 @@ +from django.contrib.auth import get_user_model + 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 payload = response.json() - assert len(payload) == 1 - assert payload[0]["ticket_no"] == "TK202606300001" + assert payload["total"] == 1 + 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): @@ -53,3 +110,20 @@ def test_transition_ticket_status_creates_event(admin_client, db): ticket.refresh_from_db() assert ticket.status == "in_progress" 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