import csv from datetime import timedelta 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 from apps.tickets.models import Ticket, TicketAttachment, TicketCategory, TicketComment, TicketEvent from apps.tickets.schemas import ( TicketAttachmentIn, TicketAttachmentOut, TicketCommentIn, TicketCommentOut, TicketEventOut, TicketIn, TicketListOut, TicketOut, TicketStatusIn, TicketUpdateIn, ) from apps.tickets.services import ( TicketDataError, TicketStatusError, create_ticket_from_fields, transition_requires_remark, 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}" 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_category(category): if category is None: return None return { "id": category.id, "code": category.code, "name": category.name, "description": category.description, } def serialize_ticket(ticket: Ticket) -> dict: return { "id": ticket.id, "ticket_no": ticket.ticket_no, "title": ticket.title, "description": ticket.description, "category": serialize_ticket_category(ticket.category), "category_code": ticket.category.code if ticket.category_id else None, "status": ticket.status, "priority": ticket.priority, "problem_performance": ticket.problem_performance, "problem_object": ticket.problem_object, "submitter_name": ticket.submitter_name, "submitter_org": ticket.submitter_org, "ai_solution": ticket.ai_solution, "resolution": ticket.resolution_summary, "is_resolved": ticket.is_resolved, "replied_to_customer": ticket.replied_to_customer, "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, } def serialize_ticket_event(event: TicketEvent) -> dict: return { "id": event.id, "event_type": event.event_type, "from_value": event.from_value, "to_value": event.to_value, "remark": event.remark, "operator": serialize_ticket_user(event.operator), "created_at": event.created_at, } @router.post("", response=TicketOut) def create_ticket(request, payload: TicketIn): require_permission(request, "ticket.create") try: ticket = create_ticket_from_fields(payload.dict(), request.user) except TicketDataError as error: raise HttpError(400, str(error)) from error return serialize_ticket(ticket) @router.get("", response=TicketListOut) def list_tickets( request, page: int = 1, page_size: int = 20, status: str | None = None, priority: str | None = None, category_code: str | None = None, q: str | None = None, related_system: str | None = None, assignee_id: int | None = None, view: str = "all", status_group: str | None = None, resolved_within_days: int | None = None, ): require_permission(request, "ticket.view") queryset = Ticket.objects.select_related("requester", "assignee", "category").all() if status: queryset = queryset.filter(status=status) if priority: queryset = queryset.filter(priority=priority) if category_code: queryset = queryset.filter(category__code=category_code) if q: queryset = queryset.filter( Q(ticket_no__icontains=q) | Q(title__icontains=q) | Q(description__icontains=q) | Q(problem_performance__icontains=q) | Q(problem_object__icontains=q) | Q(submitter_name__icontains=q) | Q(submitter_org__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"]) if status_group == "active": queryset = queryset.exclude(status__in=["resolved", "closed"]) elif status_group == "completed": queryset = queryset.filter(status__in=["resolved", "closed"]) elif status_group: raise HttpError(400, "Unknown status group") if resolved_within_days is not None: days = min(max(resolved_within_days, 1), 365) cutoff = timezone.now() - timedelta(days=days) queryset = queryset.filter( Q(resolved_at__gte=cutoff) | Q(closed_at__gte=cutoff) ) 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") def export_tickets(request): require_permission(request, "ticket.export") 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): require_permission(request, "ticket.view") return serialize_ticket( Ticket.objects.select_related("requester", "assignee", "category").get(id=ticket_id) ) @router.get("/{ticket_id}/events", response=list[TicketEventOut]) def list_ticket_events(request, ticket_id: int): require_permission(request, "ticket.view") events = TicketEvent.objects.select_related("operator").filter(ticket_id=ticket_id) return [serialize_ticket_event(event) for event in events] @router.patch("/{ticket_id}", response=TicketOut) def update_ticket(request, ticket_id: int, payload: TicketUpdateIn): require_permission(request, "ticket.edit") ticket = Ticket.objects.get(id=ticket_id) before = { "title": ticket.title, "category_code": ticket.category.code if ticket.category_id else None, "priority": ticket.priority, "problem_performance": ticket.problem_performance, "problem_object": ticket.problem_object, "submitter_name": ticket.submitter_name, "submitter_org": ticket.submitter_org, "resolution": ticket.resolution_summary, "is_resolved": ticket.is_resolved, "replied_to_customer": ticket.replied_to_customer, "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 elif field == "category_code": category = ( TicketCategory.objects.filter(code=value, enabled=True).first() if value else None ) if value and category is None: raise HttpError(400, "Unknown ticket category") ticket.category = category elif field == "resolution": ticket.resolution_summary = value or "" 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 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") ticket = Ticket.objects.select_related("category").get(id=ticket_id) category_code = ticket.category.code if ticket.category_id else None has_resolution = bool(payload.resolution.strip()) if transition_requires_remark( ticket.status, payload.status, category_code, ) and not payload.remark.strip(): raise HttpError(400, "Transition remark is required") if payload.status == "resolved" and not (payload.remark.strip() or has_resolution): raise HttpError(400, "Resolution or transition remark is required") try: updated = transition_ticket( ticket, payload.status, payload.remark, request.user if request.user.is_authenticated else None, resolution=payload.resolution.strip(), is_resolved=payload.is_resolved, replied_to_customer=payload.replied_to_customer, ) except TicketStatusError as error: raise HttpError(400, str(error)) from error return serialize_ticket(updated) @router.post("/{ticket_id}/comments", response=TicketCommentOut) def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn): require_permission(request, "ticket.edit") 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): require_permission(request, "ticket.edit") 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}, operator=request.user if request.user.is_authenticated else None, ) return attachment