Support custom document models with required fields

Same approach as 4023a90d6e for images - define an UploadedDocument model to hold the uploaded file up to the point where the required metadata has been supplied.
pull/6701/head
Matt Westcott 2020-12-20 16:08:26 +00:00
rodzic c29a92f801
commit b13641e936
10 zmienionych plików z 420 dodań i 21 usunięć

Wyświetl plik

@ -25,8 +25,6 @@ Here's an example:
# Custom field example:
source = models.CharField(
max_length=255,
# This must be set to allow Wagtail to create a document instance
# on upload.
blank=True,
null=True
)
@ -36,12 +34,9 @@ Here's an example:
'source',
)
.. note::
.. versionchanged:: 2.12
Fields defined on a custom document model must either be set as non-required
(``blank=True``), or specify a default value. This is because uploading the
document and entering custom data happens as two separate actions. Wagtail
needs to be able to create a document record immediately on upload.
Fields on a custom document model can now be defined as required (``blank=False``).
Then in your settings module:

Wyświetl plik

@ -12,7 +12,9 @@ urlpatterns = [
path('multiple/add/', multiple.add, name='add_multiple'),
path('multiple/<int:doc_id>/', multiple.edit, name='edit_multiple'),
path('multiple/create_from_uploaded_document/<int:uploaded_document_id>/', multiple.create_from_uploaded_document, name='create_multiple_from_uploaded_document'),
path('multiple/<int:doc_id>/delete/', multiple.delete, name='delete_multiple'),
path('multiple/delete_upload/<int:uploaded_document_id>/', multiple.delete_upload, name='delete_upload_multiple'),
path('chooser/', chooser.chooser, name='chooser'),
path('chooser/<int:document_id>/', chooser.document_chosen, name='document_chosen'),

Wyświetl plik

@ -0,0 +1,24 @@
# Generated by Django 3.0.10 on 2020-12-20 01:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('wagtaildocs', '0011_add_choose_permissions'),
]
operations = [
migrations.CreateModel(
name='UploadedDocument',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(max_length=200, upload_to='uploaded_documents')),
('uploaded_by_user', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='uploaded by user')),
],
),
]

Wyświetl plik

@ -208,3 +208,17 @@ class Document(AbstractDocument):
document_served = Signal(providing_args=['request'])
class UploadedDocument(models.Model):
"""
Temporary storage for documents uploaded through the multiple doc uploader, when validation
rules (e.g. required metadata fields) prevent creating a Document object from the document file
alone. In this case, the document file is stored against this model, to be turned into a
Document object once the full form has been filled in.
"""
file = models.FileField(upload_to='uploaded_documents', max_length=200)
uploaded_by_user = models.ForeignKey(
settings.AUTH_USER_MODEL, verbose_name=_('uploaded by user'),
null=True, blank=True, editable=False, on_delete=models.SET_NULL
)

Wyświetl plik

@ -2,7 +2,7 @@
{% include "wagtailadmin/shared/non_field_errors.html" %}
<form action="{% url 'wagtaildocs:edit_multiple' doc.id %}" method="POST" enctype="multipart/form-data" novalidate>
<form action="{{ edit_action }}" method="POST" enctype="multipart/form-data" novalidate>
<ul class="fields">
{% csrf_token %}
{% for field in form %}
@ -14,7 +14,7 @@
{% endfor %}
<li>
<input type="submit" value="{% trans 'Update' %}" class="button" />
<a href="{% url 'wagtaildocs:delete_multiple' doc.id %}" class="delete button button-secondary no">{% trans "Delete" %}</a>
<a href="{{ delete_action }}" class="delete button button-secondary no">{% trans "Delete" %}</a>
</li>
</ul>
</form>

Wyświetl plik

@ -11,7 +11,8 @@ from django.urls import reverse
from wagtail.core.models import Collection, GroupCollectionPermission, Page
from wagtail.documents import get_document_model, models
from wagtail.documents.tests.utils import get_test_document_file
from wagtail.tests.testapp.models import CustomDocument, EventPage, EventPageRelatedLink
from wagtail.tests.testapp.models import (
CustomDocument, CustomDocumentWithAuthor, EventPage, EventPageRelatedLink)
from wagtail.tests.utils import WagtailTestUtils
@ -581,11 +582,11 @@ class TestMultipleDocumentUploader(TestCase, WagtailTestUtils):
"""
edit_post_data = {
'title': "New title!",
'tags': "",
'tags': "cromarty, finisterre",
}
def setUp(self):
self.login()
self.user = self.login()
# Create a document for running tests on
self.doc = get_document_model().objects.create(
@ -596,7 +597,7 @@ class TestMultipleDocumentUploader(TestCase, WagtailTestUtils):
def check_doc_after_edit(self):
self.doc.refresh_from_db()
self.assertEqual(self.doc.title, "New title!")
self.assertFalse(self.doc.tags.all())
self.assertIn('cromarty', self.doc.tags.names())
def check_form_media_in_response(self, response):
# draftail should NOT be a standard JS include on this page
@ -651,6 +652,8 @@ class TestMultipleDocumentUploader(TestCase, WagtailTestUtils):
self.assertEqual(response.context['doc'].title, 'test.png')
self.assertTrue(response.context['doc'].file_size)
self.assertTrue(response.context['doc'].file_hash)
self.assertEqual(response.context['edit_action'], '/admin/documents/multiple/%d/' % response.context['doc'].id)
self.assertEqual(response.context['delete_action'], '/admin/documents/multiple/%d/delete/' % response.context['doc'].id)
# check that it is in the root collection
doc = get_document_model().objects.get(title='test.png')
@ -888,6 +891,216 @@ class TestMultipleCustomDocumentUploaderNoCollection(TestMultipleCustomDocumentU
super().tearDownClass()
@override_settings(WAGTAILDOCS_DOCUMENT_MODEL='tests.CustomDocumentWithAuthor')
class TestMultipleCustomDocumentUploaderWithRequiredField(TestMultipleDocumentUploader):
edit_post_data = dict(TestMultipleDocumentUploader.edit_post_data, author="William Shakespeare")
def setUp(self):
super().setUp()
# Create an UploadedDocument for running tests on
self.uploaded_document = models.UploadedDocument.objects.create(
file=get_test_document_file(),
uploaded_by_user=self.user,
)
def test_add_post(self):
"""
This tests that a POST request to the add view saves the document as an UploadedDocument
and returns an edit form
"""
response = self.client.post(reverse('wagtaildocs:add_multiple'), {
'files[]': SimpleUploadedFile('test.png', b"Simple text document"),
}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
self.assertTemplateUsed(response, 'wagtaildocs/multiple/edit_form.html')
# Check document
self.assertIn('uploaded_document', response.context)
self.assertTrue(response.context['uploaded_document'].file.size)
self.assertEqual(
response.context['edit_action'],
'/admin/documents/multiple/create_from_uploaded_document/%d/' % response.context['uploaded_document'].id
)
self.assertEqual(
response.context['delete_action'],
'/admin/documents/multiple/delete_upload/%d/' % response.context['uploaded_document'].id
)
# Check form
self.assertIn('form', response.context)
self.assertEqual(
set(response.context['form'].fields),
set(get_document_model().admin_form_fields) - {'file', 'collection'},
)
self.assertEqual(response.context['form'].initial['title'], 'test.png')
# Check JSON
response_json = json.loads(response.content.decode())
self.assertIn('uploaded_document_id', response_json)
self.assertIn('form', response_json)
self.assertIn('success', response_json)
self.assertEqual(response_json['uploaded_document_id'], response.context['uploaded_document'].id)
self.assertTrue(response_json['success'])
# form should not contain a collection chooser
self.assertNotIn('Collection', response_json['form'])
def test_add_post_with_collections(self):
"""
This tests that a POST request to the add view saves the document
and returns an edit form, when collections are active
"""
root_collection = Collection.get_first_root_node()
evil_plans_collection = root_collection.add_child(name="Evil plans")
response = self.client.post(reverse('wagtaildocs:add_multiple'), {
'files[]': SimpleUploadedFile('test.png', b"Simple text document"),
'collection': evil_plans_collection.id
}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
self.assertTemplateUsed(response, 'wagtaildocs/multiple/edit_form.html')
# Check document
self.assertIn('uploaded_document', response.context)
self.assertTrue(response.context['uploaded_document'].file.size)
self.assertEqual(
response.context['edit_action'],
'/admin/documents/multiple/create_from_uploaded_document/%d/' % response.context['uploaded_document'].id
)
self.assertEqual(
response.context['delete_action'],
'/admin/documents/multiple/delete_upload/%d/' % response.context['uploaded_document'].id
)
# Check form
self.assertIn('form', response.context)
self.assertEqual(
set(response.context['form'].fields),
set(get_document_model().admin_form_fields) - {'file'} | {'collection'},
)
self.assertEqual(response.context['form'].initial['title'], 'test.png')
# Check JSON
response_json = json.loads(response.content.decode())
self.assertIn('uploaded_document_id', response_json)
self.assertIn('form', response_json)
self.assertIn('success', response_json)
self.assertEqual(response_json['uploaded_document_id'], response.context['uploaded_document'].id)
self.assertTrue(response_json['success'])
# form should contain a collection chooser
self.assertIn('Collection', response_json['form'])
def check_doc_after_edit(self):
super().check_doc_after_edit()
self.assertEqual(self.doc.author, "William Shakespeare")
def test_create_from_upload_invalid_post(self):
"""
Posting an invalid form to the create_from_uploaded_document view throws a validation error
and leaves the UploadedDocument intact
"""
doc_count_before = CustomDocumentWithAuthor.objects.count()
uploaded_doc_count_before = models.UploadedDocument.objects.count()
# Send request
response = self.client.post(reverse('wagtaildocs:create_multiple_from_uploaded_document', args=(self.uploaded_document.id, )), {
('uploaded-document-%d-title' % self.uploaded_document.id): "New title!",
('uploaded-document-%d-tags' % self.uploaded_document.id): "",
('uploaded-document-%d-author' % self.uploaded_document.id): "",
}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
doc_count_after = CustomDocumentWithAuthor.objects.count()
uploaded_doc_count_after = models.UploadedDocument.objects.count()
# no changes to document / UploadedDocument count
self.assertEqual(doc_count_after, doc_count_before)
self.assertEqual(uploaded_doc_count_after, uploaded_doc_count_before)
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
# Check form
self.assertIn('form', response.context)
self.assertIn('author', response.context['form'].fields)
self.assertEqual(response.context['edit_action'], '/admin/documents/multiple/create_from_uploaded_document/%d/' % response.context['uploaded_document'].id)
self.assertEqual(response.context['delete_action'], '/admin/documents/multiple/delete_upload/%d/' % response.context['uploaded_document'].id)
self.assertFormError(response, 'form', 'author', "This field is required.")
# Check JSON
response_json = json.loads(response.content.decode())
self.assertIn('form', response_json)
self.assertIn('New title!', response_json['form'])
self.assertFalse(response_json['success'])
def test_create_from_upload(self):
"""
Posting a valid form to the create_from_uploaded_document view will create the document
"""
doc_count_before = CustomDocumentWithAuthor.objects.count()
uploaded_doc_count_before = models.UploadedDocument.objects.count()
# Send request
response = self.client.post(reverse('wagtaildocs:create_multiple_from_uploaded_document', args=(self.uploaded_document.id, )), {
('uploaded-document-%d-title' % self.uploaded_document.id): "New title!",
('uploaded-document-%d-tags' % self.uploaded_document.id): "fairies, donkey",
('uploaded-document-%d-author' % self.uploaded_document.id): "William Shakespeare",
}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
doc_count_after = CustomDocumentWithAuthor.objects.count()
uploaded_doc_count_after = models.UploadedDocument.objects.count()
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
# Check JSON
response_json = json.loads(response.content.decode())
self.assertIn('doc_id', response_json)
self.assertTrue(response_json['success'])
# Document should have been created, UploadedDocument deleted
self.assertEqual(doc_count_after, doc_count_before + 1)
self.assertEqual(uploaded_doc_count_after, uploaded_doc_count_before - 1)
doc = CustomDocumentWithAuthor.objects.get(id=response_json['doc_id'])
self.assertEqual(doc.title, 'New title!')
self.assertEqual(doc.author, 'William Shakespeare')
self.assertTrue(doc.file.name)
self.assertTrue(doc.file_hash)
self.assertTrue(doc.file_size)
self.assertIn('donkey', doc.tags.names())
def test_delete_uploaded_document(self):
"""
This tests that a POST request to the delete view deletes the UploadedDocument
"""
# Send request
response = self.client.post(reverse(
'wagtaildocs:delete_upload_multiple', args=(self.uploaded_document.id, )
), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/json')
# Make sure the document is deleted
self.assertFalse(models.UploadedDocument.objects.filter(id=self.uploaded_document.id).exists())
# Check JSON
response_json = json.loads(response.content.decode())
self.assertTrue(response_json['success'])
class TestDocumentChooserView(TestCase, WagtailTestUtils):
def setUp(self):
self.user = self.login()

Wyświetl plik

@ -1,9 +1,11 @@
import os.path
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
from django.utils.encoding import force_str
from django.urls import reverse
from django.views.decorators.http import require_POST
from django.views.decorators.vary import vary_on_headers
@ -12,6 +14,7 @@ from wagtail.search.backends import get_search_backends
from .. import get_document_model
from ..forms import get_document_form, get_document_multi_form
from ..models import UploadedDocument
from ..permissions import permission_policy
@ -63,19 +66,42 @@ def add(request):
'success': True,
'doc_id': int(doc.id),
'form': render_to_string('wagtaildocs/multiple/edit_form.html', {
'doc': doc,
'doc': doc, # only used for tests
'edit_action': reverse('wagtaildocs:edit_multiple', args=(doc.id,)),
'delete_action': reverse('wagtaildocs:delete_multiple', args=(doc.id,)),
'form': DocumentMultiForm(
instance=doc, prefix='doc-%d' % doc.id, user=request.user
),
}, request=request),
})
else:
# Validation error
elif 'file' in form.errors:
# The uploaded file is invalid; reject it now
return JsonResponse({
'success': False,
'error_message': '\n'.join(form.errors['file']),
})
else:
# Some other field of the document form has failed validation, e.g. a required metadata
# field on a custom document model. Store the document as an UploadedDocument instead
# and present the edit form so that it will become a proper Document when successfully
# filled in
uploaded_doc = UploadedDocument.objects.create(
file=request.FILES['files[]'], uploaded_by_user=request.user
)
doc = Document(title=request.FILES['files[]'].name, collection_id=request.POST.get('collection'))
# https://github.com/django/django/blob/stable/1.6.x/django/forms/util.py#L45
'error_message': '\n'.join(['\n'.join([force_str(i) for i in v]) for k, v in form.errors.items()]),
return JsonResponse({
'success': True,
'uploaded_document_id': uploaded_doc.id,
'form': render_to_string('wagtaildocs/multiple/edit_form.html', {
'uploaded_document': uploaded_doc, # only used for tests
'edit_action': reverse('wagtaildocs:create_multiple_from_uploaded_document', args=(uploaded_doc.id,)),
'delete_action': reverse('wagtaildocs:delete_upload_multiple', args=(uploaded_doc.id,)),
'form': DocumentMultiForm(
instance=doc, prefix='uploaded-document-%d' % uploaded_doc.id, user=request.user
),
}, request=request),
})
else:
# Instantiate a dummy copy of the form that we can retrieve validation messages and media from;
@ -122,7 +148,9 @@ def edit(request, doc_id, callback=None):
'success': False,
'doc_id': int(doc_id),
'form': render_to_string('wagtaildocs/multiple/edit_form.html', {
'doc': doc,
'doc': doc, # only used for tests
'edit_action': reverse('wagtaildocs:edit_multiple', args=(doc_id,)),
'delete_action': reverse('wagtaildocs:delete_multiple', args=(doc_id,)),
'form': form,
}, request=request),
})
@ -146,3 +174,75 @@ def delete(request, doc_id):
'success': True,
'doc_id': int(doc_id),
})
@require_POST
def create_from_uploaded_document(request, uploaded_document_id):
Document = get_document_model()
DocumentMultiForm = get_document_multi_form(Document)
uploaded_doc = get_object_or_404(UploadedDocument, id=uploaded_document_id)
if not request.is_ajax():
return HttpResponseBadRequest("Cannot POST to this view without AJAX")
if uploaded_doc.uploaded_by_user != request.user:
raise PermissionDenied
doc = Document()
form = DocumentMultiForm(
request.POST, request.FILES, instance=doc, prefix='uploaded-document-%d' % uploaded_document_id, user=request.user
)
if form.is_valid():
# assign the file content from uploaded_doc to the image object, to ensure it gets saved to
# Document's storage
doc.file.save(os.path.basename(uploaded_doc.file.name), uploaded_doc.file.file, save=False)
doc.uploaded_by_user = request.user
doc.file_size = doc.file.size
doc.file.open()
doc.file.seek(0)
doc._set_file_hash(doc.file.read())
doc.file.seek(0)
form.save()
uploaded_doc.file.delete()
uploaded_doc.delete()
# Reindex the document to make sure all tags are indexed
for backend in get_search_backends():
backend.add(doc)
return JsonResponse({
'success': True,
'doc_id': doc.id,
})
else:
return JsonResponse({
'success': False,
'form': render_to_string('wagtaildocs/multiple/edit_form.html', {
'uploaded_document': uploaded_doc, # only used for tests
'edit_action': reverse('wagtaildocs:create_multiple_from_uploaded_document', args=(uploaded_doc.id,)),
'delete_action': reverse('wagtaildocs:delete_upload_multiple', args=(uploaded_doc.id,)),
'form': form,
}, request=request),
})
@require_POST
def delete_upload(request, uploaded_document_id):
uploaded_doc = get_object_or_404(UploadedDocument, id=uploaded_document_id)
if not request.is_ajax():
return HttpResponseBadRequest("Cannot POST to this view without AJAX")
if uploaded_doc.uploaded_by_user != request.user:
raise PermissionDenied
uploaded_doc.file.delete()
uploaded_doc.delete()
return JsonResponse({
'success': True,
})

Wyświetl plik

@ -1902,7 +1902,7 @@ class TestMultipleImageUploaderWithCustomRequiredFields(TestCase, WagtailTestUti
self.assertEqual(response['Content-Type'], 'application/json')
# Make sure the image is deleted
self.assertFalse(CustomImageWithAuthor.objects.filter(id=self.uploaded_image.id).exists())
self.assertFalse(UploadedImage.objects.filter(id=self.uploaded_image.id).exists())
# Check JSON
response_json = json.loads(response.content.decode())

Wyświetl plik

@ -0,0 +1,42 @@
# Generated by Django 3.0.10 on 2020-12-20 14:52
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
import wagtail.core.models
import wagtail.search.index
class Migration(migrations.Migration):
dependencies = [
('wagtailcore', '0059_apply_collection_ordering'),
('taggit', '0003_taggeditem_add_unique_index'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('tests', '0056_streampage_nested_streamblock'),
]
operations = [
migrations.CreateModel(
name='CustomDocumentWithAuthor',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='title')),
('file', models.FileField(upload_to='documents', verbose_name='file')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('file_size', models.PositiveIntegerField(editable=False, null=True)),
('file_hash', models.CharField(blank=True, editable=False, max_length=40)),
('author', models.CharField(max_length=255)),
('collection', models.ForeignKey(default=wagtail.core.models.get_root_collection_id, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='wagtailcore.Collection', verbose_name='collection')),
('tags', taggit.managers.TaggableManager(blank=True, help_text=None, through='taggit.TaggedItem', to='taggit.Tag', verbose_name='tags')),
('uploaded_by_user', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='uploaded by user')),
],
options={
'verbose_name': 'document',
'verbose_name_plural': 'documents',
'abstract': False,
},
bases=(wagtail.search.index.Indexed, models.Model),
),
]

Wyświetl plik

@ -990,6 +990,15 @@ class CustomDocument(AbstractDocument):
]
# Custom document model with a required field
class CustomDocumentWithAuthor(AbstractDocument):
author = models.CharField(max_length=255)
admin_form_fields = Document.admin_form_fields + (
'author',
)
class StreamModel(models.Model):
body = StreamField([
('text', CharBlock()),