feat(documents): 增强上传反馈与状态展示
This commit is contained in:
@@ -3,14 +3,24 @@ from pathlib import Path
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from apps.scenarios.services import ScenarioNotFound, get_scenario
|
from apps.scenarios.services import ScenarioNotFound, get_scenario
|
||||||
|
from apps.scenarios.services import list_scenarios
|
||||||
|
|
||||||
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx"}
|
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx"}
|
||||||
|
|
||||||
|
|
||||||
class DocumentUploadForm(forms.Form):
|
class DocumentUploadForm(forms.Form):
|
||||||
scenario_id = forms.CharField(label="场景")
|
# 使用 ChoiceField 让表单自己维护场景选项,
|
||||||
|
# 这样模板、校验和后续扩展都能围绕一个入口完成。
|
||||||
|
scenario_id = forms.ChoiceField(label="场景", choices=())
|
||||||
file = forms.FileField(label="文件")
|
file = forms.FileField(label="文件")
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["scenario_id"].choices = [
|
||||||
|
(scenario["id"], scenario["name"])
|
||||||
|
for scenario in list_scenarios()
|
||||||
|
]
|
||||||
|
|
||||||
def clean_scenario_id(self):
|
def clean_scenario_id(self):
|
||||||
scenario_id = self.cleaned_data["scenario_id"]
|
scenario_id = self.cleaned_data["scenario_id"]
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from django.db import models
|
|||||||
|
|
||||||
|
|
||||||
class UploadedDocument(models.Model):
|
class UploadedDocument(models.Model):
|
||||||
|
# 文档状态用于驱动前端提示和后续可操作项。
|
||||||
STATUS_UPLOADED = "uploaded"
|
STATUS_UPLOADED = "uploaded"
|
||||||
STATUS_INDEXED = "indexed"
|
STATUS_INDEXED = "indexed"
|
||||||
STATUS_FAILED = "failed"
|
STATUS_FAILED = "failed"
|
||||||
@@ -21,3 +22,11 @@ class UploadedDocument(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.original_name
|
return self.original_name
|
||||||
|
|
||||||
|
def get_status_display_text(self) -> str:
|
||||||
|
"""为模板提供更适合演示的中文状态文案。"""
|
||||||
|
return {
|
||||||
|
self.STATUS_UPLOADED: "已上传,待入库",
|
||||||
|
self.STATUS_INDEXED: "已入库,可检索",
|
||||||
|
self.STATUS_FAILED: "入库失败",
|
||||||
|
}.get(self.status, self.status)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.contrib import messages
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
@@ -9,15 +10,18 @@ from .services import create_uploaded_document, index_document
|
|||||||
|
|
||||||
|
|
||||||
def document_list(request):
|
def document_list(request):
|
||||||
|
# 列表页只负责展示文档元数据和可执行操作,不处理入库细节。
|
||||||
documents = UploadedDocument.objects.all()
|
documents = UploadedDocument.objects.all()
|
||||||
return render(request, "documents/document_list.html", {"documents": documents})
|
return render(request, "documents/document_list.html", {"documents": documents})
|
||||||
|
|
||||||
|
|
||||||
def upload(request):
|
def upload(request):
|
||||||
|
# 上传成功后仅保存文件和元数据,是否入库由用户显式触发。
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = DocumentUploadForm(request.POST, request.FILES)
|
form = DocumentUploadForm(request.POST, request.FILES)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
create_uploaded_document(form.cleaned_data["scenario_id"], form.cleaned_data["file"])
|
create_uploaded_document(form.cleaned_data["scenario_id"], form.cleaned_data["file"])
|
||||||
|
messages.success(request, "文件已上传,可继续执行入库。")
|
||||||
return redirect("documents:list")
|
return redirect("documents:list")
|
||||||
else:
|
else:
|
||||||
form = DocumentUploadForm()
|
form = DocumentUploadForm()
|
||||||
@@ -31,5 +35,9 @@ def upload(request):
|
|||||||
@require_POST
|
@require_POST
|
||||||
def index(request, document_id: int):
|
def index(request, document_id: int):
|
||||||
document = get_object_or_404(UploadedDocument, pk=document_id)
|
document = get_object_or_404(UploadedDocument, pk=document_id)
|
||||||
index_document(document)
|
document = index_document(document)
|
||||||
|
if document.status == UploadedDocument.STATUS_INDEXED:
|
||||||
|
messages.success(request, "文档入库成功,当前文档已可参与检索。")
|
||||||
|
else:
|
||||||
|
messages.error(request, "文档入库失败,请检查错误原因后重试。")
|
||||||
return redirect("documents:list")
|
return redirect("documents:list")
|
||||||
|
|||||||
@@ -338,6 +338,13 @@
|
|||||||
<a class="nav-link" href="{% url 'audit:list' %}">审计日志</a>
|
<a class="nav-link" href="{% url 'audit:list' %}">审计日志</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
{% if messages %}
|
||||||
|
<section class="stack" style="margin-bottom: 18px;">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="notice{% if message.tags == 'error' %} notice-error{% endif %}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<td>{{ document.scenario_id }}</td>
|
<td>{{ document.scenario_id }}</td>
|
||||||
<td>{{ document.file_type }}</td>
|
<td>{{ document.file_type }}</td>
|
||||||
<td>{{ document.size }}</td>
|
<td>{{ document.size }}</td>
|
||||||
<td>{{ document.status }}</td>
|
<td>{{ document.get_status_display_text }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if document.status != "indexed" %}
|
{% if document.status != "indexed" %}
|
||||||
<form action="{% url 'documents:index' document.id %}" method="post">
|
<form action="{% url 'documents:index' document.id %}" method="post">
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
{% if document.error_message %}
|
{% if document.error_message %}
|
||||||
<pre class="code-block" style="margin-top: 10px;">{{ document.error_message }}</pre>
|
<pre class="code-block" style="margin-top: 10px;">{{ document.error_message }}</pre>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<p class="muted" style="margin-top: 10px;">上传时间:{{ document.created_at|date:"Y-m-d H:i" }}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -13,12 +13,11 @@
|
|||||||
<form method="post" enctype="multipart/form-data" class="stack">
|
<form method="post" enctype="multipart/form-data" class="stack">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div>
|
<div>
|
||||||
<label for="id_scenario_id">关联场景</label>
|
{{ form.scenario_id.label_tag }}
|
||||||
<select name="scenario_id" id="id_scenario_id">
|
{{ form.scenario_id }}
|
||||||
{% for scenario in scenarios %}
|
{% if form.scenario_id.errors %}
|
||||||
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
|
<p class="notice notice-error">{{ form.scenario_id.errors|join:" " }}</p>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{ form.file.label_tag }}
|
{{ form.file.label_tag }}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.documents.forms import DocumentUploadForm
|
||||||
from apps.documents.models import UploadedDocument
|
from apps.documents.models import UploadedDocument
|
||||||
from apps.documents.services import extract_text
|
from apps.documents.services import extract_text
|
||||||
|
|
||||||
@@ -20,6 +21,19 @@ def test_upload_txt_document_creates_uploaded_record(client, db):
|
|||||||
assert document.scenario_id == "knowledge_qa"
|
assert document.scenario_id == "knowledge_qa"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_redirect_shows_success_message(client, db):
|
||||||
|
file = SimpleUploadedFile("notice.txt", "hello".encode("utf-8"), content_type="text/plain")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
reverse("documents:upload"),
|
||||||
|
{"scenario_id": "knowledge_qa", "file": file},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "文件已上传,可继续执行入库" in response.content.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_upload_accepts_pdf_and_docx_documents(client, db):
|
def test_upload_accepts_pdf_and_docx_documents(client, db):
|
||||||
for filename, payload in [
|
for filename, payload in [
|
||||||
("policy.pdf", b"%PDF-1.4\nplain policy text"),
|
("policy.pdf", b"%PDF-1.4\nplain policy text"),
|
||||||
@@ -80,3 +94,37 @@ def test_extract_text_supports_pdf_and_docx_plain_text_fallback(db):
|
|||||||
|
|
||||||
assert "Safety policy" in extract_text(pdf_document)
|
assert "Safety policy" in extract_text(pdf_document)
|
||||||
assert "Contract clause review" in extract_text(docx_document)
|
assert "Contract clause review" in extract_text(docx_document)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_upload_form_builds_scenario_choices():
|
||||||
|
form = DocumentUploadForm()
|
||||||
|
|
||||||
|
choice_values = [value for value, _label in form.fields["scenario_id"].choices]
|
||||||
|
|
||||||
|
assert "knowledge_qa" in choice_values
|
||||||
|
assert "quality_analysis" in choice_values
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_failure_message_is_visible_on_document_list(client, db, monkeypatch):
|
||||||
|
document = UploadedDocument.objects.create(
|
||||||
|
scenario_id="knowledge_qa",
|
||||||
|
original_name="broken.md",
|
||||||
|
file_type="md",
|
||||||
|
size=5,
|
||||||
|
status="uploaded",
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_index_document(target_document):
|
||||||
|
target_document.status = UploadedDocument.STATUS_FAILED
|
||||||
|
target_document.error_message = "模拟入库失败"
|
||||||
|
target_document.save(update_fields=["status", "error_message", "updated_at"])
|
||||||
|
return target_document
|
||||||
|
|
||||||
|
monkeypatch.setattr("apps.documents.views.index_document", fake_index_document)
|
||||||
|
|
||||||
|
response = client.post(reverse("documents:index", args=[document.id]), follow=True)
|
||||||
|
|
||||||
|
content = response.content.decode("utf-8")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "文档入库失败,请检查错误原因后重试" in content
|
||||||
|
assert "模拟入库失败" in content
|
||||||
|
|||||||
Reference in New Issue
Block a user