From 4023a90d6e0b2876c560f0318c1cf6aea544e354 Mon Sep 17 00:00:00 2001 From: Matt Westcott Date: Fri, 26 Apr 2019 17:19:04 +0100 Subject: [PATCH] Allow custom image model to have fields defined as required If validation rules prevent the multiple image upload view from creating Image objects from just the image file, an UploadedImage object is created instead, and turned into an image once the form is filled in. * Fixes #847 * Add UploadedImage model and related views * Update custom image model docs --- CHANGELOG.txt | 1 + .../images/custom_image_model.rst | 7 +- docs/releases/2.9.rst | 1 + wagtail/images/admin_urls.py | 2 + .../images/migrations/0022_uploadedimage.py | 24 ++ wagtail/images/models.py | 14 ++ .../wagtailimages/multiple/edit_form.html | 4 +- wagtail/images/tests/test_admin_views.py | 236 +++++++++++++++++- wagtail/images/views/multiple.py | 108 +++++++- ...agewithauthor_customrenditionwithauthor.py | 62 +++++ wagtail/tests/testapp/models.py | 18 ++ 11 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 wagtail/images/migrations/0022_uploadedimage.py create mode 100644 wagtail/tests/testapp/migrations/0050_customimagewithauthor_customrenditionwithauthor.py diff --git a/CHANGELOG.txt b/CHANGELOG.txt index de5053141d..79dd4f1092 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -27,6 +27,7 @@ Changelog * Added `select_related` attribute to site settings to enable more efficient fetching of foreign key values (Andy Babic) * Add caching of image renditions (Tom Dyson, Tim Kamanin) * Add documentation for reporting security issues (Matt Westcott) + * Fields on a custom image model can now be defined as required `blank=False` (Matt Westcott) * Fix: Added ARIA alert role to live search forms in the admin (Casper Timmers) * Fix: Reorder login form elements to match expected tab order (Kjartan Sverrisson) * Fix: Re-add 'Close Explorer' button on mobile viewports (Sævar Öfjörð Magnússon) diff --git a/docs/advanced_topics/images/custom_image_model.rst b/docs/advanced_topics/images/custom_image_model.rst index 7ee5992fda..040a8386ce 100644 --- a/docs/advanced_topics/images/custom_image_model.rst +++ b/docs/advanced_topics/images/custom_image_model.rst @@ -47,12 +47,9 @@ Here's an example: ) -.. note:: +.. versionchanged:: 2.9 - Fields defined on a custom image model must either be set as non-required - (``blank=True``), or specify a default value - this is because uploading - the image and entering custom data happen as two separate actions, and - Wagtail needs to be able to create an image record immediately on upload. + Fields on a custom image model can now be defined as required (``blank=False``). Then set the ``WAGTAILIMAGES_IMAGE_MODEL`` setting to point to it: diff --git a/docs/releases/2.9.rst b/docs/releases/2.9.rst index d16f6487e7..efb6b63b20 100644 --- a/docs/releases/2.9.rst +++ b/docs/releases/2.9.rst @@ -40,6 +40,7 @@ Other features * Added ``select_related`` attribute to site settings to enable more efficient fetching of foreign key values (Andy Babic) * Add caching of image renditions (Tom Dyson, Tim Kamanin) * Add documentation for reporting security issues (Matt Westcott) + * Fields on a custom image model can now be defined as required ``blank=False`` (Matt Westcott) Bug fixes diff --git a/wagtail/images/admin_urls.py b/wagtail/images/admin_urls.py index c2cb471d35..1f12ce7da3 100644 --- a/wagtail/images/admin_urls.py +++ b/wagtail/images/admin_urls.py @@ -15,7 +15,9 @@ urlpatterns = [ url(r'^multiple/add/$', multiple.add, name='add_multiple'), url(r'^multiple/(\d+)/$', multiple.edit, name='edit_multiple'), + url(r'^multiple/create_from_uploaded_image/(\d+)/$', multiple.create_from_uploaded_image, name='create_multiple_from_uploaded_image'), url(r'^multiple/(\d+)/delete/$', multiple.delete, name='delete_multiple'), + url(r'^multiple/delete_upload/(\d+)/$', multiple.delete_upload, name='delete_upload_multiple'), url(r'^chooser/$', chooser.chooser, name='chooser'), url(r'^chooser/(\d+)/$', chooser.image_chosen, name='image_chosen'), diff --git a/wagtail/images/migrations/0022_uploadedimage.py b/wagtail/images/migrations/0022_uploadedimage.py new file mode 100644 index 0000000000..adc2e8653d --- /dev/null +++ b/wagtail/images/migrations/0022_uploadedimage.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2 on 2019-04-30 10:09 + +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), + ('wagtailimages', '0001_squashed_0021'), + ] + + operations = [ + migrations.CreateModel( + name='UploadedImage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.ImageField(max_length=200, upload_to='uploaded_images')), + ('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')), + ], + ), + ] diff --git a/wagtail/images/models.py b/wagtail/images/models.py index 5e0f2773f3..395d1c45ec 100644 --- a/wagtail/images/models.py +++ b/wagtail/images/models.py @@ -572,3 +572,17 @@ class Rendition(AbstractRendition): unique_together = ( ('image', 'filter_spec', 'focal_point_key'), ) + + +class UploadedImage(models.Model): + """ + Temporary storage for images uploaded through the multiple image uploader, when validation rules (e.g. + required metadata fields) prevent creating an Image object from the image file alone. In this case, + the image file is stored against this model, to be turned into an Image object once the full form + has been filled in. + """ + file = models.ImageField(upload_to='uploaded_images', 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 + ) diff --git a/wagtail/images/templates/wagtailimages/multiple/edit_form.html b/wagtail/images/templates/wagtailimages/multiple/edit_form.html index c169248a28..b2c8ed8d42 100644 --- a/wagtail/images/templates/wagtailimages/multiple/edit_form.html +++ b/wagtail/images/templates/wagtailimages/multiple/edit_form.html @@ -1,6 +1,6 @@ {% load i18n %} -
+
diff --git a/wagtail/images/tests/test_admin_views.py b/wagtail/images/tests/test_admin_views.py index 56a93d114a..d106e74599 100644 --- a/wagtail/images/tests/test_admin_views.py +++ b/wagtail/images/tests/test_admin_views.py @@ -9,8 +9,9 @@ from django.urls import reverse from django.utils.http import RFC3986_SUBDELIMS, urlquote from wagtail.core.models import Collection, GroupCollectionPermission +from wagtail.images.models import UploadedImage from wagtail.images.views.serve import generate_signature -from wagtail.tests.testapp.models import CustomImage +from wagtail.tests.testapp.models import CustomImage, CustomImageWithAuthor from wagtail.tests.utils import WagtailTestUtils from .utils import Image, get_test_image_file @@ -1082,13 +1083,13 @@ class TestMultipleImageUploader(TestCase, WagtailTestUtils): # Check form self.assertIn('form', response.context) self.assertEqual(response.context['form'].initial['title'], 'test.png') + self.assertEqual(response.context['edit_action'], '/admin/images/multiple/%d/' % response.context['image'].id) + self.assertEqual(response.context['delete_action'], '/admin/images/multiple/%d/delete/' % response.context['image'].id) # Check JSON response_json = json.loads(response.content.decode()) - self.assertIn('image_id', response_json) self.assertIn('form', response_json) self.assertIn('success', response_json) - self.assertEqual(response_json['image_id'], response.context['image'].id) self.assertTrue(response_json['success']) def test_add_post_noajax(self): @@ -1111,7 +1112,7 @@ class TestMultipleImageUploader(TestCase, WagtailTestUtils): def test_add_post_badfile(self): """ - This tests that the add view checks for a file when a user POSTs to it + The add view must check that the uploaded file is a valid image """ response = self.client.post(reverse('wagtailimages:add_multiple'), { 'files[]': SimpleUploadedFile('test.png', b"This is not an image!"), @@ -1149,7 +1150,7 @@ class TestMultipleImageUploader(TestCase, WagtailTestUtils): # Send request response = self.client.post(reverse('wagtailimages:edit_multiple', args=(self.image.id, )), { ('image-%d-title' % self.image.id): "New title!", - ('image-%d-tags' % self.image.id): "", + ('image-%d-tags' % self.image.id): "cromarty, finisterre", }, HTTP_X_REQUESTED_WITH='XMLHttpRequest') # Check response @@ -1164,6 +1165,11 @@ class TestMultipleImageUploader(TestCase, WagtailTestUtils): self.assertEqual(response_json['image_id'], self.image.id) self.assertTrue(response_json['success']) + # test that changes have been applied to the image + image = Image.objects.get(id=self.image.id) + self.assertEqual(image.title, "New title!") + self.assertIn('cromarty', image.tags.names()) + def test_edit_post_noajax(self): """ This tests that a POST request to the edit view without AJAX returns a 400 response @@ -1301,15 +1307,38 @@ class TestMultipleImageUploaderWithCustomImageModel(TestCase, WagtailTestUtils): self.assertEqual(response.context['form'].initial['title'], 'test.png') self.assertIn('caption', response.context['form'].fields) self.assertNotIn('not_editable_field', response.context['form'].fields) + self.assertEqual(response.context['edit_action'], '/admin/images/multiple/%d/' % response.context['image'].id) + self.assertEqual(response.context['delete_action'], '/admin/images/multiple/%d/delete/' % response.context['image'].id) # Check JSON response_json = json.loads(response.content.decode()) - self.assertIn('image_id', response_json) self.assertIn('form', response_json) self.assertIn('success', response_json) - self.assertEqual(response_json['image_id'], response.context['image'].id) self.assertTrue(response_json['success']) + def test_add_post_badfile(self): + """ + The add view must check that the uploaded file is a valid image + """ + response = self.client.post(reverse('wagtailimages:add_multiple'), { + 'files[]': SimpleUploadedFile('test.png', b"This is not an image!"), + }, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + # 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.assertNotIn('image_id', response_json) + self.assertNotIn('form', response_json) + self.assertIn('success', response_json) + self.assertIn('error_message', response_json) + self.assertFalse(response_json['success']) + self.assertEqual( + response_json['error_message'], "Not a supported image format. Supported formats: GIF, JPEG, PNG, WEBP." + ) + def test_edit_post(self): """ This tests that a POST request to the edit view edits the image @@ -1317,7 +1346,7 @@ class TestMultipleImageUploaderWithCustomImageModel(TestCase, WagtailTestUtils): # Send request response = self.client.post(reverse('wagtailimages:edit_multiple', args=(self.image.id, )), { ('image-%d-title' % self.image.id): "New title!", - ('image-%d-tags' % self.image.id): "", + ('image-%d-tags' % self.image.id): "footwear, dystopia", ('image-%d-caption' % self.image.id): "a boot stamping on a human face, forever", }, HTTP_X_REQUESTED_WITH='XMLHttpRequest') @@ -1337,6 +1366,7 @@ class TestMultipleImageUploaderWithCustomImageModel(TestCase, WagtailTestUtils): new_image = CustomImage.objects.get(id=self.image.id) self.assertEqual(new_image.title, "New title!") self.assertEqual(new_image.caption, "a boot stamping on a human face, forever") + self.assertIn('footwear', new_image.tags.names()) def test_delete_post(self): """ @@ -1365,6 +1395,196 @@ class TestMultipleImageUploaderWithCustomImageModel(TestCase, WagtailTestUtils): self.assertEqual(CustomImage.objects.filter(id=self.image.id).count(), 0) +@override_settings(WAGTAILIMAGES_IMAGE_MODEL='tests.CustomImageWithAuthor') +class TestMultipleImageUploaderWithCustomRequiredFields(TestCase, WagtailTestUtils): + """ + This tests the multiple image upload views located in wagtailimages/views/multiple.py + with a custom image model + """ + def setUp(self): + self.user = self.login() + + # Create an UploadedImage for running tests on + self.uploaded_image = UploadedImage.objects.create( + file=get_test_image_file(), + uploaded_by_user=self.user, + ) + + def test_add(self): + """ + This tests that the add view responds correctly on a GET request + """ + # Send request + response = self.client.get(reverse('wagtailimages:add_multiple')) + + # Check response + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'wagtailimages/multiple/add.html') + + def test_add_post(self): + """ + A POST request to the add view should create an UploadedImage rather than an image, + as we do not have enough data to pass CustomImageWithAuthor's validation yet + """ + image_count_before = CustomImageWithAuthor.objects.count() + uploaded_image_count_before = UploadedImage.objects.count() + + response = self.client.post(reverse('wagtailimages:add_multiple'), { + 'files[]': SimpleUploadedFile('test.png', get_test_image_file().file.getvalue()), + }, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + image_count_after = CustomImageWithAuthor.objects.count() + uploaded_image_count_after = UploadedImage.objects.count() + + # an UploadedImage should have been created now, but not a CustomImageWithAuthor + self.assertEqual(image_count_after, image_count_before) + self.assertEqual(uploaded_image_count_after, uploaded_image_count_before + 1) + + # Check response + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/json') + self.assertTemplateUsed(response, 'wagtailimages/multiple/edit_form.html') + + # Check image + self.assertIn('uploaded_image', response.context) + self.assertTrue(response.context['uploaded_image'].file.name) + + # Check form + self.assertIn('form', response.context) + self.assertEqual(response.context['form'].initial['title'], 'test.png') + self.assertIn('author', response.context['form'].fields) + self.assertEqual(response.context['edit_action'], '/admin/images/multiple/create_from_uploaded_image/%d/' % response.context['uploaded_image'].id) + self.assertEqual(response.context['delete_action'], '/admin/images/multiple/delete_upload/%d/' % response.context['uploaded_image'].id) + + # Check JSON + response_json = json.loads(response.content.decode()) + self.assertIn('form', response_json) + self.assertIn('success', response_json) + self.assertTrue(response_json['success']) + + def test_add_post_badfile(self): + """ + The add view must check that the uploaded file is a valid image + """ + response = self.client.post(reverse('wagtailimages:add_multiple'), { + 'files[]': SimpleUploadedFile('test.png', b"This is not an image!"), + }, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + # 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.assertNotIn('image_id', response_json) + self.assertNotIn('form', response_json) + self.assertIn('success', response_json) + self.assertIn('error_message', response_json) + self.assertFalse(response_json['success']) + self.assertEqual( + response_json['error_message'], "Not a supported image format. Supported formats: GIF, JPEG, PNG, WEBP." + ) + + def test_create_from_upload_invalid_post(self): + """ + Posting an invalid form to the create_from_uploaded_image view throws a validation error and leaves the + UploadedImage intact + """ + image_count_before = CustomImageWithAuthor.objects.count() + uploaded_image_count_before = UploadedImage.objects.count() + + # Send request + response = self.client.post(reverse('wagtailimages:create_multiple_from_uploaded_image', args=(self.uploaded_image.id, )), { + ('uploaded-image-%d-title' % self.uploaded_image.id): "New title!", + ('uploaded-image-%d-tags' % self.uploaded_image.id): "", + ('uploaded-image-%d-author' % self.uploaded_image.id): "", + }, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + image_count_after = CustomImageWithAuthor.objects.count() + uploaded_image_count_after = UploadedImage.objects.count() + + # no changes to image / UploadedImage count + self.assertEqual(image_count_after, image_count_before) + self.assertEqual(uploaded_image_count_after, uploaded_image_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/images/multiple/create_from_uploaded_image/%d/' % response.context['uploaded_image'].id) + self.assertEqual(response.context['delete_action'], '/admin/images/multiple/delete_upload/%d/' % response.context['uploaded_image'].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_image view will create the image + """ + image_count_before = CustomImageWithAuthor.objects.count() + uploaded_image_count_before = UploadedImage.objects.count() + + # Send request + response = self.client.post(reverse('wagtailimages:create_multiple_from_uploaded_image', args=(self.uploaded_image.id, )), { + ('uploaded-image-%d-title' % self.uploaded_image.id): "New title!", + ('uploaded-image-%d-tags' % self.uploaded_image.id): "abstract, squares", + ('uploaded-image-%d-author' % self.uploaded_image.id): "Piet Mondrian", + }, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + image_count_after = CustomImageWithAuthor.objects.count() + uploaded_image_count_after = UploadedImage.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('image_id', response_json) + self.assertTrue(response_json['success']) + + # Image should have been created, UploadedImage deleted + self.assertEqual(image_count_after, image_count_before + 1) + self.assertEqual(uploaded_image_count_after, uploaded_image_count_before - 1) + + image = CustomImageWithAuthor.objects.get(id=response_json['image_id']) + self.assertEqual(image.title, 'New title!') + self.assertEqual(image.author, 'Piet Mondrian') + self.assertTrue(image.file.name) + self.assertTrue(image.file_hash) + self.assertTrue(image.file_size) + self.assertEqual(image.width, 640) + self.assertEqual(image.height, 480) + self.assertIn('abstract', image.tags.names()) + + def test_delete_uploaded_image(self): + """ + This tests that a POST request to the delete view deletes the UploadedImage + """ + # Send request + response = self.client.post(reverse( + 'wagtailimages:delete_upload_multiple', args=(self.uploaded_image.id, ) + ), HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + # Check response + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/json') + + # Make sure the image is deleted + self.assertFalse(CustomImageWithAuthor.objects.filter(id=self.uploaded_image.id).exists()) + + # Check JSON + response_json = json.loads(response.content.decode()) + self.assertTrue(response_json['success']) + + class TestURLGeneratorView(TestCase, WagtailTestUtils): def setUp(self): # Create an image for running tests on diff --git a/wagtail/images/views/multiple.py b/wagtail/images/views/multiple.py index 236b4dd4a0..37f237a470 100644 --- a/wagtail/images/views/multiple.py +++ b/wagtail/images/views/multiple.py @@ -1,8 +1,10 @@ +import os.path + from django.core.exceptions import PermissionDenied from django.http import HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404, render from django.template.loader import render_to_string -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 @@ -11,6 +13,7 @@ from wagtail.core.models import Collection from wagtail.images import get_image_model from wagtail.images.fields import ALLOWED_EXTENSIONS from wagtail.images.forms import get_image_form +from wagtail.images.models import UploadedImage from wagtail.images.permissions import permission_policy from wagtail.search.backends import get_search_backends @@ -79,18 +82,39 @@ def add(request): 'image_id': int(image.id), 'form': render_to_string('wagtailimages/multiple/edit_form.html', { 'image': image, + 'edit_action': reverse('wagtailimages:edit_multiple', args=(image.id,)), + 'delete_action': reverse('wagtailimages:delete_multiple', args=(image.id,)), 'form': get_image_edit_form(Image)( instance=image, prefix='image-%d' % image.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 image form has failed validation, e.g. a required metadata field + # on a custom image model. Store the image as an UploadedImage instead and present the + # edit form so that it will become a proper Image when successfully filled in + uploaded_image = UploadedImage.objects.create( + file=request.FILES['files[]'], uploaded_by_user=request.user + ) + image = Image(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_image_id': uploaded_image.id, + 'form': render_to_string('wagtailimages/multiple/edit_form.html', { + 'uploaded_image': uploaded_image, + 'edit_action': reverse('wagtailimages:create_multiple_from_uploaded_image', args=(uploaded_image.id,)), + 'delete_action': reverse('wagtailimages:delete_upload_multiple', args=(uploaded_image.id,)), + 'form': get_image_edit_form(Image)( + instance=image, prefix='uploaded-image-%d' % uploaded_image.id, user=request.user + ), + }, request=request), }) else: # Instantiate a dummy copy of the form that we can retrieve validation messages and media from; @@ -142,6 +166,8 @@ def edit(request, image_id, callback=None): 'image_id': int(image_id), 'form': render_to_string('wagtailimages/multiple/edit_form.html', { 'image': image, + 'edit_action': reverse('wagtailimages:edit_multiple', args=(image_id,)), + 'delete_action': reverse('wagtailimages:delete_multiple', args=(image_id,)), 'form': form, }, request=request), }) @@ -163,3 +189,75 @@ def delete(request, image_id): 'success': True, 'image_id': int(image_id), }) + + +@require_POST +def create_from_uploaded_image(request, uploaded_image_id): + Image = get_image_model() + ImageForm = get_image_edit_form(Image) + + uploaded_image = get_object_or_404(UploadedImage, id=uploaded_image_id) + + if not request.is_ajax(): + return HttpResponseBadRequest("Cannot POST to this view without AJAX") + + if uploaded_image.uploaded_by_user != request.user: + raise PermissionDenied + + image = Image() + form = ImageForm( + request.POST, request.FILES, instance=image, prefix='uploaded-image-' + uploaded_image_id, user=request.user + ) + + if form.is_valid(): + # assign the file content from uploaded_image to the image object, to ensure it gets saved to + # Image's storage + + image.file.save(os.path.basename(uploaded_image.file.name), uploaded_image.file.file, save=False) + image.uploaded_by_user = request.user + image.file_size = image.file.size + image.file.open() + image.file.seek(0) + image._set_file_hash(image.file.read()) + image.file.seek(0) + form.save() + + uploaded_image.file.delete() + uploaded_image.delete() + + # Reindex the image to make sure all tags are indexed + for backend in get_search_backends(): + backend.add(image) + + return JsonResponse({ + 'success': True, + 'image_id': image.id, + }) + else: + return JsonResponse({ + 'success': False, + 'form': render_to_string('wagtailimages/multiple/edit_form.html', { + 'uploaded_image': uploaded_image, + 'edit_action': reverse('wagtailimages:create_multiple_from_uploaded_image', args=(uploaded_image.id,)), + 'delete_action': reverse('wagtailimages:delete_upload_multiple', args=(uploaded_image.id,)), + 'form': form, + }, request=request), + }) + + +@require_POST +def delete_upload(request, uploaded_image_id): + uploaded_image = get_object_or_404(UploadedImage, id=uploaded_image_id) + + if not request.is_ajax(): + return HttpResponseBadRequest("Cannot POST to this view without AJAX") + + if uploaded_image.uploaded_by_user != request.user: + raise PermissionDenied + + uploaded_image.file.delete() + uploaded_image.delete() + + return JsonResponse({ + 'success': True, + }) diff --git a/wagtail/tests/testapp/migrations/0050_customimagewithauthor_customrenditionwithauthor.py b/wagtail/tests/testapp/migrations/0050_customimagewithauthor_customrenditionwithauthor.py new file mode 100644 index 0000000000..d5d25cb60e --- /dev/null +++ b/wagtail/tests/testapp/migrations/0050_customimagewithauthor_customrenditionwithauthor.py @@ -0,0 +1,62 @@ +# Generated by Django 2.2 on 2019-04-26 15:48 + +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.images.models +import wagtail.search.index + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('wagtailcore', '0041_group_collection_permissions_verbose_name_plural'), + ('taggit', '0002_auto_20150616_2121'), + ('tests', '0049_rawhtmlblock'), + ] + + operations = [ + migrations.CreateModel( + name='CustomImageWithAuthor', + 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.ImageField(height_field='height', upload_to=wagtail.images.models.get_upload_to, verbose_name='file', width_field='width')), + ('width', models.IntegerField(editable=False, verbose_name='width')), + ('height', models.IntegerField(editable=False, verbose_name='height')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='created at')), + ('focal_point_x', models.PositiveIntegerField(blank=True, null=True)), + ('focal_point_y', models.PositiveIntegerField(blank=True, null=True)), + ('focal_point_width', models.PositiveIntegerField(blank=True, null=True)), + ('focal_point_height', models.PositiveIntegerField(blank=True, null=True)), + ('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={ + 'abstract': False, + }, + bases=(wagtail.search.index.Indexed, models.Model), + ), + migrations.CreateModel( + name='CustomRenditionWithAuthor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('filter_spec', models.CharField(db_index=True, max_length=255)), + ('file', models.ImageField(height_field='height', upload_to=wagtail.images.models.get_rendition_upload_to, width_field='width')), + ('width', models.IntegerField(editable=False)), + ('height', models.IntegerField(editable=False)), + ('focal_point_key', models.CharField(blank=True, default='', editable=False, max_length=16)), + ('image', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='renditions', to='tests.CustomImageWithAuthor')), + ], + options={ + 'unique_together': {('image', 'filter_spec', 'focal_point_key')}, + }, + ), + ] diff --git a/wagtail/tests/testapp/models.py b/wagtail/tests/testapp/models.py index 5ab47afe80..e926f3ac1b 100644 --- a/wagtail/tests/testapp/models.py +++ b/wagtail/tests/testapp/models.py @@ -932,6 +932,24 @@ class CustomRendition(AbstractRendition): ) +# Custom image model with a required field +class CustomImageWithAuthor(AbstractImage): + author = models.CharField(max_length=255) + + admin_form_fields = Image.admin_form_fields + ( + 'author', + ) + + +class CustomRenditionWithAuthor(AbstractRendition): + image = models.ForeignKey(CustomImageWithAuthor, related_name='renditions', on_delete=models.CASCADE) + + class Meta: + unique_together = ( + ('image', 'filter_spec', 'focal_point_key'), + ) + + class CustomDocument(AbstractDocument): description = models.TextField(blank=True) fancy_description = RichTextField(blank=True)