Files
ai-ticket-platform/backend/apps/tickets/api.py
2026-07-27 18:57:51 +08:00

998 lines
36 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.agents.services import AgentValidationError
from apps.audit.models import OperationAuditLog
from apps.knowledge.distillation import (
KnowledgeDistillationError,
start_ticket_knowledge_distillation,
)
from apps.knowledge.models import TicketKnowledgeDistillation
from apps.skills.services import SkillValidationError
from apps.tickets.evaluation import build_intake_quality_metrics, record_evaluation_result
from apps.tickets.models import (
CustomerReplyTask,
IntakeEvaluationCase,
SupportEvaluationCase,
Ticket,
TicketAttachment,
TicketCollaborator,
TicketCollaboratorRole,
TicketComment,
TicketDraft,
TicketEvent,
TicketFieldEvidence,
TicketSimilaritySuggestion,
TicketSupportAnalysis,
)
from apps.tickets.replies import (
CustomerReplyError,
confirm_customer_reply_sent,
update_customer_reply,
)
from apps.tickets.schemas import (
CustomerReplyConfirmIn,
CustomerReplyTaskOut,
CustomerReplyUpdateIn,
IntakeEvaluationRecordIn,
IntakeEvaluationResultOut,
IntakeQualityMetricsOut,
SupportEvaluationRecordIn,
SupportEvaluationResultOut,
SupportQualityMetricsOut,
TicketAttachmentIn,
TicketAttachmentOut,
TicketCollaboratorIn,
TicketCollaboratorOut,
TicketCommentIn,
TicketCommentOut,
TicketEventOut,
TicketFieldEvidenceOut,
TicketIn,
TicketIntakeTraceOut,
TicketKnowledgeDistillationOut,
TicketListOut,
TicketOut,
TicketSimilaritySuggestionOut,
TicketStatusIn,
TicketSupportAnalysisOut,
TicketUpdateIn,
TicketUserOut,
)
from apps.tickets.services import (
TicketDataError,
TicketStatusError,
create_ticket_from_fields,
set_ticket_archived,
transition_requires_remark,
transition_ticket,
update_ticket_fields,
)
from apps.tickets.similarity import ALGORITHM_VERSION, find_similar_tickets
from apps.tickets.support_analysis import (
SupportAnalysisError,
start_ticket_support_analysis,
)
from apps.tickets.support_evaluation import (
SupportEvaluationError,
build_support_quality_metrics,
record_support_evaluation_result,
)
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:
resolution = getattr(ticket, "resolution", None)
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,
"resolution": resolution.solution_text if resolution else "",
"resolution_id": resolution.id if resolution else None,
"resolution_type": resolution.resolution_type if resolution else None,
"resolution_source": resolution.source if resolution else None,
"resolution_root_cause": resolution.root_cause if resolution else "",
"linked_solution_id": resolution.linked_solution_id if resolution else None,
"is_resolved": resolution is not None and ticket.status in {"resolved", "closed"},
"replied_to_customer": ticket.replied_to_customer,
"impact_scope": ticket.impact_scope,
"environment": ticket.environment,
"related_system": ticket.related_system,
"product_name": ticket.product_name,
"module_name": ticket.module_name,
"tags": ticket.tags,
"attempted_actions": ticket.attempted_actions,
"error_information": ticket.error_information,
"customer_request": ticket.customer_request,
"current_result": ticket.current_result,
"requester_id": ticket.requester_id,
"assignee_id": ticket.assignee_id,
"requester": serialize_ticket_user(ticket.requester),
"assignee": serialize_ticket_user(ticket.assignee),
"problem_started_at": ticket.problem_started_at,
"customer_last_message_at": ticket.customer_last_message_at,
"support_first_response_at": ticket.support_first_response_at,
"support_last_reply_at": ticket.support_last_reply_at,
"resolved_at": ticket.resolved_at,
"closed_at": ticket.closed_at,
"archived_at": ticket.archived_at,
"archived_by": serialize_ticket_user(ticket.archived_by),
"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),
"source": event.source,
"agent_run_id": event.agent_run_id,
"metadata": event.metadata,
"created_at": event.created_at,
}
def serialize_ticket_collaborator(collaborator: TicketCollaborator) -> dict:
return {
"id": collaborator.id,
"user": serialize_ticket_user(collaborator.user),
"role": collaborator.role,
"added_by": serialize_ticket_user(collaborator.added_by),
"created_at": collaborator.created_at,
}
def serialize_ticket_comment(comment: TicketComment) -> dict:
return {
"id": comment.id,
"content": comment.content,
"comment_type": comment.comment_type,
"visibility": comment.visibility,
"created_by": serialize_ticket_user(comment.created_by),
"created_at": comment.created_at,
}
def serialize_field_evidence(item: TicketFieldEvidence) -> dict:
return {
"id": item.id,
"field_name": item.field_name,
"value": item.value,
"source": item.source,
"confidence": float(item.confidence) if item.confidence is not None else None,
"evidence": item.evidence,
"agent_run_id": item.agent_run_id,
"is_protected": item.is_protected,
"created_by": serialize_ticket_user(item.created_by),
"created_at": item.created_at,
}
def serialize_similarity_suggestion(item: TicketSimilaritySuggestion) -> dict:
candidate = item.candidate_ticket
return {
"ticket_id": candidate.id,
"ticket_no": candidate.ticket_no,
"title": candidate.title,
"status": candidate.status,
"category_code": candidate.category.code if candidate.category_id else None,
"score": float(item.score),
"title_similarity": float(item.title_similarity),
"keyword_score": float(item.keyword_score),
"matched_excerpt": item.matched_excerpt,
"algorithm_version": item.algorithm_version,
"created_at": item.created_at,
}
def serialize_dynamic_similarity(item: dict) -> dict:
candidate = item["ticket"]
return {
"ticket_id": candidate.id,
"ticket_no": candidate.ticket_no,
"title": candidate.title,
"status": candidate.status,
"category_code": candidate.category.code if candidate.category_id else None,
"score": item["score"],
"title_similarity": item["title_similarity"],
"keyword_score": item["keyword_score"],
"matched_excerpt": item["matched_excerpt"],
"algorithm_version": ALGORITHM_VERSION,
"created_at": None,
}
def serialize_support_analysis(item: TicketSupportAnalysis) -> dict:
run = item.run
return {
"id": item.id,
"ticket_id": item.ticket_id,
"run_public_id": str(run.public_id),
"agent_version": run.agent_version.version,
"status": run.status,
"progress_stage": run.progress_stage,
"progress_message": run.progress_message,
"query": item.query,
"retrieval_policy": item.retrieval_policy,
"references": item.references,
"final_output": run.final_output,
"automation_decision": item.automation_decision,
"applied_solution_id": item.applied_solution_id,
"processed_at": item.processed_at,
"error_code": run.error_code,
"error_message": run.error_message,
"created_at": item.created_at,
"started_at": run.started_at,
"finished_at": run.finished_at,
}
def serialize_knowledge_distillation(item: TicketKnowledgeDistillation) -> dict:
run = item.run
return {
"id": item.id,
"ticket_id": item.ticket_id,
"run_public_id": str(run.public_id),
"agent_version": run.agent_version.version,
"status": run.status,
"progress_stage": run.progress_stage,
"progress_message": run.progress_message,
"references": item.references,
"retrieval_policy": item.retrieval_policy,
"final_output": run.final_output,
"should_distill": item.should_distill,
"reason": item.reason,
"assessment": item.assessment,
"knowledge_item_id": item.knowledge_item_id,
"troubleshooting_plan_id": item.troubleshooting_plan_id,
"solution_id": item.solution_id,
"processed_at": item.processed_at,
"error_code": run.error_code,
"error_message": run.error_message,
"created_at": item.created_at,
"started_at": run.started_at,
"finished_at": run.finished_at,
}
def serialize_customer_reply_task(item: CustomerReplyTask) -> dict:
return {
"id": item.id,
"ticket_id": item.ticket_id,
"support_analysis_id": item.support_analysis_id,
"status": item.status,
"draft_text": item.draft_text,
"final_text": item.final_text,
"revision": item.revision,
"created_by_id": item.created_by_id,
"updated_by_id": item.updated_by_id,
"approved_by_id": item.approved_by_id,
"approved_at": item.approved_at,
"sent_by_id": item.sent_by_id,
"sent_at": item.sent_at,
"created_at": item.created_at,
"updated_at": item.updated_at,
"revisions": [
{
"version": revision.version,
"content": revision.content,
"source": revision.source,
"edited_by_id": revision.edited_by_id,
"created_at": revision.created_at,
}
for revision in item.revisions.all()
],
}
def serialize_evaluation_result(result) -> dict:
return {
"id": result.id,
"case_key": result.case.key,
"case_source_kind": result.case.source_kind,
"draft_public_id": str(result.draft.public_id),
"ticket_id": result.ticket_id,
"actual_auto_created": result.actual_auto_created,
"correct_field_count": result.correct_field_count,
"evaluated_field_count": result.evaluated_field_count,
"field_accuracy": (
float(result.field_accuracy) if result.field_accuracy is not None else None
),
"passed": result.passed,
"details": result.details,
"created_at": result.created_at,
"updated_at": result.updated_at,
}
def serialize_support_evaluation_result(result) -> dict:
return {
"id": result.id,
"case_key": result.case.key,
"case_source_kind": result.case.source_kind,
"analysis_id": result.analysis_id,
"ticket_id": result.analysis.ticket_id,
"actual_candidate_solution_ids": result.actual_candidate_solution_ids,
"actual_citation_ranks": result.actual_citation_ranks,
"matched_expected_solution": result.matched_expected_solution,
"actual_auto_resolved": result.actual_auto_resolved,
"actual_manual_fallback": result.actual_manual_fallback,
"incorrect_auto_reuse": result.incorrect_auto_reuse,
"citations_valid": result.citations_valid,
"passed": result.passed,
"details": result.details,
"created_at": result.created_at,
"updated_at": result.updated_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,
include_archived: bool = False,
):
require_permission(request, "ticket.view")
queryset = Ticket.objects.select_related(
"requester",
"assignee",
"category",
"resolution",
"resolution__linked_solution",
"archived_by",
)
if not include_archived:
queryset = queryset.filter(archived_at__isnull=True)
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)
| Q(product_name__icontains=q)
| Q(module_name__icontains=q)
| Q(error_information__icontains=q)
| Q(customer_request__icontains=q)
| Q(current_result__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.filter(archived_at__isnull=True).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("/intake-quality-metrics", response=IntakeQualityMetricsOut)
def get_intake_quality_metrics(request, include_synthetic: bool = False):
require_permission(request, "ticket.view")
return build_intake_quality_metrics(include_synthetic=include_synthetic)
@router.get("/participants", response=list[TicketUserOut])
def list_ticket_participants(request):
require_permission(request, "ticket.view")
users = get_user_model().objects.filter(is_active=True).order_by("first_name", "username", "id")
return [serialize_ticket_user(user) for user in users]
@router.post("/intake-evaluations", response=IntakeEvaluationResultOut)
def create_intake_evaluation(request, payload: IntakeEvaluationRecordIn):
require_permission(request, "ticket.edit")
case = IntakeEvaluationCase.objects.filter(key=payload.case_key, enabled=True).first()
if case is None:
raise HttpError(404, "评估案例不存在或已停用")
draft = TicketDraft.objects.select_related("confirmed_ticket").filter(
public_id=payload.draft_public_id
).first()
if draft is None:
raise HttpError(404, "受理记录不存在")
result = record_evaluation_result(case, draft, reviewer=request.user)
OperationAuditLog.objects.create(
user=request.user,
module="tickets",
action="intake_quality_evaluated",
target_type="ticket_draft",
target_id=str(draft.public_id),
request_snapshot={"case_key": case.key, "source_kind": case.source_kind},
result="success",
)
return serialize_evaluation_result(result)
@router.get("/support-quality-metrics", response=SupportQualityMetricsOut)
def get_support_quality_metrics(request, include_synthetic: bool = False):
require_permission(request, "ticket.view")
require_permission(request, "knowledge.view")
require_permission(request, "solution.view")
return build_support_quality_metrics(include_synthetic=include_synthetic)
@router.post("/support-evaluations", response=SupportEvaluationResultOut)
def create_support_evaluation(request, payload: SupportEvaluationRecordIn):
require_permission(request, "ticket.edit")
require_permission(request, "knowledge.view")
require_permission(request, "solution.view")
case = SupportEvaluationCase.objects.filter(
key=payload.case_key,
enabled=True,
).first()
if case is None:
raise HttpError(404, "支持分析评估案例不存在或已停用")
analysis = TicketSupportAnalysis.objects.select_related("run", "ticket").filter(
id=payload.analysis_id
).first()
if analysis is None:
raise HttpError(404, "支持分析记录不存在")
try:
result = record_support_evaluation_result(
case,
analysis,
reviewer=request.user,
)
except SupportEvaluationError as error:
raise HttpError(400, str(error)) from error
OperationAuditLog.objects.create(
user=request.user,
module="tickets",
action="support_quality_evaluated",
target_type="ticket_support_analysis",
target_id=str(analysis.id),
request_snapshot={"case_key": case.key, "source_kind": case.source_kind},
result="success",
)
result = type(result).objects.select_related("case", "analysis__ticket").get(
id=result.id
)
return serialize_support_evaluation_result(result)
@router.get("/{ticket_id}/support-analyses", response=list[TicketSupportAnalysisOut])
def list_ticket_support_analyses(request, ticket_id: int):
require_permission(request, "ticket.view")
require_permission(request, "knowledge.view")
require_permission(request, "solution.view")
queryset = TicketSupportAnalysis.objects.select_related(
"run",
"run__agent_version",
).filter(ticket_id=ticket_id)
return [serialize_support_analysis(item) for item in queryset[:20]]
@router.post("/{ticket_id}/support-analyses", response=TicketSupportAnalysisOut)
def create_ticket_support_analysis(request, ticket_id: int):
require_permission(request, "ticket.view")
require_permission(request, "knowledge.view")
require_permission(request, "solution.view")
require_permission(request, "agent.use")
ticket = Ticket.objects.filter(id=ticket_id).first()
if ticket is None:
raise HttpError(404, "Ticket not found")
try:
analysis = start_ticket_support_analysis(ticket, created_by=request.user)
except (SupportAnalysisError, AgentValidationError, SkillValidationError) as error:
raise HttpError(400, str(error)) from error
analysis = TicketSupportAnalysis.objects.select_related(
"run",
"run__agent_version",
).get(id=analysis.id)
return serialize_support_analysis(analysis)
@router.get(
"/{ticket_id}/knowledge-distillations",
response=list[TicketKnowledgeDistillationOut],
)
def list_ticket_knowledge_distillations(request, ticket_id: int):
require_permission(request, "ticket.view")
require_permission(request, "knowledge.view")
queryset = TicketKnowledgeDistillation.objects.select_related(
"run",
"run__agent_version",
).filter(ticket_id=ticket_id)
return [serialize_knowledge_distillation(item) for item in queryset[:20]]
@router.post(
"/{ticket_id}/knowledge-distillations",
response=TicketKnowledgeDistillationOut,
)
def create_ticket_knowledge_distillation(request, ticket_id: int):
require_permission(request, "ticket.view")
require_permission(request, "knowledge.manage")
require_permission(request, "agent.use")
ticket = Ticket.objects.filter(id=ticket_id).first()
if ticket is None:
raise HttpError(404, "Ticket not found")
try:
distillation = start_ticket_knowledge_distillation(
ticket,
created_by=request.user,
)
except (
KnowledgeDistillationError,
AgentValidationError,
SkillValidationError,
) as error:
raise HttpError(400, str(error)) from error
distillation = TicketKnowledgeDistillation.objects.select_related(
"run",
"run__agent_version",
).get(id=distillation.id)
return serialize_knowledge_distillation(distillation)
@router.get("/{ticket_id}/reply-tasks", response=list[CustomerReplyTaskOut])
def list_customer_reply_tasks(request, ticket_id: int):
require_permission(request, "ticket.view")
queryset = (
CustomerReplyTask.objects.filter(ticket_id=ticket_id)
.select_related("support_analysis")
.prefetch_related("revisions")
)
return [serialize_customer_reply_task(item) for item in queryset[:20]]
@router.patch(
"/{ticket_id}/reply-tasks/{reply_task_id}",
response=CustomerReplyTaskOut,
)
def edit_customer_reply_task(
request,
ticket_id: int,
reply_task_id: int,
payload: CustomerReplyUpdateIn,
):
require_permission(request, "ticket.edit")
task = CustomerReplyTask.objects.filter(
id=reply_task_id,
ticket_id=ticket_id,
).first()
if task is None:
raise HttpError(404, "客户回复任务不存在")
try:
task = update_customer_reply(
task,
payload.final_text,
expected_revision=payload.expected_revision,
actor=request.user,
)
except CustomerReplyError as error:
raise HttpError(409, str(error)) from error
task = CustomerReplyTask.objects.prefetch_related("revisions").get(id=task.id)
return serialize_customer_reply_task(task)
@router.post(
"/{ticket_id}/reply-tasks/{reply_task_id}/confirm-sent",
response=CustomerReplyTaskOut,
)
def confirm_customer_reply_task_sent(
request,
ticket_id: int,
reply_task_id: int,
payload: CustomerReplyConfirmIn,
):
require_permission(request, "ticket.reply")
task = CustomerReplyTask.objects.filter(
id=reply_task_id,
ticket_id=ticket_id,
).first()
if task is None:
raise HttpError(404, "客户回复任务不存在")
try:
task = confirm_customer_reply_sent(
task,
expected_revision=payload.expected_revision,
actor=request.user,
)
except CustomerReplyError as error:
raise HttpError(409, str(error)) from error
task = CustomerReplyTask.objects.prefetch_related("revisions").get(id=task.id)
return serialize_customer_reply_task(task)
@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",
"resolution",
"resolution__linked_solution",
"archived_by",
).get(id=ticket_id)
)
@router.get("/{ticket_id}/similar", response=list[TicketSimilaritySuggestionOut])
def list_similar_tickets(request, ticket_id: int):
require_permission(request, "ticket.view")
ticket = Ticket.objects.select_related("category").filter(id=ticket_id).first()
if ticket is None:
raise HttpError(404, "Ticket not found")
suggestions = list(
TicketSimilaritySuggestion.objects.filter(
source_ticket=ticket,
algorithm_version=ALGORITHM_VERSION,
).select_related("candidate_ticket__category")
)
if suggestions:
return [serialize_similarity_suggestion(item) for item in suggestions]
return [serialize_dynamic_similarity(item) for item in find_similar_tickets(ticket)]
@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):
changes = payload.dict(exclude_unset=True)
if "assignee_id" in changes:
require_permission(request, "ticket.assign")
if set(changes) - {"assignee_id"}:
require_permission(request, "ticket.edit")
if not changes:
require_permission(request, "ticket.edit")
ticket = Ticket.objects.get(id=ticket_id)
try:
updated, _ = update_ticket_fields(ticket, changes, request.user)
except TicketDataError as error:
raise HttpError(400, str(error)) from error
return serialize_ticket(
Ticket.objects.select_related(
"requester", "assignee", "category", "resolution", "archived_by"
).get(id=updated.id)
)
@router.post("/{ticket_id}/status", response=TicketOut)
def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
if payload.status == "closed":
require_permission(request, "ticket.close")
elif payload.status == "resolved":
require_permission(request, "ticket.resolve")
else:
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(),
replied_to_customer=payload.replied_to_customer,
)
except (TicketDataError, TicketStatusError) as error:
raise HttpError(400, str(error)) from error
return serialize_ticket(
Ticket.objects.select_related(
"requester", "assignee", "category", "resolution", "archived_by"
).get(id=updated.id)
)
@router.post("/{ticket_id}/archive", response=TicketOut)
def archive_ticket(request, ticket_id: int):
require_permission(request, "ticket.archive")
ticket = set_ticket_archived(Ticket.objects.get(id=ticket_id), True, request.user)
return serialize_ticket(ticket)
@router.post("/{ticket_id}/restore", response=TicketOut)
def restore_ticket(request, ticket_id: int):
require_permission(request, "ticket.archive")
ticket = set_ticket_archived(Ticket.objects.get(id=ticket_id), False, request.user)
return serialize_ticket(ticket)
@router.get("/{ticket_id}/collaborators", response=list[TicketCollaboratorOut])
def list_ticket_collaborators(request, ticket_id: int):
require_permission(request, "ticket.view")
queryset = TicketCollaborator.objects.select_related("user", "added_by").filter(
ticket_id=ticket_id
)
return [serialize_ticket_collaborator(item) for item in queryset]
@router.post("/{ticket_id}/collaborators", response=TicketCollaboratorOut)
def add_ticket_collaborator(request, ticket_id: int, payload: TicketCollaboratorIn):
require_permission(request, "ticket.assign")
if payload.role not in TicketCollaboratorRole.values:
raise HttpError(400, "Unknown collaborator role")
user = get_user_model().objects.filter(id=payload.user_id).first()
if user is None:
raise HttpError(400, "User not found")
ticket = Ticket.objects.get(id=ticket_id)
collaborator, created = TicketCollaborator.objects.get_or_create(
ticket=ticket,
user=user,
role=payload.role,
defaults={"added_by": request.user},
)
if created:
TicketEvent.objects.create(
ticket=ticket,
event_type="collaborator_added",
to_value={"user_id": user.id, "role": payload.role},
operator=request.user,
)
return serialize_ticket_collaborator(collaborator)
@router.delete("/{ticket_id}/collaborators/{collaborator_id}")
def remove_ticket_collaborator(request, ticket_id: int, collaborator_id: int):
require_permission(request, "ticket.assign")
collaborator = TicketCollaborator.objects.filter(
id=collaborator_id,
ticket_id=ticket_id,
).first()
if collaborator is None:
raise HttpError(404, "Collaborator not found")
event_value = {"user_id": collaborator.user_id, "role": collaborator.role}
collaborator.delete()
TicketEvent.objects.create(
ticket_id=ticket_id,
event_type="collaborator_removed",
from_value=event_value,
operator=request.user,
)
return {"success": True}
@router.get("/{ticket_id}/field-evidence", response=list[TicketFieldEvidenceOut])
def list_ticket_field_evidence(request, ticket_id: int):
require_permission(request, "ticket.view")
queryset = TicketFieldEvidence.objects.select_related("created_by").filter(ticket_id=ticket_id)
return [serialize_field_evidence(item) for item in queryset]
@router.get("/{ticket_id}/intake-trace", response=TicketIntakeTraceOut)
def get_ticket_intake_trace(request, ticket_id: int):
require_permission(request, "ticket.view")
ticket = Ticket.objects.filter(id=ticket_id).first()
if ticket is None:
raise HttpError(404, "Ticket not found")
try:
draft = ticket.source_draft
except TicketDraft.DoesNotExist:
return {"available": False}
from apps.tickets.draft_services import serialize_timeline_item
timeline_items = list(draft.timeline_items.all())
return {
"available": True,
"public_id": str(draft.public_id),
"status": draft.status,
"source_text": draft.source_text,
"problem_valid": draft.problem_valid,
"invalid_reason": draft.invalid_reason,
"missing_fields": draft.missing_fields,
"message_timeline": (
[serialize_timeline_item(item) for item in timeline_items]
if timeline_items
else draft.message_timeline
),
"agent_run_public_id": str(draft.latest_agent_run.public_id)
if draft.latest_agent_run_id
else None,
}
@router.get("/{ticket_id}/comments", response=list[TicketCommentOut])
def list_ticket_comments(request, ticket_id: int):
require_permission(request, "ticket.view")
comments = TicketComment.objects.select_related("created_by").filter(ticket_id=ticket_id)
return [serialize_ticket_comment(comment) for comment in comments]
@router.post("/{ticket_id}/comments", response=TicketCommentOut)
def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn):
require_permission(request, "ticket.edit")
content = payload.content.strip()
if not content:
raise HttpError(400, "Comment content is required")
if len(content) > 10000:
raise HttpError(400, "Comment content is too long")
allowed_comment_types = {"internal_note", "user_reply"}
allowed_visibilities = {"internal", "public"}
if payload.comment_type not in allowed_comment_types:
raise HttpError(400, "Unknown comment type")
if payload.visibility not in allowed_visibilities:
raise HttpError(400, "Unknown comment visibility")
expected_visibility = (
"public" if payload.comment_type == "user_reply" else "internal"
)
if payload.visibility != expected_visibility:
raise HttpError(400, "Comment type and visibility do not match")
ticket = Ticket.objects.get(id=ticket_id)
comment = TicketComment.objects.create(
ticket=ticket,
content=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 serialize_ticket_comment(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
@router.get("/{ticket_id}/attachments", response=list[TicketAttachmentOut])
def list_ticket_attachments(request, ticket_id: int):
require_permission(request, "ticket.view")
return TicketAttachment.objects.filter(ticket_id=ticket_id)