From 24bccf8b9cf2392b93b1b0b412174b69e36b7c8c Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 19 Apr 2021 15:39:38 +0200 Subject: [PATCH] Add Question.valid_file_portrait as well as crop editor for images --- doc/api/resources/questions.rst | 9 + src/pretix/api/serializers/item.py | 4 +- src/pretix/base/forms/questions.py | 159 +- .../0182_question_valid_file_portrait.py | 18 + src/pretix/base/models/items.py | 6 + .../forms/widgets/portrait_image.html | 19 + src/pretix/control/forms/__init__.py | 16 +- src/pretix/control/forms/item.py | 1 + .../control/templates/pretixcontrol/base.html | 1 + .../pretixcontrol/items/question_edit.html | 3 + .../pretixpresale/event/fragment_cart.html | 7 + .../templates/pretixpresale/fragment_js.html | 1 + src/pretix/static/cropper/cropper.js | 3625 +++++++++++++++++ src/pretix/static/cropper/cropper.scss | 304 ++ src/pretix/static/pretixcontrol/js/ui/main.js | 1 + .../static/pretixcontrol/js/ui/question.js | 1 + .../static/pretixcontrol/scss/_forms.scss | 15 + .../static/pretixcontrol/scss/main.scss | 1 + src/pretix/static/pretixpresale/js/ui/main.js | 1 + .../static/pretixpresale/js/ui/questions.js | 42 +- .../static/pretixpresale/scss/_cart.scss | 8 + .../static/pretixpresale/scss/_forms.scss | 16 + .../static/pretixpresale/scss/main.scss | 1 + src/tests/api/test_items.py | 1 + 24 files changed, 4239 insertions(+), 21 deletions(-) create mode 100644 src/pretix/base/migrations/0182_question_valid_file_portrait.py create mode 100644 src/pretix/base/templates/pretixbase/forms/widgets/portrait_image.html create mode 100644 src/pretix/static/cropper/cropper.js create mode 100644 src/pretix/static/cropper/cropper.scss diff --git a/doc/api/resources/questions.rst b/doc/api/resources/questions.rst index 1ba8ee9c9e..62b13c8fcb 100644 --- a/doc/api/resources/questions.rst +++ b/doc/api/resources/questions.rst @@ -62,6 +62,7 @@ valid_date_min date Minimum value f valid_date_max date Maximum value for date questions (optional) valid_datetime_min datetime Minimum value for date and time questions (optional) valid_datetime_max datetime Maximum value for date and time questions (optional) +valid_file_portrait boolean Turn on file validation for portrait photos dependency_question integer Internal ID of a different question. The current question will only be shown if the question given in this attribute is set to the value given in @@ -83,6 +84,10 @@ dependency_value string An old version The attributes ``valid_*`` have been added. +.. versionchanged:: 3.18 + + The attribute ``valid_file_portrait`` have been added. + Endpoints --------- @@ -134,6 +139,7 @@ Endpoints "valid_date_max": null, "valid_datetime_min": null, "valid_datetime_max": null, + "valid_file_portrait": false, "dependency_question": null, "dependency_value": null, "dependency_values": [], @@ -211,6 +217,7 @@ Endpoints "valid_date_max": null, "valid_datetime_min": null, "valid_datetime_max": null, + "valid_file_portrait": false, "dependency_question": null, "dependency_value": null, "dependency_values": [], @@ -311,6 +318,7 @@ Endpoints "valid_date_max": null, "valid_datetime_min": null, "valid_datetime_max": null, + "valid_file_portrait": false, "options": [ { "id": 1, @@ -392,6 +400,7 @@ Endpoints "valid_date_max": null, "valid_datetime_min": null, "valid_datetime_max": null, + "valid_file_portrait": false, "options": [ { "id": 1, diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 6ac8716144..fe99b7d752 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -316,8 +316,8 @@ class QuestionSerializer(I18nAwareModelSerializer): fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position', 'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values', 'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min', - 'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max' - ) + 'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max', + 'valid_file_portrait') def validate_identifier(self, value): Question._clean_identifier(self.context['event'], value, self.instance) diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index 87f1ca2bf2..98e2b0cffa 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -36,6 +36,7 @@ import copy import json import logging from decimal import Decimal +from io import BytesIO from urllib.error import HTTPError import dateutil.parser @@ -47,6 +48,7 @@ from babel import Locale from django import forms from django.contrib import messages from django.core.exceptions import ValidationError +from django.core.files.uploadedfile import SimpleUploadedFile from django.core.validators import MaxValueValidator, MinValueValidator from django.db.models import QuerySet from django.forms import Select @@ -63,6 +65,7 @@ from phonenumber_field.phonenumber import PhoneNumber from phonenumber_field.widgets import PhoneNumberPrefixWidget from phonenumbers import NumberParseException, national_significant_number from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE +from PIL import ImageOps from pretix.base.forms.widgets import ( BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget, @@ -80,7 +83,9 @@ from pretix.base.settings import ( PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, ) from pretix.base.templatetags.rich_text import rich_text -from pretix.control.forms import ExtFileField, SplitDateTimeField +from pretix.control.forms import ( + ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField, +) from pretix.helpers.countries import CachedCountries from pretix.helpers.escapejson import escapejson_attr from pretix.helpers.i18n import get_format_without_seconds @@ -386,6 +391,126 @@ class MaxDateTimeValidator(MaxValueValidator): raise e +class PortraitImageWidget(UploadedFileWidget): + template_name = 'pretixbase/forms/widgets/portrait_image.html' + + def value_from_datadict(self, data, files, name): + d = super().value_from_datadict(data, files, name) + if d is not None: + d._cropdata = json.loads(data.get(name + '_cropdata', '{}') or '{}') + return d + + +class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileField): + widget = PortraitImageWidget + default_error_messages = { + 'aspect_ratio_landscape': _( + "You uploaded an image in landscape orientation. Please upload an image in portrait orientation." + ), + 'aspect_ratio_not_3_by_4': _( + "Please upload an image where the width is 3/4 of the height." + ), + 'max_dimension': _( + "The file you uploaded has a very large number of pixels, please upload an image no larger than 10000 x 10000 pixels." + ), + 'invalid_image': _( + "Upload a valid image. The file you uploaded was either not an " + "image or a corrupted image." + ), + } + + def to_python(self, data): + """ + Based on Django's ImageField + """ + f = super().to_python(data) + if f is None: + return None + + from PIL import Image + + # We need to get a file object for Pillow. We might have a path or we might + # have to read the data into memory. + if hasattr(data, 'temporary_file_path'): + file = data.temporary_file_path() + else: + if hasattr(data, 'read'): + file = BytesIO(data.read()) + else: + file = BytesIO(data['content']) + + try: + image = Image.open(file) + # verify() must be called immediately after the constructor. + image.verify() + + # We want to do more than just verify(), so we need to re-open the file + if hasattr(file, 'seek'): + file.seek(0) + image = Image.open(file) + + # load() is a potential DoS vector (see Django bug #18520), so we verify the size first + if image.width > 10_000 or image.height > 10_000: + raise ValidationError( + self.error_messages['max_dimension'], + code='max_dimension', + ) + + image.load() + + # Annotating so subclasses can reuse it for their own validation + f.image = image + # Pillow doesn't detect the MIME type of all formats. In those + # cases, content_type will be None. + f.content_type = Image.MIME.get(image.format) + + # before we calc aspect ratio, we need to check and apply EXIF-orientation + image = ImageOps.exif_transpose(image) + + if f._cropdata: + image = image.crop(( + f._cropdata.get('x', 0), + f._cropdata.get('y', 0), + f._cropdata.get('x', 0) + f._cropdata.get('width', image.width), + f._cropdata.get('y', 0) + f._cropdata.get('height', image.height), + )) + with BytesIO() as output: + # This might use a lot of memory, but temporary files are not a good option since + # we don't control the cleanup + image.save(output, format=f.image.format) + f = SimpleUploadedFile(f.name, output.getvalue(), f.content_type) + f.image = image + + if image.width > image.height: + raise ValidationError( + self.error_messages['aspect_ratio_landscape'], + code='aspect_ratio_landscape', + ) + + if not 3 / 4 * .95 < image.width / image.height < 3 / 4 * 1.05: # give it some tolerance + raise ValidationError( + self.error_messages['aspect_ratio_not_3_by_4'], + code='aspect_ratio_not_3_by_4', + ) + except Exception as exc: + logger.exception('foo') + # Pillow doesn't recognize it as an image. + if isinstance(exc, ValidationError): + raise + raise ValidationError( + self.error_messages['invalid_image'], + code='invalid_image', + ) from exc + if hasattr(f, 'seek') and callable(f.seek): + f.seek(0) + return f + + def __init__(self, *args, **kwargs): + kwargs.setdefault('ext_whitelist', (".png", ".jpg", ".jpeg", ".jfif", ".tif", ".tiff", ".bmp")) + kwargs.setdefault('max_size', 10 * 1024 * 1024) + super().__init__(*args, **kwargs) + + class BaseQuestionsForm(forms.Form): """ This form class is responsible for asking order-related questions. This includes @@ -596,18 +721,26 @@ class BaseQuestionsForm(forms.Form): initial=initial.options.all() if initial else None, ) elif q.type == Question.TYPE_FILE: - field = ExtFileField( - label=label, required=required, - help_text=help_text, - initial=initial.file if initial else None, - widget=UploadedFileWidget(position=pos, event=event, answer=initial), - ext_whitelist=( - ".png", ".jpg", ".gif", ".jpeg", ".pdf", ".txt", ".docx", ".gif", ".svg", - ".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages", - ".bmp", ".tif", ".tiff" - ), - max_size=10 * 1024 * 1024, - ) + if q.valid_file_portrait: + field = PortraitImageField( + label=label, required=required, + help_text=help_text, + initial=initial.file if initial else None, + widget=PortraitImageWidget(position=pos, event=event, answer=initial, attrs={'data-portrait-photo': 'true'}), + ) + else: + field = ExtFileField( + label=label, required=required, + help_text=help_text, + initial=initial.file if initial else None, + widget=UploadedFileWidget(position=pos, event=event, answer=initial), + ext_whitelist=( + ".png", ".jpg", ".gif", ".jpeg", ".pdf", ".txt", ".docx", ".gif", ".svg", + ".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages", + ".bmp", ".tif", ".tiff" + ), + max_size=10 * 1024 * 1024, + ) elif q.type == Question.TYPE_DATE: attrs = {} if q.valid_date_min: diff --git a/src/pretix/base/migrations/0182_question_valid_file_portrait.py b/src/pretix/base/migrations/0182_question_valid_file_portrait.py new file mode 100644 index 0000000000..b31809d9e9 --- /dev/null +++ b/src/pretix/base/migrations/0182_question_valid_file_portrait.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.13 on 2021-04-19 09:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0181_team_can_checkin_orders'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='valid_file_portrait', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 0991d79b1d..839a18b2fd 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -1140,6 +1140,12 @@ class Question(LoggedModel): valid_datetime_max = models.DateTimeField(null=True, blank=True, verbose_name=_('Maximum value'), help_text=_('Currently not supported in our apps and during check-in')) + valid_file_portrait = models.BooleanField( + default=False, + verbose_name=_('Validate file to be a portrait'), + help_text=_('If checked, files must be images with an aspect ratio of 3:4. This is commonly used for photos ' + 'printed on badges.') + ) objects = ScopedManager(organizer='event__organizer') diff --git a/src/pretix/base/templates/pretixbase/forms/widgets/portrait_image.html b/src/pretix/base/templates/pretixbase/forms/widgets/portrait_image.html new file mode 100644 index 0000000000..7c029b1525 --- /dev/null +++ b/src/pretix/base/templates/pretixbase/forms/widgets/portrait_image.html @@ -0,0 +1,19 @@ +{% load i18n %} +{% if widget.is_initial %}
{{ widget.initial_text }}: {{ widget.value }}{% if not widget.required %} + +{% endif %}
+{{ widget.input_text }}:{% else %}{% endif %} + + + + + + diff --git a/src/pretix/control/forms/__init__.py b/src/pretix/control/forms/__init__.py index 107aee5208..3786b35326 100644 --- a/src/pretix/control/forms/__init__.py +++ b/src/pretix/control/forms/__init__.py @@ -178,8 +178,7 @@ class CachedFileInput(forms.ClearableFileInput): return ctx -class SizeFileField(forms.FileField): - +class SizeValidationMixin: def __init__(self, *args, **kwargs): self.max_size = kwargs.pop("max_size", None) super().__init__(*args, **kwargs) @@ -196,13 +195,12 @@ class SizeFileField(forms.FileField): data = super().clean(*args, **kwargs) if isinstance(data, UploadedFile) and self.max_size and data.size > self.max_size: raise forms.ValidationError(_("Please do not upload files larger than {size}!").format( - size=SizeFileField._sizeof_fmt(self.max_size) + size=SizeValidationMixin._sizeof_fmt(self.max_size) )) return data -class ExtFileField(SizeFileField): - widget = ClearableBasenameFileInput +class ExtValidationMixin: def __init__(self, *args, **kwargs): ext_whitelist = kwargs.pop("ext_whitelist") @@ -220,6 +218,14 @@ class ExtFileField(SizeFileField): return data +class SizeFileField(SizeValidationMixin, forms.FileField): + pass + + +class ExtFileField(ExtValidationMixin, SizeFileField): + widget = ClearableBasenameFileInput + + class CachedFileField(ExtFileField): widget = CachedFileInput diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 7905d62a8f..4a328600ce 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -153,6 +153,7 @@ class QuestionForm(I18nModelForm): 'valid_datetime_max', 'valid_date_min', 'valid_date_max', + 'valid_file_portrait', ] widgets = { 'valid_datetime_min': SplitDateTimePickerWidget(), diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index f9c5660829..2c1023677e 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -33,6 +33,7 @@ + diff --git a/src/pretix/control/templates/pretixcontrol/items/question_edit.html b/src/pretix/control/templates/pretixcontrol/items/question_edit.html index 827d420757..200c078605 100644 --- a/src/pretix/control/templates/pretixcontrol/items/question_edit.html +++ b/src/pretix/control/templates/pretixcontrol/items/question_edit.html @@ -44,6 +44,9 @@ {% bootstrap_field form.valid_datetime_min layout="control" %} {% bootstrap_field form.valid_datetime_max layout="control" %} +
+ {% bootstrap_field form.valid_file_portrait layout="control" %} +

{% trans "Answer options" %}