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)