feat: consolidate ticket domain baseline
This commit is contained in:
@@ -10,26 +10,82 @@ 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.tickets.models import Ticket, TicketAttachment, TicketCategory, TicketComment, TicketEvent
|
||||
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"])
|
||||
@@ -63,6 +119,7 @@ def serialize_ticket_category(category):
|
||||
|
||||
|
||||
def serialize_ticket(ticket: Ticket) -> dict:
|
||||
resolution = getattr(ticket, "resolution", None)
|
||||
return {
|
||||
"id": ticket.id,
|
||||
"ticket_no": ticket.ticket_no,
|
||||
@@ -76,17 +133,36 @@ def serialize_ticket(ticket: Ticket) -> dict:
|
||||
"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,
|
||||
"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,
|
||||
}
|
||||
@@ -100,10 +176,207 @@ def serialize_ticket_event(event: TicketEvent) -> dict:
|
||||
"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")
|
||||
@@ -128,9 +401,19 @@ def list_tickets(
|
||||
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").all()
|
||||
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:
|
||||
@@ -146,6 +429,11 @@ def list_tickets(
|
||||
| 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)
|
||||
@@ -192,7 +480,7 @@ def export_tickets(request):
|
||||
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"):
|
||||
for ticket in Ticket.objects.filter(archived_at__isnull=True).order_by("ticket_no"):
|
||||
writer.writerow(
|
||||
[
|
||||
ticket.ticket_no,
|
||||
@@ -213,14 +501,267 @@ def export_tickets(request):
|
||||
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").get(id=ticket_id)
|
||||
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")
|
||||
@@ -230,52 +771,33 @@ def list_ticket_events(request, ticket_id: int):
|
||||
|
||||
@router.patch("/{ticket_id}", response=TicketOut)
|
||||
def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
||||
require_permission(request, "ticket.edit")
|
||||
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)
|
||||
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,
|
||||
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)
|
||||
)
|
||||
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")
|
||||
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())
|
||||
@@ -294,21 +816,154 @@ def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
|
||||
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:
|
||||
except (TicketDataError, TicketStatusError) as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
return serialize_ticket(updated)
|
||||
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=payload.content,
|
||||
content=content,
|
||||
comment_type=payload.comment_type,
|
||||
visibility=payload.visibility,
|
||||
created_by=request.user if request.user.is_authenticated else None,
|
||||
@@ -319,7 +974,7 @@ def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn):
|
||||
to_value={"comment_id": comment.id, "comment_type": comment.comment_type},
|
||||
operator=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
return comment
|
||||
return serialize_ticket_comment(comment)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/attachments", response=TicketAttachmentOut)
|
||||
@@ -334,3 +989,9 @@ def add_ticket_attachment(request, ticket_id: int, payload: TicketAttachmentIn):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user