Add Question.valid_file_portrait as well as crop editor for images

This commit is contained in:
Raphael Michel
2021-04-19 15:39:38 +02:00
parent 638b856f42
commit 24bccf8b9c
24 changed files with 4239 additions and 21 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,6 +721,14 @@ class BaseQuestionsForm(forms.Form):
initial=initial.options.all() if initial else None,
)
elif q.type == Question.TYPE_FILE:
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,

View File

@@ -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),
),
]

View File

@@ -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')

View File

@@ -0,0 +1,19 @@
{% load i18n %}
{% if widget.is_initial %}<div class="photo-initial">{{ widget.initial_text }}: <a href="{{ widget.value.url }}">{{ widget.value }}</a>{% if not widget.required %}
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}">
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}<br></div>
<span class="photo-input">{{ widget.input_text }}:{% else %}<span class="photo-input">{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
</span>
<div class="photo-buttons hidden">
<button class="btn btn-primary" type="button" data-action="upload">
<span class="fa fa-upload"></span> {% trans "Upload photo" %}
</button>
</div>
<div class="photo-camera hidden">
<video></video>
</div>
<div class="photo-preview hidden">
<img>
</div>
<input type="hidden" name="{{ widget.name }}_cropdata" value="">

View File

@@ -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

View File

@@ -153,6 +153,7 @@ class QuestionForm(I18nModelForm):
'valid_datetime_max',
'valid_date_min',
'valid_date_max',
'valid_file_portrait',
]
widgets = {
'valid_datetime_min': SplitDateTimePickerWidget(),

View File

@@ -33,6 +33,7 @@
<script type="text/javascript" src="{% static "charts/raphael-min.js" %}"></script>
<script type="text/javascript" src="{% static "charts/morris.js" %}"></script>
<script type="text/javascript" src="{% static "clipboard/clipboard.js" %}"></script>
<script type="text/javascript" src="{% static "cropper/cropper.js" %}"></script>
<script type="text/javascript" src="{% static "rrule/rrule.js" %}"></script>
<script type="text/javascript" src="{% static "pretixpresale/js/ui/questions.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/jquery.qrcode.min.js" %}"></script>

View File

@@ -44,6 +44,9 @@
{% bootstrap_field form.valid_datetime_min layout="control" %}
{% bootstrap_field form.valid_datetime_max layout="control" %}
</div>
<div id="valid-file">
{% bootstrap_field form.valid_file_portrait layout="control" %}
</div>
<div id="answer-options">
<h3>{% trans "Answer options" %}</h3>
<noscript>

View File

@@ -102,6 +102,13 @@
<a href="{{ q.answer.frontend_file_url }}?token={% answer_token request q.answer %}">
{{ q.answer.file_name }}
</a>
{% if q.answer.is_image %}
<br>
<a href="{{ q.answer.frontend_file_url }}?token={% answer_token request q.answer %}" data-lightbox="order"
class="answer-thumb">
<img src="{{ q.answer.frontend_file_url }}?token={% answer_token request q.answer %}">
</a>
{% endif %}
{% elif q.type == "M" %}
{{ q.answer|rich_text_snippet }}
{% else %}

View File

@@ -8,6 +8,7 @@
<script type="text/javascript" src="{% static "bootstrap/js/bootstrap.js" %}"></script>
<script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
<script type="text/javascript" src="{% static "slider/bootstrap-slider.js" %}"></script>
<script type="text/javascript" src="{% static "cropper/cropper.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/jquery.qrcode.min.js" %}"></script>
<script type="text/javascript" src="{% static "pretixpresale/js/widget/floatformat.js" %}"></script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,304 @@
/*!
* Cropper.js v1.5.11
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2021-02-17T11:53:21.992Z
*/
.cropper-container {
direction: ltr;
font-size: 0;
line-height: 0;
position: relative;
-ms-touch-action: none;
touch-action: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.cropper-container img {
display: block;
height: 100%;
image-orientation: 0deg;
max-height: none !important;
max-width: none !important;
min-height: 0 !important;
min-width: 0 !important;
width: 100%;
}
.cropper-wrap-box,
.cropper-canvas,
.cropper-drag-box,
.cropper-crop-box,
.cropper-modal {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
}
.cropper-wrap-box,
.cropper-canvas {
overflow: hidden;
}
.cropper-drag-box {
background-color: #fff;
opacity: 0;
}
.cropper-modal {
background-color: #000;
opacity: 0.5;
}
.cropper-view-box {
display: block;
height: 100%;
outline: 1px solid #39f;
outline-color: rgba(51, 153, 255, 0.75);
overflow: hidden;
width: 100%;
}
.cropper-dashed {
border: 0 dashed #eee;
display: block;
opacity: 0.5;
position: absolute;
}
.cropper-dashed.dashed-h {
border-bottom-width: 1px;
border-top-width: 1px;
height: calc(100% / 3);
left: 0;
top: calc(100% / 3);
width: 100%;
}
.cropper-dashed.dashed-v {
border-left-width: 1px;
border-right-width: 1px;
height: 100%;
left: calc(100% / 3);
top: 0;
width: calc(100% / 3);
}
.cropper-center {
display: block;
height: 0;
left: 50%;
opacity: 0.75;
position: absolute;
top: 50%;
width: 0;
}
.cropper-center::before,
.cropper-center::after {
background-color: #eee;
content: ' ';
display: block;
position: absolute;
}
.cropper-center::before {
height: 1px;
left: -3px;
top: 0;
width: 7px;
}
.cropper-center::after {
height: 7px;
left: 0;
top: -3px;
width: 1px;
}
.cropper-face,
.cropper-line,
.cropper-point {
display: block;
height: 100%;
opacity: 0.1;
position: absolute;
width: 100%;
}
.cropper-face {
background-color: #fff;
left: 0;
top: 0;
}
.cropper-line {
background-color: #39f;
}
.cropper-line.line-e {
cursor: ew-resize;
right: -3px;
top: 0;
width: 5px;
}
.cropper-line.line-n {
cursor: ns-resize;
height: 5px;
left: 0;
top: -3px;
}
.cropper-line.line-w {
cursor: ew-resize;
left: -3px;
top: 0;
width: 5px;
}
.cropper-line.line-s {
bottom: -3px;
cursor: ns-resize;
height: 5px;
left: 0;
}
.cropper-point {
background-color: #39f;
height: 5px;
opacity: 0.75;
width: 5px;
}
.cropper-point.point-e {
cursor: ew-resize;
margin-top: -3px;
right: -3px;
top: 50%;
}
.cropper-point.point-n {
cursor: ns-resize;
left: 50%;
margin-left: -3px;
top: -3px;
}
.cropper-point.point-w {
cursor: ew-resize;
left: -3px;
margin-top: -3px;
top: 50%;
}
.cropper-point.point-s {
bottom: -3px;
cursor: s-resize;
left: 50%;
margin-left: -3px;
}
.cropper-point.point-ne {
cursor: nesw-resize;
right: -3px;
top: -3px;
}
.cropper-point.point-nw {
cursor: nwse-resize;
left: -3px;
top: -3px;
}
.cropper-point.point-sw {
bottom: -3px;
cursor: nesw-resize;
left: -3px;
}
.cropper-point.point-se {
bottom: -3px;
cursor: nwse-resize;
height: 20px;
opacity: 1;
right: -3px;
width: 20px;
}
@media (min-width: 768px) {
.cropper-point.point-se {
height: 15px;
width: 15px;
}
}
@media (min-width: 992px) {
.cropper-point.point-se {
height: 10px;
width: 10px;
}
}
@media (min-width: 1200px) {
.cropper-point.point-se {
height: 5px;
opacity: 0.75;
width: 5px;
}
}
.cropper-point.point-se::before {
background-color: #39f;
bottom: -50%;
content: ' ';
display: block;
height: 200%;
opacity: 0;
position: absolute;
right: -50%;
width: 200%;
}
.cropper-invisible {
opacity: 0;
}
.cropper-bg {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
}
.cropper-hide {
display: block;
height: 0;
position: absolute;
width: 0;
}
.cropper-hidden {
display: none !important;
}
.cropper-move {
cursor: move;
}
.cropper-crop {
cursor: crosshair;
}
.cropper-disabled .cropper-drag-box,
.cropper-disabled .cropper-face,
.cropper-disabled .cropper-line,
.cropper-disabled .cropper-point {
cursor: not-allowed;
}

View File

@@ -567,6 +567,7 @@ var form_handlers = function (el) {
el.find("input[name*=question], select[name*=question]").change(questions_toggle_dependent);
questions_toggle_dependent();
questions_init_photos(el);
};
$(function () {

View File

@@ -105,6 +105,7 @@ $(function () {
$("#valid-date").toggle($("#id_type").val() == "D");
$("#valid-datetime").toggle($("#id_type").val() == "W");
$("#valid-number").toggle($("#id_type").val() == "N");
$("#valid-file").toggle($("#id_type").val() == "F");
show = $("#id_type").val() == "B" && $("#id_required").prop("checked");
$(".alert-required-boolean").toggle(show);

View File

@@ -318,6 +318,21 @@ input[type=number].short {
}
}
.photo-initial {
margin-bottom: 5px;
}
.photo-preview {
max-width: 550px;
width: 100%;
height: auto;
margin-top: 10px;
img {
display: block;
max-width: 100%;
}
}
.propagated-settings-box {
position: relative;

View File

@@ -4,6 +4,7 @@
@import "../../pretixbase/scss/_theme.scss";
@import "../../typeahead/typeahead.scss";
@import "../../charts/morris.scss";
@import "../../cropper/cropper.scss";
@import "../../datetimepicker/_bootstrap-datetimepicker.scss";
@import "_sb-admin-2.scss";
@import "_forms.scss";

View File

@@ -133,6 +133,7 @@ var form_handlers = function (el) {
el.find("input[name*=question], select[name*=question]").change(questions_toggle_dependent);
questions_toggle_dependent();
questions_init_photos(el);
};

View File

@@ -70,6 +70,46 @@ function questions_toggle_dependent(ev) {
$(this).prop("required", false).addClass("required-hidden");
});
}
});
}
function questions_init_photos(el) {
if (!FileReader) {
// No browser support
return
}
el.find("input[data-portrait-photo]").each(function () {
var $inp = $(this)
var $container = $inp.parent().parent()
$container.find(".photo-input").addClass("hidden")
$container.find(".photo-buttons").removeClass("hidden")
$container.find("button[data-action=upload]").click(function () {
$inp.click();
})
var cropper = new Cropper($container.find(".photo-preview img").get(0), {
aspectRatio: 3 / 4,
viewMode: 1,
zoomable: false,
crop(event) {
$container.find("input[type=hidden]").val(JSON.stringify(cropper.getData(true)));
},
});
/* This rule is very important, please don't ignore this */
$inp.on("change", function () {
if (!$inp.get(0).files[0]) return
$container.find("button[data-action=upload]").append("<span class='fa fa-spin fa-cog'></span>")
var fr = new FileReader()
fr.onload = function () {
cropper.replace(fr.result)
$container.find(".photo-preview").removeClass("hidden")
$container.find("button[data-action=upload] .fa-spin").remove()
}
fr.readAsDataURL($inp.get(0).files[0])
})
});
}

View File

@@ -142,3 +142,11 @@
.btn-invisible {
opacity: 0 !important;
}
.answer-thumb img {
width: auto;
height: auto;
max-height: 100px;
max-width: 100%;
margin-top: 5px;
}

View File

@@ -102,6 +102,22 @@ a.btn, button.btn {
}
}
.photo-initial {
margin-bottom: 5px;
}
.photo-preview {
max-width: 550px;
width: 100%;
height: auto;
margin-top: 10px;
img {
display: block;
max-width: 100%;
}
}
@media(max-width: $screen-xs-max) {
.nameparts-form-group {
display: block;

View File

@@ -2,6 +2,7 @@
@import "../../bootstrap/scss/_bootstrap_reduced.scss";
@import "../../pretixbase/scss/_theme.scss";
@import "../../lightbox/css/lightbox.scss";
@import "../../cropper/cropper.scss";
@import "../../datetimepicker/_bootstrap-datetimepicker.scss";
@import "../../slider/_bootstrap-slider.scss";
@import "../../fontawesome/scss/font-awesome.scss";

View File

@@ -1848,6 +1848,7 @@ TEST_QUESTION_RES = {
"valid_date_max": None,
"valid_datetime_min": None,
"valid_datetime_max": None,
"valid_file_portrait": False,
"help_text": {"en": "This is an example question"},
"options": [
{