forked from CGM_Public/pretix_original
Add a file upload type to questions (#534)
* Initial stuff * More features
This commit is contained in:
@@ -22,6 +22,7 @@ type string The expected ty
|
|||||||
* ``B`` – boolean
|
* ``B`` – boolean
|
||||||
* ``C`` – choice from a list
|
* ``C`` – choice from a list
|
||||||
* ``M`` – multiple choice from a list
|
* ``M`` – multiple choice from a list
|
||||||
|
* ``F`` – file upload
|
||||||
required boolean If ``True``, the question needs to be filled out.
|
required boolean If ``True``, the question needs to be filled out.
|
||||||
position integer An integer, used for sorting
|
position integer An integer, used for sorting
|
||||||
items list of integers List of item IDs this question is assigned to.
|
items list of integers List of item IDs this question is assigned to.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from .answers import * # noqa
|
||||||
from .invoices import * # noqa
|
from .invoices import * # noqa
|
||||||
from .json import * # noqa
|
from .json import * # noqa
|
||||||
from .mail import * # noqa
|
from .mail import * # noqa
|
||||||
|
|||||||
61
src/pretix/base/exporters/answers.py
Normal file
61
src/pretix/base/exporters/answers.py
Normal 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
|
||||||
27
src/pretix/base/migrations/0064_auto_20170703_0912.py
Normal file
27
src/pretix/base/migrations/0064_auto_20170703_0912.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -16,7 +16,7 @@ def cachedfile_name(instance, filename: str) -> str:
|
|||||||
|
|
||||||
class CachedFile(models.Model):
|
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)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
|
||||||
expires = models.DateTimeField(null=True, blank=True)
|
expires = models.DateTimeField(null=True, blank=True)
|
||||||
|
|||||||
@@ -446,6 +446,7 @@ class Question(LoggedModel):
|
|||||||
* a multi-line string (``TYPE_TEXT``)
|
* a multi-line string (``TYPE_TEXT``)
|
||||||
* a boolean (``TYPE_BOOLEAN``)
|
* a boolean (``TYPE_BOOLEAN``)
|
||||||
* a multiple choice option (``TYPE_CHOICE`` and ``TYPE_CHOICE_MULTIPLE``)
|
* a multiple choice option (``TYPE_CHOICE`` and ``TYPE_CHOICE_MULTIPLE``)
|
||||||
|
* a file upload (``TYPE_FILE``))
|
||||||
|
|
||||||
:param event: The event this question belongs to
|
:param event: The event this question belongs to
|
||||||
:type event: Event
|
:type event: Event
|
||||||
@@ -463,13 +464,15 @@ class Question(LoggedModel):
|
|||||||
TYPE_BOOLEAN = "B"
|
TYPE_BOOLEAN = "B"
|
||||||
TYPE_CHOICE = "C"
|
TYPE_CHOICE = "C"
|
||||||
TYPE_CHOICE_MULTIPLE = "M"
|
TYPE_CHOICE_MULTIPLE = "M"
|
||||||
|
TYPE_FILE = "F"
|
||||||
TYPE_CHOICES = (
|
TYPE_CHOICES = (
|
||||||
(TYPE_NUMBER, _("Number")),
|
(TYPE_NUMBER, _("Number")),
|
||||||
(TYPE_STRING, _("Text (one line)")),
|
(TYPE_STRING, _("Text (one line)")),
|
||||||
(TYPE_TEXT, _("Multiline text")),
|
(TYPE_TEXT, _("Multiline text")),
|
||||||
(TYPE_BOOLEAN, _("Yes/No")),
|
(TYPE_BOOLEAN, _("Yes/No")),
|
||||||
(TYPE_CHOICE, _("Choose one from a list")),
|
(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(
|
event = models.ForeignKey(
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ from django.db.models import F, Sum
|
|||||||
from django.db.models.signals import post_delete
|
from django.db.models.signals import post_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.crypto import get_random_string
|
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.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.timezone import now
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
@@ -340,6 +343,17 @@ class Order(LoggedModel):
|
|||||||
return True
|
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):
|
class QuestionAnswer(models.Model):
|
||||||
"""
|
"""
|
||||||
The answer to a Question, connected to an OrderPosition or CartPosition.
|
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
|
QuestionOption, related_name='answers', blank=True
|
||||||
)
|
)
|
||||||
answer = models.TextField()
|
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):
|
def __str__(self):
|
||||||
if self.question.type == Question.TYPE_BOOLEAN and self.answer == "True":
|
if self.question.type == Question.TYPE_BOOLEAN and self.answer == "True":
|
||||||
return str(_("Yes"))
|
return str(_("Yes"))
|
||||||
elif self.question.type == Question.TYPE_BOOLEAN and self.answer == "False":
|
elif self.question.type == Question.TYPE_BOOLEAN and self.answer == "False":
|
||||||
return str(_("No"))
|
return str(_("No"))
|
||||||
|
elif self.question.type == Question.TYPE_FILE:
|
||||||
|
return str(_("<file>"))
|
||||||
else:
|
else:
|
||||||
return self.answer
|
return self.answer
|
||||||
|
|
||||||
@@ -684,3 +725,17 @@ def cachedticket_delete(sender, instance, **kwargs):
|
|||||||
if instance.file:
|
if instance.file:
|
||||||
# Pass false so FileField doesn't save the model.
|
# Pass false so FileField doesn't save the model.
|
||||||
instance.file.delete(False)
|
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)
|
||||||
|
|||||||
@@ -198,8 +198,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% for q in line.questions %}
|
{% for q in line.questions %}
|
||||||
<dt>{{ q.question }}</dt>
|
<dt>{{ q.question }}</dt>
|
||||||
<dd>{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %}
|
<dd>
|
||||||
<em>{% trans "not answered" %}</em>{% endif %}</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 %}
|
{% endfor %}
|
||||||
{% for q in line.additional_fields %}
|
{% for q in line.additional_fields %}
|
||||||
<dt>{{ q.question }}</dt>
|
<dt>{{ q.question }}</dt>
|
||||||
|
|||||||
@@ -414,7 +414,14 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
|
|||||||
i = self.request.GET.get("item", "")
|
i = self.request.GET.get("item", "")
|
||||||
qs = qs.filter(orderposition__item_id__in=(i,))
|
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')\
|
qs = qs.order_by('options').values('options', 'options__answer')\
|
||||||
.annotate(count=Count('id')).order_by('-count')
|
.annotate(count=Count('id')).order_by('-count')
|
||||||
for a in qs:
|
for a in qs:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
|
||||||
@@ -10,8 +11,9 @@ from django.utils.timezone import now
|
|||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from pretix.base.models import ItemVariation, Question
|
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.base.templatetags.rich_text import rich_text
|
||||||
|
from pretix.multidomain.urlreverse import eventreverse
|
||||||
from pretix.presale.signals import contact_form_fields, question_form_fields
|
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.'))
|
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):
|
class QuestionsForm(forms.Form):
|
||||||
"""
|
"""
|
||||||
This form class is responsible for asking order-related questions. This includes
|
This form class is responsible for asking order-related questions. This includes
|
||||||
@@ -169,6 +207,12 @@ class QuestionsForm(forms.Form):
|
|||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple,
|
||||||
initial=initial.options.all() if initial else None,
|
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
|
field.question = q
|
||||||
if answers:
|
if answers:
|
||||||
# Cache the answer object for later use
|
# Cache the answer object for later use
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>{% trans "Checkout" %}</h2>
|
<h2>{% trans "Checkout" %}</h2>
|
||||||
<p>{% trans "Before we continue, we need you to answer some questions." %}</p>
|
<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 %}
|
{% csrf_token %}
|
||||||
<div class="panel-group" id="questions_group">
|
<div class="panel-group" id="questions_group">
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
|
|||||||
@@ -27,7 +27,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% for q in line.questions %}
|
{% for q in line.questions %}
|
||||||
<dt>{{ q.question }}</dt>
|
<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 %}
|
{% endfor %}
|
||||||
{% for q in line.additional_answers %}
|
{% for q in line.additional_answers %}
|
||||||
<dt>{{ q.question }}</dt>
|
<dt>{{ q.question }}</dt>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
Modify order: {{ code }}
|
Modify order: {{ code }}
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</h2>
|
</h2>
|
||||||
<form class="form-horizontal" method="post">
|
<form class="form-horizontal" method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="panel-group" id="questions_accordion">
|
<div class="panel-group" id="questions_accordion">
|
||||||
{% if event.settings.invoice_address_asked %}
|
{% if event.settings.invoice_address_asked %}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ event_patterns = [
|
|||||||
url(r'^cart/add$', pretix.presale.views.cart.CartAdd.as_view(), name='event.cart.add'),
|
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/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/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'^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'^checkout/start$', pretix.presale.views.checkout.CheckoutView.as_view(), name='event.checkout.start'),
|
||||||
url(r'^redeem/?$', pretix.presale.views.cart.RedeemView.as_view(),
|
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',
|
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/pay/change',
|
||||||
pretix.presale.views.order.OrderPayChangeMethod.as_view(),
|
pretix.presale.views.order.OrderPayChangeMethod.as_view(),
|
||||||
name='event.order.pay.change'),
|
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>[^/]+)$',
|
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/download/(?P<output>[^/]+)$',
|
||||||
pretix.presale.views.order.OrderDownload.as_view(),
|
pretix.presale.views.order.OrderDownload.as_view(),
|
||||||
name='event.order.download.combined'),
|
name='event.order.download.combined'),
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from django.http import JsonResponse
|
from django.http import FileResponse, Http404, JsonResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.views.generic import TemplateView, View
|
from django.views.generic import TemplateView, View
|
||||||
|
|
||||||
from pretix.base.decimal import round_decimal
|
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 (
|
from pretix.base.services.cart import (
|
||||||
CartError, add_items_to_cart, clear_cart, remove_cart_position,
|
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 redirect(eventreverse(request.event, 'presale:event.index'))
|
||||||
|
|
||||||
return super().dispatch(request, *args, **kwargs)
|
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
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from django.http import FileResponse, Http404, JsonResponse
|
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.functional import cached_property
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.views.generic import TemplateView, View
|
from django.views.generic import TemplateView, View
|
||||||
|
|
||||||
from pretix.base.models import CachedTicket, Invoice, Order, OrderPosition
|
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.payment import PaymentException
|
||||||
from pretix.base.services.invoices import (
|
from pretix.base.services.invoices import (
|
||||||
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
||||||
@@ -29,13 +32,13 @@ from pretix.presale.views.questions import QuestionsViewMixin
|
|||||||
class OrderDetailMixin:
|
class OrderDetailMixin:
|
||||||
@cached_property
|
@cached_property
|
||||||
def order(self):
|
def order(self):
|
||||||
try:
|
order = self.request.event.orders.filter(code=self.kwargs['order']).select_related('event').first()
|
||||||
order = self.request.event.orders.get(code=self.kwargs['order'])
|
if order:
|
||||||
if order.secret.lower() == self.kwargs['secret'].lower():
|
if order.secret.lower() == self.kwargs['secret'].lower():
|
||||||
return order
|
return order
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
except Order.DoesNotExist:
|
else:
|
||||||
# Do a comparison as well to harden timing attacks
|
# Do a comparison as well to harden timing attacks
|
||||||
if 'abcdefghijklmnopq'.lower() == self.kwargs['secret'].lower():
|
if 'abcdefghijklmnopq'.lower() == self.kwargs['secret'].lower():
|
||||||
return None
|
return None
|
||||||
@@ -89,7 +92,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TemplateView):
|
|||||||
ctx['download_buttons'] = self.download_buttons
|
ctx['download_buttons'] = self.download_buttons
|
||||||
ctx['cart'] = self.get_cart(
|
ctx['cart'] = self.get_cart(
|
||||||
answers=True, downloads=ctx['can_download'],
|
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
|
payment_fee=self.order.payment_fee, payment_fee_tax_rate=self.order.payment_fee_tax_rate
|
||||||
)
|
)
|
||||||
ctx['invoices'] = list(self.order.invoices.all())
|
ctx['invoices'] = list(self.order.invoices.all())
|
||||||
@@ -488,6 +491,23 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View):
|
|||||||
return _('The order has been canceled.')
|
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):
|
class OrderDownload(EventViewMixin, OrderDetailMixin, View):
|
||||||
|
|
||||||
def get_self_url(self):
|
def get_self_url(self):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
from pretix.base.models import CartPosition, OrderPosition, QuestionAnswer
|
from pretix.base.models import CartPosition, OrderPosition, QuestionAnswer
|
||||||
@@ -39,7 +40,8 @@ class QuestionsViewMixin:
|
|||||||
prefix=cr.id,
|
prefix=cr.id,
|
||||||
cartpos=cartpos,
|
cartpos=cartpos,
|
||||||
orderpos=orderpos,
|
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
|
form.pos = cartpos or orderpos
|
||||||
if len(form.fields) > 0:
|
if len(form.fields) > 0:
|
||||||
formlist.append(form)
|
formlist.append(form)
|
||||||
@@ -78,7 +80,9 @@ class QuestionsViewMixin:
|
|||||||
if hasattr(field, 'answer'):
|
if hasattr(field, 'answer'):
|
||||||
# We already have a cached answer object, so we don't
|
# We already have a cached answer object, so we don't
|
||||||
# have to create a new one
|
# 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()
|
field.answer.delete()
|
||||||
else:
|
else:
|
||||||
self._save_to_answer(field, field.answer, v)
|
self._save_to_answer(field, field.answer, v)
|
||||||
@@ -119,5 +123,9 @@ class QuestionsViewMixin:
|
|||||||
answer.options.clear()
|
answer.options.clear()
|
||||||
answer.options.add(value)
|
answer.options.add(value)
|
||||||
answer.answer = value.answer
|
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:
|
else:
|
||||||
answer.answer = value
|
answer.answer = value
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
|
||||||
@@ -110,6 +112,43 @@ class CheckoutTestCase(TestCase):
|
|||||||
self.assertEqual(cr1.answers.filter(question=q2).count(), 1)
|
self.assertEqual(cr1.answers.filter(question=q2).count(), 1)
|
||||||
self.assertFalse(cr2.answers.filter(question=q2).exists())
|
self.assertFalse(cr2.answers.filter(question=q2).exists())
|
||||||
|
|
||||||
|
def test_question_file_upload(self):
|
||||||
|
q1 = Question.objects.create(
|
||||||
|
event=self.event, question='Student ID', type=Question.TYPE_FILE,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
self.ticket.questions.add(q1)
|
||||||
|
cr1 = CartPosition.objects.create(
|
||||||
|
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||||
|
price=23, expires=now() + timedelta(minutes=10)
|
||||||
|
)
|
||||||
|
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
|
||||||
|
doc = BeautifulSoup(response.rendered_content, "lxml")
|
||||||
|
|
||||||
|
self.assertEqual(len(doc.select('input[name=%s-question_%s]' % (cr1.id, q1.id))), 1)
|
||||||
|
|
||||||
|
f = SimpleUploadedFile("testfile.txt", b"file_content")
|
||||||
|
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
|
||||||
|
'%s-question_%s' % (cr1.id, q1.id): f,
|
||||||
|
'email': 'admin@localhost'
|
||||||
|
}, follow=True)
|
||||||
|
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
|
||||||
|
target_status_code=200)
|
||||||
|
|
||||||
|
cr1 = CartPosition.objects.get(id=cr1.id)
|
||||||
|
a = cr1.answers.get(question=q1)
|
||||||
|
assert a.file
|
||||||
|
assert a.file.read() == b"file_content"
|
||||||
|
assert os.path.exists(os.path.join(settings.MEDIA_ROOT, a.file.name))
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
|
||||||
|
'%s-question_%s-clear' % (cr1.id, q1.id): 'on',
|
||||||
|
'email': 'admin@localhost'
|
||||||
|
}, follow=True)
|
||||||
|
assert not cr1.answers.exists()
|
||||||
|
assert not os.path.exists(os.path.join(settings.MEDIA_ROOT, a.file.name))
|
||||||
|
|
||||||
def test_attendee_email_required(self):
|
def test_attendee_email_required(self):
|
||||||
self.event.settings.set('attendee_emails_asked', True)
|
self.event.settings.set('attendee_emails_asked', True)
|
||||||
self.event.settings.set('attendee_emails_required', True)
|
self.event.settings.set('attendee_emails_required', True)
|
||||||
|
|||||||
Reference in New Issue
Block a user