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.automation import ( TicketAutomationError, apply_automation_action, approve_and_send, serialize_automation_run, ) from apps.tickets.evaluation import build_intake_quality_metrics, record_evaluation_result from apps.tickets.models import ( CustomerReplyTask, IntakeEvaluationCase, SupportEvaluationCase, Ticket, TicketAttachment, TicketAutomationRun, TicketAutomationRunStatus, 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, TicketAutomationActionIn, TicketAutomationApproveIn, TicketAutomationOut, 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, "follow_up_of_id": ticket.follow_up_of_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, "result_mode": item.result_mode, "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}/automation", response=TicketAutomationOut | None) def get_ticket_automation(request, ticket_id: int): require_permission(request, "ticket.view") run = ( TicketAutomationRun.objects.select_related("ticket") .filter(ticket_id=ticket_id) .first() ) return serialize_automation_run(run) if run is not None else None @router.post("/{ticket_id}/approve-and-send", response=TicketAutomationOut) def approve_ticket_automation_and_send( request, ticket_id: int, payload: TicketAutomationApproveIn, ): require_permission(request, "ticket.reply") require_permission(request, "ticket.resolve") if payload.channel_endpoint_id: require_permission(request, "channel.send") run = ( TicketAutomationRun.objects.select_related("ticket", "reply_task") .filter(ticket_id=ticket_id) .first() ) if run is None: raise HttpError(404, "工单自动化运行不存在") if run.status in { TicketAutomationRunStatus.WAITING_CUSTOMER, TicketAutomationRunStatus.COMPLETED, } and run.approved_at: return serialize_automation_run(run) if run.ticket.updated_at != payload.expected_ticket_updated_at: raise HttpError(409, "工单已被更新,请刷新后重新确认") try: run = approve_and_send( run, actor=request.user, expected_reply_revision=payload.expected_reply_revision, final_text=payload.final_text, resolution_text=payload.resolution_text, root_cause=payload.root_cause, solution_id=payload.solution_id, channel_endpoint_id=payload.channel_endpoint_id, external_conversation_id=payload.external_conversation_id, manual_delivery_confirmed=payload.manual_delivery_confirmed, ) except (TicketAutomationError, CustomerReplyError) as error: raise HttpError(409, str(error)) from error return serialize_automation_run( TicketAutomationRun.objects.select_related("ticket").get(id=run.id) ) @router.post("/{ticket_id}/automation/actions", response=TicketAutomationOut) def act_on_ticket_automation( request, ticket_id: int, payload: TicketAutomationActionIn, ): require_permission(request, "ticket.edit") run = ( TicketAutomationRun.objects.select_related("ticket") .filter(ticket_id=ticket_id) .first() ) if run is None: raise HttpError(404, "工单自动化运行不存在") if payload.action == "retry" and run.current_step == "delivery_failed": require_permission(request, "ticket.reply") require_permission(request, "channel.send") try: run = apply_automation_action(run, payload.action, actor=request.user) except TicketAutomationError as error: raise HttpError(409, str(error)) from error return serialize_automation_run( TicketAutomationRun.objects.select_related("ticket").get(id=run.id) ) @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)