feat(attachments): 补充附件管理接口
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
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 Conversation, ExportedSummaryFile, FileAttachment, Message
|
||||||
from review_agent.models import FileSummaryBatch, WorkflowEvent
|
from review_agent.models import FileSummaryBatch, WorkflowEvent
|
||||||
from .events import serialize_event
|
from .events import serialize_event
|
||||||
|
from .paths import resolve_storage_path
|
||||||
|
|
||||||
from .storage import save_uploaded_attachment, serialize_attachment
|
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]})
|
return JsonResponse({"attachments": [serialize_attachment(item) for item in queryset]})
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["DELETE"])
|
@require_http_methods(["DELETE", "PATCH"])
|
||||||
@login_required
|
@login_required
|
||||||
def attachment_detail(request, conversation_id: int, attachment_id: int):
|
def attachment_detail(request, conversation_id: int, attachment_id: int):
|
||||||
conversation = _conversation_for_user(request.user, conversation_id)
|
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:
|
if not attachment:
|
||||||
raise Http404("附件不存在。")
|
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.upload_status = FileAttachment.UploadStatus.DELETED
|
||||||
attachment.is_active = False
|
attachment.is_active = False
|
||||||
attachment.save(update_fields=["upload_status", "is_active"])
|
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)})
|
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]:
|
def _serialize_message(message: Message) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"id": message.pk,
|
"id": message.pk,
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .file_summary.views import (
|
from .file_summary.views import (
|
||||||
|
attachment_download,
|
||||||
attachment_detail,
|
attachment_detail,
|
||||||
attachments,
|
attachments,
|
||||||
batch_events,
|
batch_events,
|
||||||
batch_status,
|
batch_status,
|
||||||
|
conversation_list,
|
||||||
conversation_messages,
|
conversation_messages,
|
||||||
export_download,
|
export_download,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"api/review-agent/conversations/",
|
||||||
|
conversation_list,
|
||||||
|
name="review_agent_conversation_list",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"api/review-agent/conversations/<int:conversation_id>/attachments/",
|
"api/review-agent/conversations/<int:conversation_id>/attachments/",
|
||||||
attachments,
|
attachments,
|
||||||
@@ -26,6 +33,11 @@ urlpatterns = [
|
|||||||
attachment_detail,
|
attachment_detail,
|
||||||
name="file_summary_attachment_detail",
|
name="file_summary_attachment_detail",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"api/review-agent/conversations/<int:conversation_id>/attachments/<int:attachment_id>/download/",
|
||||||
|
attachment_download,
|
||||||
|
name="file_summary_attachment_download",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"api/review-agent/conversations/<int:conversation_id>/messages/",
|
"api/review-agent/conversations/<int:conversation_id>/messages/",
|
||||||
conversation_messages,
|
conversation_messages,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from review_agent.models import (
|
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()
|
payload = response.json()
|
||||||
assert payload["batch"]["error_message"] == "压缩包解压失败"
|
assert payload["batch"]["error_message"] == "压缩包解压失败"
|
||||||
assert payload["nodes"][0]["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"
|
||||||
|
|||||||
Reference in New Issue
Block a user