337 lines
12 KiB
Python
337 lines
12 KiB
Python
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
|