Files
ai-ticket-platform/backend/apps/tickets/draft_api.py
2026-07-16 19:37:32 +08:00

245 lines
9.8 KiB
Python

from datetime import timedelta
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from ninja import File, Router, UploadedFile
from ninja.errors import HttpError
from apps.accounts.api import require_permission
from apps.tickets.api import serialize_ticket
from apps.tickets.draft_schemas import (
TicketDraftActionIn,
TicketDraftAnswerIn,
TicketDraftAvailabilityOut,
TicketDraftCreateIn,
TicketDraftOut,
TicketDraftPatchIn,
)
from apps.tickets.draft_services import (
ACTIVE_DRAFT_STATUSES,
TicketDraftError,
add_answer,
assert_revision,
audit_draft,
confirm_draft,
create_agent_run_for_draft,
draft_availability,
reject_draft,
serialize_draft,
)
from apps.tickets.models import TicketDraft, TicketDraftImage
from apps.tickets.storage import DraftImageError, delete_object, prepare_image, put_object
from apps.tickets.tasks import execute_ticket_draft_analysis
router = Router(tags=["ticket-drafts"])
def get_owned_draft(request, public_id: str, *, allow_admin_view: bool = False) -> TicketDraft:
require_permission(request, "ticket.ai_draft")
queryset = TicketDraft.objects.select_related(
"latest_agent_run", "confirmed_ticket", "created_by"
).prefetch_related("images", "messages")
draft = queryset.filter(public_id=public_id).first()
if draft is None:
raise HttpError(404, "草稿不存在")
if draft.created_by_id != request.user.id:
can_view = allow_admin_view and (
request.user.is_superuser or request.user.has_perm("system.agent.run.view")
)
if not can_view:
raise HttpError(403, "不能访问其他人的草稿")
return draft
def raise_draft_error(error: TicketDraftError) -> None:
raise HttpError(error.status_code, str(error)) from error
@router.get("/availability", response=TicketDraftAvailabilityOut)
def get_availability(request):
require_permission(request, "ticket.ai_draft")
return draft_availability()
@router.get("", response=list[TicketDraftOut])
def list_ticket_drafts(request):
require_permission(request, "ticket.ai_draft")
drafts = (
TicketDraft.objects.filter(created_by=request.user, status__in=ACTIVE_DRAFT_STATUSES)
.select_related("latest_agent_run", "confirmed_ticket")
.prefetch_related("images", "messages")[:20]
)
return [serialize_draft(draft, ticket_serializer=serialize_ticket) for draft in drafts]
@router.post("", response=TicketDraftOut)
def create_ticket_draft(request, payload: TicketDraftCreateIn):
require_permission(request, "ticket.ai_draft")
availability = draft_availability()
if not availability["available"]:
raise HttpError(422, availability["reason"])
source_text = payload.source_text.strip()
draft = TicketDraft.objects.create(created_by=request.user, source_text=source_text)
audit_draft(draft, "draft_created")
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.get("/{public_id}", response=TicketDraftOut)
def get_ticket_draft(request, public_id: str):
draft = get_owned_draft(request, public_id, allow_admin_view=True)
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.patch("/{public_id}", response=TicketDraftOut)
def update_ticket_draft(request, public_id: str, payload: TicketDraftPatchIn):
with transaction.atomic():
draft = (
TicketDraft.objects.select_for_update()
.filter(public_id=public_id, created_by=request.user)
.first()
)
if draft is None:
require_permission(request, "ticket.ai_draft")
raise HttpError(404, "草稿不存在")
require_permission(request, "ticket.ai_draft")
try:
assert_revision(draft, payload.expected_revision)
except TicketDraftError as error:
raise_draft_error(error)
if draft.status not in {"editing", "ready", "failed"}:
raise HttpError(400, "当前状态不能编辑")
draft.current_fields = payload.fields
draft.revision += 1
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
draft.save()
audit_draft(draft, "draft_updated")
draft = get_owned_draft(request, public_id)
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/images", response=TicketDraftOut)
def upload_ticket_draft_image(request, public_id: str, file: File[UploadedFile]):
draft = get_owned_draft(request, public_id)
if draft.status not in {"editing", "ready", "failed"}:
raise HttpError(400, "当前状态不能上传图片")
if draft.images.count() >= 4:
raise HttpError(413, "每个草稿最多上传 4 张图片")
raw = file.read()
try:
prepared = prepare_image(raw, file.name or "image")
except DraftImageError as error:
raise HttpError(error.status_code, str(error)) from error
current_total = sum(image.file_size for image in draft.images.all())
if current_total + len(prepared.data) > 20 * 1024 * 1024:
raise HttpError(413, "草稿图片总大小不能超过 20 MB")
image = TicketDraftImage(
draft=draft,
file_name=prepared.file_name,
mime_type=prepared.mime_type,
file_size=len(prepared.data),
sha256=prepared.sha256,
storage_path="",
)
image.storage_path = (
f"ticket-drafts/{draft.created_by_id}/{draft.public_id}/"
f"{image.public_id}.{prepared.extension}"
)
try:
put_object(image.storage_path, prepared.data, prepared.mime_type)
except Exception as error:
raise HttpError(503, "图片存储暂时不可用") from error
image.save()
draft.revision += 1
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
draft.save(update_fields=["revision", "expires_at", "updated_at"])
audit_draft(draft, "draft_image_uploaded")
return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket)
@router.delete("/{public_id}/images/{image_id}", response=TicketDraftOut)
def delete_ticket_draft_image(request, public_id: str, image_id: str):
draft = get_owned_draft(request, public_id)
image = draft.images.filter(public_id=image_id, status="temporary").first()
if image is None:
raise HttpError(404, "图片不存在")
try:
delete_object(image.storage_path)
except Exception as error:
raise HttpError(503, "图片存储暂时不可用") from error
image.delete()
draft.revision += 1
draft.save(update_fields=["revision", "updated_at"])
audit_draft(draft, "draft_image_deleted")
return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket)
def start_analysis(draft: TicketDraft, expected_revision: int) -> TicketDraft:
try:
assert_revision(draft, expected_revision)
if draft.status not in {"editing", "ready", "failed"}:
raise TicketDraftError(400, "当前状态不能启动分析")
if not draft.source_text.strip() and not draft.current_fields:
raise TicketDraftError(400, "请先输入问题描述")
if draft.images.exists() and not draft_availability()["vision_available"]:
raise TicketDraftError(422, draft_availability()["vision_reason"])
run = create_agent_run_for_draft(draft)
except TicketDraftError as error:
raise_draft_error(error)
result = execute_ticket_draft_analysis.delay(draft.id, run.id)
run.refresh_from_db()
if not run.task_id:
run.task_id = result.id or ""
run.save(update_fields=["task_id"])
draft.refresh_from_db()
return draft
@router.post("/{public_id}/analyze", response=TicketDraftOut)
def analyze_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
draft = get_owned_draft(request, public_id)
draft = start_analysis(draft, payload.expected_revision)
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/answers", response=TicketDraftOut)
def answer_ticket_draft(request, public_id: str, payload: TicketDraftAnswerIn):
draft = get_owned_draft(request, public_id)
try:
assert_revision(draft, payload.expected_revision)
if not payload.answer.strip():
raise TicketDraftError(400, "回答不能为空")
add_answer(draft, payload.answer, payload.question)
except TicketDraftError as error:
raise_draft_error(error)
draft.refresh_from_db()
draft = start_analysis(draft, draft.revision)
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/confirm", response=TicketDraftOut)
def confirm_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
require_permission(request, "ticket.ai_draft")
require_permission(request, "ticket.create")
try:
draft = confirm_draft(public_id, payload.expected_revision, request.user)
except TicketDraft.DoesNotExist as error:
raise HttpError(404, "草稿不存在") from error
except TicketDraftError as error:
raise_draft_error(error)
draft = get_owned_draft(request, public_id)
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/reject", response=TicketDraftOut)
def reject_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
require_permission(request, "ticket.ai_draft")
try:
draft = reject_draft(public_id, payload.expected_revision, request.user)
except TicketDraft.DoesNotExist as error:
raise HttpError(404, "草稿不存在") from error
except TicketDraftError as error:
raise_draft_error(error)
draft = get_owned_draft(request, public_id)
return serialize_draft(draft, ticket_serializer=serialize_ticket)