diff --git a/config/urls.py b/config/urls.py index ec39f6a..cd123c8 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,11 +1,12 @@ from django.contrib import admin from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView -from django.urls import path +from django.urls import include, path from review_agent.views import stream_chat, workspace urlpatterns = [ path("", workspace, name="home"), + path("", include("review_agent.urls")), path("chat/stream/", stream_chat, name="chat_stream"), path( "login/", diff --git a/review_agent/file_summary/__init__.py b/review_agent/file_summary/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/review_agent/file_summary/__init__.py @@ -0,0 +1 @@ + diff --git a/review_agent/file_summary/constants.py b/review_agent/file_summary/constants.py new file mode 100644 index 0000000..3421ec9 --- /dev/null +++ b/review_agent/file_summary/constants.py @@ -0,0 +1,4 @@ +from pathlib import Path + + +ATTACHMENT_ROOT = Path("file_summary") / "users" diff --git a/review_agent/file_summary/storage.py b/review_agent/file_summary/storage.py new file mode 100644 index 0000000..7c2a0c7 --- /dev/null +++ b/review_agent/file_summary/storage.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +from django.conf import settings +from django.db import transaction +from django.utils.text import get_valid_filename + +from review_agent.models import Conversation, FileAttachment + +from .constants import ATTACHMENT_ROOT + + +def _safe_original_name(name: str) -> str: + clean = get_valid_filename(Path(name).name) + return clean or f"upload-{uuid4().hex}" + + +def _relative_attachment_path(conversation: Conversation, filename: str, version_no: int) -> Path: + suffix = Path(filename).suffix + stem = Path(filename).stem + stored_name = f"{stem}_v{version_no}_{uuid4().hex[:8]}{suffix}" + return ( + ATTACHMENT_ROOT + / str(conversation.user_id) + / str(conversation.pk) + / "attachments" + / stored_name + ) + + +def _ensure_inside_media_root(path: Path) -> None: + media_root = Path(settings.MEDIA_ROOT).resolve() + resolved = path.resolve() + if media_root != resolved and media_root not in resolved.parents: + raise ValueError("上传路径必须位于 MEDIA_ROOT 内。") + + +@transaction.atomic +def save_uploaded_attachment(*, conversation: Conversation, user, uploaded_file) -> FileAttachment: + """Stores an uploaded file and creates a versioned attachment record.""" + + original_name = _safe_original_name(uploaded_file.name) + latest = ( + FileAttachment.objects.filter(conversation=conversation, original_name=original_name) + .order_by("-version_no") + .first() + ) + version_no = (latest.version_no if latest else 0) + 1 + relative_path = _relative_attachment_path(conversation, original_name, version_no) + absolute_path = Path(settings.MEDIA_ROOT) / relative_path + _ensure_inside_media_root(absolute_path) + absolute_path.parent.mkdir(parents=True, exist_ok=True) + + with absolute_path.open("wb") as target: + for chunk in uploaded_file.chunks(): + target.write(chunk) + + FileAttachment.objects.filter( + conversation=conversation, + original_name=original_name, + is_active=True, + ).update(is_active=False) + + return FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name=original_name, + version_no=version_no, + is_active=True, + storage_path=relative_path.as_posix(), + file_size=uploaded_file.size, + content_type=getattr(uploaded_file, "content_type", "") or "", + ) + + +def serialize_attachment(attachment: FileAttachment) -> dict[str, object]: + return { + "id": attachment.pk, + "original_name": attachment.original_name, + "version_no": attachment.version_no, + "is_active": attachment.is_active, + "file_size": attachment.file_size, + "content_type": attachment.content_type, + "upload_status": attachment.upload_status, + "created_at": attachment.created_at.isoformat(), + } diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py new file mode 100644 index 0000000..1b48924 --- /dev/null +++ b/review_agent/file_summary/views.py @@ -0,0 +1,58 @@ +from django.contrib.auth.decorators import login_required +from django.http import Http404, JsonResponse +from django.views.decorators.http import require_http_methods + +from review_agent.models import Conversation, FileAttachment + +from .storage import save_uploaded_attachment, serialize_attachment + + +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) + saved = [ + save_uploaded_attachment( + conversation=conversation, + user=request.user, + uploaded_file=uploaded_file, + ) + for uploaded_file in files + ] + return JsonResponse({"attachments": [serialize_attachment(item) for item in saved]}) + + queryset = FileAttachment.objects.filter(conversation=conversation).order_by( + "original_name", + "-version_no", + ) + return JsonResponse({"attachments": [serialize_attachment(item) for item in queryset]}) + + +@require_http_methods(["DELETE"]) +@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("附件不存在。") + + attachment.upload_status = FileAttachment.UploadStatus.DELETED + attachment.is_active = False + attachment.save(update_fields=["upload_status", "is_active"]) + return JsonResponse({"ok": True, "attachment": serialize_attachment(attachment)}) diff --git a/review_agent/urls.py b/review_agent/urls.py new file mode 100644 index 0000000..272291d --- /dev/null +++ b/review_agent/urls.py @@ -0,0 +1,22 @@ +from django.urls import path + +from .file_summary.views import attachment_detail, attachments + + +urlpatterns = [ + path( + "api/review-agent/conversations//attachments/", + attachments, + name="file_summary_attachment_upload", + ), + path( + "api/review-agent/conversations//attachments/", + attachments, + name="file_summary_attachment_list", + ), + path( + "api/review-agent/conversations//attachments//", + attachment_detail, + name="file_summary_attachment_detail", + ), +] diff --git a/tests/test_file_summary_storage.py b/tests/test_file_summary_storage.py new file mode 100644 index 0000000..38220b6 --- /dev/null +++ b/tests/test_file_summary_storage.py @@ -0,0 +1,48 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +import pytest + +from review_agent.file_summary.storage import save_uploaded_attachment +from review_agent.models import Conversation, FileAttachment + + +pytestmark = pytest.mark.django_db + + +def test_save_uploaded_attachment_versions_same_name(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + first = save_uploaded_attachment( + conversation=conversation, + user=user, + uploaded_file=SimpleUploadedFile("资料.docx", b"first"), + ) + second = save_uploaded_attachment( + conversation=conversation, + user=user, + uploaded_file=SimpleUploadedFile("资料.docx", b"second"), + ) + + first.refresh_from_db() + assert first.version_no == 1 + assert first.is_active is False + assert second.version_no == 2 + assert second.is_active is True + assert FileAttachment.objects.filter(conversation=conversation).count() == 2 + assert (tmp_path / second.storage_path).read_bytes() == b"second" + + +def test_save_uploaded_attachment_rejects_path_traversal(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + attachment = save_uploaded_attachment( + conversation=conversation, + user=user, + uploaded_file=SimpleUploadedFile("../资料.docx", b"content"), + ) + + assert ".." not in attachment.storage_path + assert (tmp_path / attachment.storage_path).exists() diff --git a/tests/test_file_summary_views.py b/tests/test_file_summary_views.py new file mode 100644 index 0000000..bbf8745 --- /dev/null +++ b/tests/test_file_summary_views.py @@ -0,0 +1,75 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +import pytest + +from review_agent.models import Conversation, FileAttachment + + +pytestmark = pytest.mark.django_db + + +def test_upload_attachments_requires_conversation_owner(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="会话") + client.force_login(other) + + response = client.post( + reverse("file_summary_attachment_upload", args=[conversation.pk]), + {"files": [SimpleUploadedFile("a.docx", b"a")]}, + ) + + assert response.status_code == 404 + + +def test_attachment_api_requires_login(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + response = client.get(reverse("file_summary_attachment_list", args=[conversation.pk])) + + assert response.status_code == 302 + + +def test_upload_and_list_current_conversation_attachments(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + client.force_login(user) + + upload_response = client.post( + reverse("file_summary_attachment_upload", args=[conversation.pk]), + { + "files": [ + SimpleUploadedFile("a.docx", b"a", content_type="application/docx"), + SimpleUploadedFile("b.zip", b"b", content_type="application/zip"), + ] + }, + ) + list_response = client.get(reverse("file_summary_attachment_list", args=[conversation.pk])) + + assert upload_response.status_code == 200 + assert upload_response.json()["attachments"][0]["original_name"] == "a.docx" + assert len(list_response.json()["attachments"]) == 2 + + +def test_delete_attachment_is_logical_and_scoped(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + 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="a.docx", + storage_path="x/a.docx", + file_size=1, + ) + client.force_login(user) + + response = client.delete(reverse("file_summary_attachment_detail", args=[conversation.pk, attachment.pk])) + + attachment.refresh_from_db() + assert response.status_code == 200 + assert attachment.upload_status == FileAttachment.UploadStatus.DELETED + assert attachment.is_active is False