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)