366 lines
13 KiB
Python
366 lines
13 KiB
Python
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
|