From f8298632b6414979f0ac9e9ce4a50e442b7fb843 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 30 Jun 2026 18:42:04 +0800 Subject: [PATCH] feat: add ticket comments attachments and export --- backend/apps/tickets/api.py | 75 ++++++++++++++++++- backend/apps/tickets/schemas.py | 32 ++++++++ .../apps/tickets/tests/test_ticket_export.py | 49 ++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 backend/apps/tickets/tests/test_ticket_export.py diff --git a/backend/apps/tickets/api.py b/backend/apps/tickets/api.py index 2fc0e45..94602b1 100644 --- a/backend/apps/tickets/api.py +++ b/backend/apps/tickets/api.py @@ -1,10 +1,23 @@ +import csv + 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 apps.tickets.models import Ticket, TicketEvent -from apps.tickets.schemas import TicketIn, TicketOut, TicketStatusIn, TicketUpdateIn +from apps.audit.models import OperationAuditLog +from apps.tickets.models import Ticket, TicketAttachment, TicketComment, TicketEvent +from apps.tickets.schemas import ( + TicketAttachmentIn, + TicketAttachmentOut, + TicketCommentIn, + TicketCommentOut, + TicketIn, + TicketOut, + TicketStatusIn, + TicketUpdateIn, +) from apps.tickets.services import transition_ticket router = Router(tags=["tickets"]) @@ -50,6 +63,33 @@ def list_tickets(request, status: str | None = None, q: str | None = None): return queryset[:100] +@router.get("/export") +def export_tickets(request): + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="tickets.csv"' + writer = csv.writer(response) + writer.writerow(["ticket_no", "title", "status", "priority", "created_at"]) + for ticket in Ticket.objects.all().order_by("ticket_no"): + writer.writerow( + [ + ticket.ticket_no, + ticket.title, + ticket.status, + ticket.priority, + ticket.created_at.isoformat(), + ] + ) + OperationAuditLog.objects.create( + user=request.user if request.user.is_authenticated else None, + module="tickets", + action="export", + target_type="ticket", + result="success", + request_snapshot={}, + ) + return response + + @router.get("/{ticket_id}", response=TicketOut) def get_ticket(request, ticket_id: int): return Ticket.objects.get(id=ticket_id) @@ -91,3 +131,34 @@ def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn): payload.remark, request.user if request.user.is_authenticated else None, ) + + +@router.post("/{ticket_id}/comments", response=TicketCommentOut) +def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn): + ticket = Ticket.objects.get(id=ticket_id) + comment = TicketComment.objects.create( + ticket=ticket, + content=payload.content, + comment_type=payload.comment_type, + visibility=payload.visibility, + created_by=request.user if request.user.is_authenticated else None, + ) + TicketEvent.objects.create( + ticket=ticket, + event_type="commented", + to_value={"comment_id": comment.id, "comment_type": comment.comment_type}, + operator=request.user if request.user.is_authenticated else None, + ) + return comment + + +@router.post("/{ticket_id}/attachments", response=TicketAttachmentOut) +def add_ticket_attachment(request, ticket_id: int, payload: TicketAttachmentIn): + ticket = Ticket.objects.get(id=ticket_id) + attachment = TicketAttachment.objects.create(ticket=ticket, **payload.dict()) + TicketEvent.objects.create( + ticket=ticket, + event_type="attachment_added", + to_value={"attachment_id": attachment.id, "file_name": attachment.file_name}, + ) + return attachment diff --git a/backend/apps/tickets/schemas.py b/backend/apps/tickets/schemas.py index dea71bb..29eab67 100644 --- a/backend/apps/tickets/schemas.py +++ b/backend/apps/tickets/schemas.py @@ -50,3 +50,35 @@ class TicketEventOut(Schema): to_value: dict remark: str created_at: datetime + + +class TicketCommentIn(Schema): + content: str + comment_type: str = "user" + visibility: str = "internal" + + +class TicketCommentOut(Schema): + id: int + content: str + comment_type: str + visibility: str + created_at: datetime + + +class TicketAttachmentIn(Schema): + file_name: str + file_type: str = "" + mime_type: str = "" + file_size: int = 0 + storage_path: str + + +class TicketAttachmentOut(Schema): + id: int + file_name: str + file_type: str + mime_type: str + file_size: int + storage_path: str + created_at: datetime diff --git a/backend/apps/tickets/tests/test_ticket_export.py b/backend/apps/tickets/tests/test_ticket_export.py new file mode 100644 index 0000000..988fd4d --- /dev/null +++ b/backend/apps/tickets/tests/test_ticket_export.py @@ -0,0 +1,49 @@ +from apps.audit.models import OperationAuditLog +from apps.tickets.models import Ticket + + +def test_add_comment_to_ticket(client, db): + ticket = Ticket.objects.create(ticket_no="TK202606300010", title="A", description="A") + + response = client.post( + f"/api/tickets/{ticket.id}/comments", + data={ + "content": "Checked logs", + "comment_type": "internal_note", + "visibility": "internal", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["content"] == "Checked logs" + assert ticket.comments.count() == 1 + + +def test_add_attachment_to_ticket(client, db): + ticket = Ticket.objects.create(ticket_no="TK202606300011", title="A", description="A") + + response = client.post( + f"/api/tickets/{ticket.id}/attachments", + data={ + "file_name": "error.log", + "mime_type": "text/plain", + "file_size": 123, + "storage_path": "tickets/TK202606300011/error.log", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["file_name"] == "error.log" + + +def test_export_tickets_returns_csv_and_audit_log(client, db): + Ticket.objects.create(ticket_no="TK202606300012", title="Export me", description="A") + + response = client.get("/api/tickets/export") + + assert response.status_code == 200 + assert response["content-type"].startswith("text/csv") + assert "TK202606300012" in response.content.decode() + assert OperationAuditLog.objects.filter(module="tickets", action="export").exists()