from django.contrib.auth.decorators import login_required from django.db import transaction from django.db.models import Count, Q import json import logging from pathlib import Path from django.http import FileResponse, Http404, JsonResponse from django.views.decorators.http import require_http_methods from review_agent.models import ( ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileAttachment, Message, RegulatoryInfoPackageBatch, RegulatoryReviewBatch, ) from review_agent.models import FileSummaryBatch, WorkflowEvent from review_agent.notifications.presenter import serialize_notification_records from .events import serialize_event from .paths import resolve_storage_path from .storage import save_uploaded_attachment, serialize_attachment logger = logging.getLogger("review_agent.file_summary.views") def _conversation_for_user(user, conversation_id: int) -> Conversation: conversation = Conversation.objects.filter(pk=conversation_id, user=user).first() if not conversation: raise Http404("对话不存在。") return conversation @require_http_methods(["POST", "GET"]) @login_required def attachments(request, conversation_id: int): conversation = _conversation_for_user(request.user, conversation_id) if request.method == "POST": files = request.FILES.getlist("files") if not files: return JsonResponse({"error": "请选择至少一个文件。"}, status=400) logger.info( "Attachment upload request received", extra={ "conversation_id": conversation.pk, "user_id": request.user.pk, "file_count": len(files), "filenames": [uploaded_file.name for uploaded_file in files], }, ) saved = [ save_uploaded_attachment( conversation=conversation, user=request.user, uploaded_file=uploaded_file, ) for uploaded_file in files ] logger.info( "Attachment upload request finished", extra={ "conversation_id": conversation.pk, "attachment_ids": [attachment.pk for attachment in saved], }, ) return JsonResponse({"attachments": [serialize_attachment(item) for item in saved]}) queryset = FileAttachment.objects.filter(conversation=conversation).order_by( "original_name", "-version_no", ) logger.info( "Attachment list requested", extra={"conversation_id": conversation.pk, "attachment_count": queryset.count()}, ) return JsonResponse({"attachments": [serialize_attachment(item) for item in queryset]}) @require_http_methods(["DELETE", "PATCH"]) @login_required def attachment_detail(request, conversation_id: int, attachment_id: int): conversation = _conversation_for_user(request.user, conversation_id) attachment = FileAttachment.objects.filter( pk=attachment_id, conversation=conversation, user=request.user, ).first() if not attachment: raise Http404("附件不存在。") if request.method == "PATCH": try: payload = json.loads(request.body.decode("utf-8") or "{}") except json.JSONDecodeError: return JsonResponse({"error": "JSON 格式错误。"}, status=400) update_fields = [] original_name = (payload.get("original_name") or "").strip() if original_name: attachment.original_name = Path(original_name).name update_fields.append("original_name") if "is_active" in payload: attachment.is_active = bool(payload["is_active"]) update_fields.append("is_active") if update_fields: attachment.save(update_fields=update_fields) logger.info( "Attachment updated", extra={ "conversation_id": conversation.pk, "attachment_id": attachment.pk, "update_fields": update_fields, }, ) return JsonResponse({"ok": True, "attachment": serialize_attachment(attachment)}) attachment.upload_status = FileAttachment.UploadStatus.DELETED attachment.is_active = False attachment.save(update_fields=["upload_status", "is_active"]) logger.info( "Attachment deleted", extra={"conversation_id": conversation.pk, "attachment_id": attachment.pk}, ) return JsonResponse({"ok": True, "attachment": serialize_attachment(attachment)}) @require_http_methods(["GET"]) @login_required def conversation_list(request): conversations = ( Conversation.objects.filter(user=request.user) .annotate( attachment_count=Count( "file_attachments", filter=~Q(file_attachments__upload_status=FileAttachment.UploadStatus.DELETED), ) ) .order_by("-updated_at", "-id") ) return JsonResponse( { "conversations": [ { "id": conversation.pk, "title": conversation.title or "新对话", "updated_at": conversation.updated_at.isoformat(), "attachment_count": conversation.attachment_count, } for conversation in conversations ] } ) @require_http_methods(["DELETE"]) @login_required def conversation_detail(request, conversation_id: int): conversation = _conversation_for_user(request.user, conversation_id) with transaction.atomic(): ApplicationFormFillBatch.objects.filter(conversation=conversation).delete() RegulatoryReviewBatch.objects.filter(conversation=conversation).delete() conversation.delete() return JsonResponse({"ok": True, "conversation_id": conversation_id}) @require_http_methods(["GET"]) @login_required def attachment_download(request, conversation_id: int, attachment_id: int): conversation = _conversation_for_user(request.user, conversation_id) attachment = FileAttachment.objects.filter( pk=attachment_id, conversation=conversation, user=request.user, ).exclude(upload_status=FileAttachment.UploadStatus.DELETED).first() if not attachment: raise Http404("附件不存在。") path = resolve_storage_path(attachment.storage_path) if not path.exists(): logger.warning( "Attachment download missing file", extra={"attachment_id": attachment.pk, "storage_path": attachment.storage_path}, ) return JsonResponse({"error": "文件不存在。"}, status=404) logger.info( "Attachment download started", extra={"conversation_id": conversation.pk, "attachment_id": attachment.pk}, ) return FileResponse( path.open("rb"), as_attachment=True, filename=attachment.original_name, content_type=attachment.content_type or "application/octet-stream", ) def _serialize_message(message: Message) -> dict[str, object]: return { "id": message.pk, "role": message.role, "content": message.content, "created_at": message.created_at.isoformat(), } @require_http_methods(["GET"]) @login_required def conversation_messages(request, conversation_id: int): conversation = _conversation_for_user(request.user, conversation_id) after = request.GET.get("after") or "0" try: after_id = int(after) except ValueError: after_id = 0 messages = list(conversation.messages.filter(pk__gt=after_id).order_by("id")) latest_message_id = ( conversation.messages.order_by("-id").values_list("id", flat=True).first() or 0 ) logger.info( "Conversation incremental messages requested", extra={ "conversation_id": conversation.pk, "after_id": after_id, "message_count": len(messages), "latest_message_id": latest_message_id, }, ) return JsonResponse( { "conversation_id": conversation.pk, "latest_message_id": latest_message_id, "messages": [_serialize_message(message) for message in messages], } ) @require_http_methods(["GET"]) @login_required def batch_status(request, batch_id: int): batch = FileSummaryBatch.objects.filter(pk=batch_id, user=request.user).first() if not batch: raise Http404("批次不存在。") notifications = serialize_notification_records("file_summary", batch.pk) return JsonResponse( { "batch": { "id": batch.pk, "workflow_type": "file_summary", "batch_no": batch.batch_no, "status": batch.status, "product_name": batch.product_name, "total_files": batch.total_files, "success_files": batch.success_files, "failed_files": batch.failed_files, "total_pages": batch.total_pages, "error_message": batch.error_message, }, "nodes": [ { "node_code": node.node_code, "node_name": node.node_name, "status": node.status, "progress": node.progress, "message": node.message, } for node in batch.node_runs.order_by("id") ], "notifications": notifications, "latest_notification": notifications[0] if notifications else None, } ) @require_http_methods(["GET"]) @login_required def batch_events(request, batch_id: int): batch = FileSummaryBatch.objects.filter(pk=batch_id, user=request.user).first() if not batch: raise Http404("批次不存在。") after = request.GET.get("after") or "0" try: after_id = int(after) except ValueError: after_id = 0 events = WorkflowEvent.objects.filter(batch=batch, pk__gt=after_id).order_by("id") return JsonResponse({"events": [serialize_event(event) for event in events]}) @require_http_methods(["GET"]) @login_required def export_download(request, export_id: int): exported = _export_for_user(request.user, export_id) if not exported: raise Http404("导出文件不存在。") path = Path(exported.storage_path) if not path.exists(): logger.warning( "Export download missing file", extra={"export_id": exported.pk, "storage_path": exported.storage_path}, ) return JsonResponse({"error": "文件不存在。"}, status=404) suffix = Path(exported.file_name).suffix.lower() content_types = { ExportedSummaryFile.ExportType.MARKDOWN: "text/markdown; charset=utf-8", ExportedSummaryFile.ExportType.EXCEL: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ExportedSummaryFile.ExportType.JSON: "application/json; charset=utf-8", ExportedSummaryFile.ExportType.WORD: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ExportedSummaryFile.ExportType.PDF: "application/pdf", ExportedSummaryFile.ExportType.ZIP: "application/zip", } content_type = content_types.get(exported.export_type, "application/octet-stream") if exported.export_type == ExportedSummaryFile.ExportType.WORD and suffix == ".doc": content_type = "application/msword" elif exported.export_type == ExportedSummaryFile.ExportType.WORD and suffix == ".docx": content_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" logger.info( "Export download started", extra={ "export_id": exported.pk, "batch_id": exported.batch_id, "file_name": exported.file_name, "content_type": content_type, }, ) return FileResponse( path.open("rb"), as_attachment=True, filename=exported.file_name, content_type=content_type, ) def _export_for_user(user, export_id: int) -> ExportedSummaryFile | None: exported = ExportedSummaryFile.objects.filter(pk=export_id).first() if not exported: return None if exported.workflow_type == "application_form_fill": if not exported.workflow_batch_id: return None allowed = ApplicationFormFillBatch.objects.filter( pk=exported.workflow_batch_id, conversation__user=user, is_deleted=False, ).exists() return exported if allowed else None if exported.workflow_type == "regulatory_info_package": if not exported.workflow_batch_id: return None allowed = RegulatoryInfoPackageBatch.objects.filter( pk=exported.workflow_batch_id, conversation__user=user, is_deleted=False, ).exists() return exported if allowed else None if exported.batch_id is None: return None if exported.batch.user_id != user.pk: return None return exported