Add a file upload type to questions (#534)

* Initial stuff

* More features
This commit is contained in:
Raphael Michel
2017-07-03 14:22:31 +02:00
committed by GitHub
parent 678d510e29
commit 0db5d062be
18 changed files with 334 additions and 20 deletions

View File

@@ -1,3 +1,4 @@
from .answers import * # noqa
from .invoices import * # noqa
from .json import * # noqa
from .mail import * # noqa

View File

@@ -0,0 +1,61 @@
import os
import tempfile
from collections import OrderedDict
from zipfile import ZipFile
from django import forms
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import QuestionAnswer
from ..exporter import BaseExporter
from ..signals import register_data_exporters
class AnswerFilesExporter(BaseExporter):
identifier = 'answerfiles'
verbose_name = _('Answers to file upload questions')
@property
def export_form_fields(self):
return OrderedDict(
[
('questions',
forms.ModelMultipleChoiceField(
queryset=self.event.questions.filter(type='F'),
label=_('Questions'),
widget=forms.CheckboxSelectMultiple,
required=False
)),
]
)
def render(self, form_data: dict):
qs = QuestionAnswer.objects.filter(
orderposition__order__event=self.event,
).select_related('orderposition', 'orderposition__order', 'question')
if form_data.get('questions'):
qs = qs.filter(question__in=form_data['questions'])
with tempfile.TemporaryDirectory() as d:
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for i in qs:
if i.file:
i.file.open('r')
fname = '{}-{}-{}-q{}-{}'.format(
self.event.slug.upper(),
i.orderposition.order.code,
i.orderposition.positionid,
i.question.pk,
os.path.basename(i.file.name).split('.', 1)[1]
)
zipf.writestr(fname, i.file.read())
i.file.close()
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return 'answers.zip', 'application/zip', zipf.read()
@receiver(register_data_exporters, dispatch_uid="exporter_answers")
def register_anwers_export(sender, **kwargs):
return AnswerFilesExporter

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-07-03 09:12
from __future__ import unicode_literals
from django.db import migrations, models
import pretix.base.models.orders
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0063_auto_20170702_1711'),
]
operations = [
migrations.AddField(
model_name='questionanswer',
name='file',
field=models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.answerfile_name),
),
migrations.AlterField(
model_name='question',
name='type',
field=models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No'), ('C', 'Choose one from a list'), ('M', 'Choose multiple from a list'), ('F', 'File upload')], max_length=5, verbose_name='Question type'),
),
]

View File

@@ -16,7 +16,7 @@ def cachedfile_name(instance, filename: str) -> str:
class CachedFile(models.Model):
"""
A cached file (e.g. pre-generated ticket PDF)
An uploaded file, with an optional expiry date.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
expires = models.DateTimeField(null=True, blank=True)

View File

@@ -446,6 +446,7 @@ class Question(LoggedModel):
* a multi-line string (``TYPE_TEXT``)
* a boolean (``TYPE_BOOLEAN``)
* a multiple choice option (``TYPE_CHOICE`` and ``TYPE_CHOICE_MULTIPLE``)
* a file upload (``TYPE_FILE``))
:param event: The event this question belongs to
:type event: Event
@@ -463,13 +464,15 @@ class Question(LoggedModel):
TYPE_BOOLEAN = "B"
TYPE_CHOICE = "C"
TYPE_CHOICE_MULTIPLE = "M"
TYPE_FILE = "F"
TYPE_CHOICES = (
(TYPE_NUMBER, _("Number")),
(TYPE_STRING, _("Text (one line)")),
(TYPE_TEXT, _("Multiline text")),
(TYPE_BOOLEAN, _("Yes/No")),
(TYPE_CHOICE, _("Choose one from a list")),
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list"))
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list")),
(TYPE_FILE, _("File upload")),
)
event = models.ForeignKey(

View File

@@ -12,7 +12,10 @@ from django.db.models import F, Sum
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.utils.crypto import get_random_string
from django.utils.encoding import escape_uri_path
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
@@ -340,6 +343,17 @@ class Order(LoggedModel):
return True
def answerfile_name(instance, filename: str) -> str:
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
event = (instance.cartposition if instance.cartposition else instance.orderposition.order).event
return 'cachedfiles/answers/{org}/{ev}/{secret}.{filename}'.format(
org=event.organizer.slug,
ev=event.slug,
secret=secret,
filename=escape_uri_path(filename),
)
class QuestionAnswer(models.Model):
"""
The answer to a Question, connected to an OrderPosition or CartPosition.
@@ -370,12 +384,39 @@ class QuestionAnswer(models.Model):
QuestionOption, related_name='answers', blank=True
)
answer = models.TextField()
file = models.FileField(
null=True, blank=True, upload_to=answerfile_name
)
@property
def file_link(self):
from pretix.multidomain.urlreverse import eventreverse
if self.file:
if self.orderposition:
url = eventreverse(self.orderposition.order.event, 'presale:event.order.download.answer', kwargs={
'order': self.orderposition.order.code,
'secret': self.orderposition.order.secret,
'answer': self.pk,
})
else:
url = eventreverse(self.cartposition.event, 'presale:event.cart.download.answer', kwargs={
'answer': self.pk,
})
return mark_safe("<a href='{}'>{}</a>".format(
url,
escape(self.file.name.split('.', 1)[-1])
))
return ""
def __str__(self):
if self.question.type == Question.TYPE_BOOLEAN and self.answer == "True":
return str(_("Yes"))
elif self.question.type == Question.TYPE_BOOLEAN and self.answer == "False":
return str(_("No"))
elif self.question.type == Question.TYPE_FILE:
return str(_("<file>"))
else:
return self.answer
@@ -684,3 +725,17 @@ def cachedticket_delete(sender, instance, **kwargs):
if instance.file:
# Pass false so FileField doesn't save the model.
instance.file.delete(False)
@receiver(post_delete, sender=CachedCombinedTicket)
def cachedcombinedticket_delete(sender, instance, **kwargs):
if instance.file:
# Pass false so FileField doesn't save the model.
instance.file.delete(False)
@receiver(post_delete, sender=QuestionAnswer)
def answer_delete(sender, instance, **kwargs):
if instance.file:
# Pass false so FileField doesn't save the model.
instance.file.delete(False)

View File

@@ -198,8 +198,17 @@
{% endif %}
{% for q in line.questions %}
<dt>{{ q.question }}</dt>
<dd>{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %}
<em>{% trans "not answered" %}</em>{% endif %}</dd>
<dd>
{% if q.answer %}
{% if q.answer.file %}
<span class="fa fa-file"></span> {{ q.answer.file_link }}
{% else %}
{{ q.answer|linebreaksbr }}
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endfor %}
{% for q in line.additional_fields %}
<dt>{{ q.question }}</dt>

View File

@@ -414,7 +414,14 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
i = self.request.GET.get("item", "")
qs = qs.filter(orderposition__item_id__in=(i,))
if self.object.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
if self.object.type == Question.TYPE_FILE:
qs = [
{
'answer': ugettext('File uploaded'),
'count': qs.filter(file__isnull=False).count()
}
]
elif self.object.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
qs = qs.order_by('options').values('options', 'options__answer')\
.annotate(count=Count('id')).order_by('-count')
for a in qs:

View File

@@ -1,3 +1,4 @@
import os
from decimal import Decimal
from itertools import chain
@@ -10,8 +11,9 @@ from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import ItemVariation, Question
from pretix.base.models.orders import InvoiceAddress
from pretix.base.models.orders import InvoiceAddress, OrderPosition
from pretix.base.templatetags.rich_text import rich_text
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.signals import contact_form_fields, question_form_fields
@@ -73,6 +75,42 @@ class InvoiceAddressForm(forms.ModelForm):
raise ValidationError(_('You need to provide either a company name or your name.'))
class UploadedFileWidget(forms.ClearableFileInput):
def __init__(self, *args, **kwargs):
self.position = kwargs.pop('position')
self.event = kwargs.pop('event')
self.answer = kwargs.pop('answer')
super().__init__(*args, **kwargs)
class FakeFile:
def __init__(self, file, position, event, answer):
self.file = file
self.position = position
self.event = event
self.answer = answer
def __str__(self):
return os.path.basename(self.file.name).split('.', 1)[-1]
@property
def url(self):
if isinstance(self.position, OrderPosition):
return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={
'order': self.position.order.code,
'secret': self.position.order.secret,
'answer': self.answer.pk,
})
else:
return eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={
'answer': self.answer.pk,
})
def format_value(self, value):
if self.is_initial(value):
return self.FakeFile(value, self.position, self.event, self.answer)
class QuestionsForm(forms.Form):
"""
This form class is responsible for asking order-related questions. This includes
@@ -169,6 +207,12 @@ class QuestionsForm(forms.Form):
widget=forms.CheckboxSelectMultiple,
initial=initial.options.all() if initial else None,
)
elif q.type == Question.TYPE_FILE:
field = forms.FileField(
label=q.question, required=q.required,
initial=initial.file if initial else None,
widget=UploadedFileWidget(position=pos, event=event, answer=initial)
)
field.question = q
if answers:
# Cache the answer object for later use

View File

@@ -5,7 +5,7 @@
{% block content %}
<h2>{% trans "Checkout" %}</h2>
<p>{% trans "Before we continue, we need you to answer some questions." %}</p>
<form class="form-horizontal" method="post">
<form class="form-horizontal" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="panel-group" id="questions_group">
<div class="panel panel-default">

View File

@@ -27,7 +27,17 @@
{% endif %}
{% for q in line.questions %}
<dt>{{ q.question }}</dt>
<dd>{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}</dd>
<dd>
{% if q.answer %}
{% if q.answer.file %}
<span class="fa fa-file"></span> {{ q.answer.file_link }}
{% else %}
{{ q.answer|linebreaksbr }}
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endfor %}
{% for q in line.additional_answers %}
<dt>{{ q.question }}</dt>

View File

@@ -8,7 +8,7 @@
Modify order: {{ code }}
{% endblocktrans %}
</h2>
<form class="form-horizontal" method="post">
<form class="form-horizontal" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="panel-group" id="questions_accordion">
{% if event.settings.invoice_address_asked %}

View File

@@ -16,6 +16,9 @@ event_patterns = [
url(r'^cart/add$', pretix.presale.views.cart.CartAdd.as_view(), name='event.cart.add'),
url(r'^cart/remove$', pretix.presale.views.cart.CartRemove.as_view(), name='event.cart.remove'),
url(r'^cart/clear$', pretix.presale.views.cart.CartClear.as_view(), name='event.cart.clear'),
url(r'^cart/answer/(?P<answer>[^/]+)/$',
pretix.presale.views.cart.AnswerDownload.as_view(),
name='event.cart.download.answer'),
url(r'^waitinglist', pretix.presale.views.waiting.WaitingView.as_view(), name='event.waitinglist'),
url(r'^checkout/start$', pretix.presale.views.checkout.CheckoutView.as_view(), name='event.checkout.start'),
url(r'^redeem/?$', pretix.presale.views.cart.RedeemView.as_view(),
@@ -48,6 +51,9 @@ event_patterns = [
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/pay/change',
pretix.presale.views.order.OrderPayChangeMethod.as_view(),
name='event.order.pay.change'),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/answer/(?P<answer>[^/]+)/$',
pretix.presale.views.order.AnswerDownload.as_view(),
name='event.order.download.answer'),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/download/(?P<output>[^/]+)$',
pretix.presale.views.order.OrderDownload.as_view(),
name='event.order.download.combined'),

View File

@@ -1,14 +1,17 @@
import mimetypes
import os
from django.contrib import messages
from django.db.models import Count, Q
from django.http import JsonResponse
from django.shortcuts import redirect
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.utils import translation
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from django.views.generic import TemplateView, View
from pretix.base.decimal import round_decimal
from pretix.base.models import CartPosition, Quota, Voucher
from pretix.base.models import CartPosition, QuestionAnswer, Quota, Voucher
from pretix.base.services.cart import (
CartError, add_items_to_cart, clear_cart, remove_cart_position,
)
@@ -266,3 +269,23 @@ class RedeemView(EventViewMixin, TemplateView):
return redirect(eventreverse(request.event, 'presale:event.index'))
return super().dispatch(request, *args, **kwargs)
class AnswerDownload(EventViewMixin, View):
def get(self, request, *args, **kwargs):
answid = kwargs.get('answer')
answer = get_object_or_404(
QuestionAnswer,
cartposition__cart_id=self.request.session.session_key,
id=answid
)
if not answer.file:
return Http404()
ftype, _ = mimetypes.guess_type(answer.file.name)
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-cart-{}"'.format(
self.request.event.slug.upper(),
os.path.basename(answer.file.name).split('.', 1)[1]
)
return resp

View File

@@ -1,15 +1,18 @@
import mimetypes
import os
from django.contrib import messages
from django.db import transaction
from django.db.models import Sum
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import redirect, render
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.views.generic import TemplateView, View
from pretix.base.models import CachedTicket, Invoice, Order, OrderPosition
from pretix.base.models.orders import InvoiceAddress
from pretix.base.models.orders import InvoiceAddress, QuestionAnswer
from pretix.base.payment import PaymentException
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
@@ -29,13 +32,13 @@ from pretix.presale.views.questions import QuestionsViewMixin
class OrderDetailMixin:
@cached_property
def order(self):
try:
order = self.request.event.orders.get(code=self.kwargs['order'])
order = self.request.event.orders.filter(code=self.kwargs['order']).select_related('event').first()
if order:
if order.secret.lower() == self.kwargs['secret'].lower():
return order
else:
return None
except Order.DoesNotExist:
else:
# Do a comparison as well to harden timing attacks
if 'abcdefghijklmnopq'.lower() == self.kwargs['secret'].lower():
return None
@@ -89,7 +92,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TemplateView):
ctx['download_buttons'] = self.download_buttons
ctx['cart'] = self.get_cart(
answers=True, downloads=ctx['can_download'],
queryset=OrderPosition.objects.filter(order=self.order),
queryset=self.order.positions.all(),
payment_fee=self.order.payment_fee, payment_fee_tax_rate=self.order.payment_fee_tax_rate
)
ctx['invoices'] = list(self.order.invoices.all())
@@ -488,6 +491,23 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View):
return _('The order has been canceled.')
class AnswerDownload(EventViewMixin, OrderDetailMixin, View):
def get(self, request, *args, **kwargs):
answid = kwargs.get('answer')
answer = get_object_or_404(QuestionAnswer, orderposition__order=self.order, id=answid)
if not answer.file:
return Http404()
ftype, _ = mimetypes.guess_type(answer.file.name)
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format(
self.request.event.slug.upper(), self.order.code,
answer.orderposition.positionid,
os.path.basename(answer.file.name).split('.', 1)[1]
)
return resp
class OrderDownload(EventViewMixin, OrderDetailMixin, View):
def get_self_url(self):

View File

@@ -2,6 +2,7 @@ import json
from collections import defaultdict
from django import forms
from django.core.files.uploadedfile import UploadedFile
from django.utils.functional import cached_property
from pretix.base.models import CartPosition, OrderPosition, QuestionAnswer
@@ -39,7 +40,8 @@ class QuestionsViewMixin:
prefix=cr.id,
cartpos=cartpos,
orderpos=orderpos,
data=(self.request.POST if self.request.method == 'POST' else None))
data=(self.request.POST if self.request.method == 'POST' else None),
files=(self.request.FILES if self.request.method == 'POST' else None))
form.pos = cartpos or orderpos
if len(form.fields) > 0:
formlist.append(form)
@@ -78,7 +80,9 @@ class QuestionsViewMixin:
if hasattr(field, 'answer'):
# We already have a cached answer object, so we don't
# have to create a new one
if v == '':
if v == '' or v is None or (isinstance(field, forms.FileField) and v is False):
if field.answer.file:
field.answer.file.delete()
field.answer.delete()
else:
self._save_to_answer(field, field.answer, v)
@@ -119,5 +123,9 @@ class QuestionsViewMixin:
answer.options.clear()
answer.options.add(value)
answer.answer = value.answer
elif isinstance(field, forms.FileField):
if isinstance(value, UploadedFile):
answer.file.save(value.name, value)
answer.answer = 'file://' + value.name
else:
answer.answer = value