diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index fa27e52..a87fee9 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -1,4 +1,6 @@ from django.contrib.auth.decorators import login_required +from django.db.models import Count, Q +import json import logging from pathlib import Path @@ -8,6 +10,7 @@ from django.views.decorators.http import require_http_methods from review_agent.models import Conversation, ExportedSummaryFile, FileAttachment, Message from review_agent.models import FileSummaryBatch, WorkflowEvent from .events import serialize_event +from .paths import resolve_storage_path from .storage import save_uploaded_attachment, serialize_attachment @@ -68,7 +71,7 @@ def attachments(request, conversation_id: int): return JsonResponse({"attachments": [serialize_attachment(item) for item in queryset]}) -@require_http_methods(["DELETE"]) +@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) @@ -80,6 +83,32 @@ def attachment_detail(request, conversation_id: int, attachment_id: int): 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"]) @@ -90,6 +119,65 @@ def attachment_detail(request, conversation_id: int, attachment_id: int): 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(["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, diff --git a/review_agent/urls.py b/review_agent/urls.py index 418bb88..6e480dd 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -1,16 +1,23 @@ from django.urls import path from .file_summary.views import ( + attachment_download, attachment_detail, attachments, batch_events, batch_status, + conversation_list, conversation_messages, export_download, ) urlpatterns = [ + path( + "api/review-agent/conversations/", + conversation_list, + name="review_agent_conversation_list", + ), path( "api/review-agent/conversations//attachments/", attachments, @@ -26,6 +33,11 @@ urlpatterns = [ attachment_detail, name="file_summary_attachment_detail", ), + path( + "api/review-agent/conversations//attachments//download/", + attachment_download, + name="file_summary_attachment_download", + ), path( "api/review-agent/conversations//messages/", conversation_messages, diff --git a/tests/test_file_summary_views.py b/tests/test_file_summary_views.py index d88e872..6aeaa7f 100644 --- a/tests/test_file_summary_views.py +++ b/tests/test_file_summary_views.py @@ -1,5 +1,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse +import json import pytest from review_agent.models import ( @@ -171,3 +172,89 @@ def test_batch_status_exposes_batch_and_node_errors(client, django_user_model): payload = response.json() assert payload["batch"]["error_message"] == "压缩包解压失败" assert payload["nodes"][0]["message"] == "未解出任何可扫描文件" + + +def test_conversation_list_api_returns_owned_conversations_with_attachment_counts(client, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + owned = Conversation.objects.create(user=owner, title="有附件会话") + Conversation.objects.create(user=other, title="其他用户会话") + FileAttachment.objects.create( + conversation=owned, + user=owner, + original_name="a.docx", + storage_path="x/a.docx", + file_size=1, + ) + FileAttachment.objects.create( + conversation=owned, + user=owner, + original_name="deleted.docx", + storage_path="x/deleted.docx", + file_size=1, + upload_status=FileAttachment.UploadStatus.DELETED, + is_active=False, + ) + client.force_login(owner) + + response = client.get(reverse("review_agent_conversation_list")) + + assert response.status_code == 200 + payload = response.json() + assert [item["title"] for item in payload["conversations"]] == ["有附件会话"] + assert payload["conversations"][0]["attachment_count"] == 1 + + +def test_patch_attachment_updates_name_and_active_state(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + attachment = FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="old.docx", + storage_path="x/old.docx", + file_size=1, + is_active=True, + ) + client.force_login(user) + + response = client.patch( + reverse("file_summary_attachment_detail", args=[conversation.pk, attachment.pk]), + data=json.dumps({"original_name": "new.docx", "is_active": False}), + content_type="application/json", + ) + + attachment.refresh_from_db() + assert response.status_code == 200 + assert attachment.original_name == "new.docx" + assert attachment.is_active is False + assert response.json()["attachment"]["original_name"] == "new.docx" + + +def test_attachment_download_requires_owner_and_returns_file(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + attachment_path = tmp_path / "uploads" / "a.docx" + attachment_path.parent.mkdir(parents=True) + attachment_path.write_bytes(b"attachment-content") + attachment = FileAttachment.objects.create( + conversation=conversation, + user=owner, + original_name="a.docx", + storage_path=str(attachment_path), + file_size=attachment_path.stat().st_size, + content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + client.force_login(other) + denied = client.get(reverse("file_summary_attachment_download", args=[conversation.pk, attachment.pk])) + assert denied.status_code == 404 + + client.force_login(owner) + allowed = client.get(reverse("file_summary_attachment_download", args=[conversation.pk, attachment.pk])) + assert allowed.status_code == 200 + assert "attachment" in allowed["Content-Disposition"] + assert "a.docx" in allowed["Content-Disposition"] + assert b"".join(allowed.streaming_content) == b"attachment-content"