Fix #190 and #472 -- Change of questions within pretix control

This commit is contained in:
Raphael Michel
2018-01-26 12:43:47 +01:00
parent 083c94403b
commit 1ee6e31538
19 changed files with 732 additions and 546 deletions

View File

@@ -4,7 +4,7 @@ from django.utils.translation import get_language_info
from i18nfield.strings import LazyI18nString
from pretix.base.settings import GlobalSettingsObject
from pretix.control.utils.i18n import (
from pretix.helpers.i18n import (
get_javascript_format_without_seconds, get_moment_locale,
)

View File

@@ -1,14 +1,6 @@
import logging
import os
from decimal import Decimal
from itertools import chain
import dateutil
import pytz
import vat_moss.errors
import vat_moss.id
from django import forms
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.db.models import Count, Prefetch, Q
from django.utils.encoding import force_text
@@ -16,18 +8,13 @@ from django.utils.formats import number_format
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, OrderPosition
from pretix.base.models.tax import EU_COUNTRIES, TAXED_ZERO
from pretix.base.templatetags.rich_text import rich_text
from pretix.control.forms import (
DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget,
from pretix.base.forms.questions import (
BaseInvoiceAddressForm, BaseQuestionsForm,
)
from pretix.control.utils.i18n import get_format_without_seconds
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.signals import contact_form_fields, question_form_fields
logger = logging.getLogger(__name__)
from pretix.base.models import ItemVariation
from pretix.base.models.tax import TAXED_ZERO
from pretix.base.templatetags.rich_text import rich_text
from pretix.presale.signals import contact_form_fields
class ContactForm(forms.Form):
@@ -60,140 +47,12 @@ class ContactForm(forms.Form):
raise ValidationError(_('Please enter the same email address twice.'))
class BusinessBooleanRadio(forms.RadioSelect):
def __init__(self, attrs=None):
choices = (
('individual', _('Individual customer')),
('business', _('Business customer')),
)
super().__init__(attrs, choices)
def format_value(self, value):
try:
return {True: 'business', False: 'individual'}[value]
except KeyError:
return 'individual'
def value_from_datadict(self, data, files, name):
value = data.get(name)
return {
'business': True,
True: True,
'True': True,
'individual': False,
'False': False,
False: False,
}.get(value)
class InvoiceAddressForm(forms.ModelForm):
class InvoiceAddressForm(BaseInvoiceAddressForm):
required_css_class = 'required'
class Meta:
model = InvoiceAddress
fields = ('is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
'internal_reference')
widgets = {
'is_business': BusinessBooleanRadio,
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'name': forms.TextInput(attrs={}),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'internal_reference': forms.TextInput,
}
labels = {
'is_business': ''
}
def __init__(self, *args, **kwargs):
self.event = event = kwargs.pop('event')
self.request = kwargs.pop('request', None)
self.validate_vat_id = kwargs.pop('validate_vat_id')
super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid:
del self.fields['vat_id']
if not event.settings.invoice_address_required:
for k, f in self.fields.items():
f.required = False
f.widget.is_required = False
if 'required' in f.widget.attrs:
del f.widget.attrs['required']
if event.settings.invoice_name_required:
self.fields['name'].required = True
else:
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
self.fields['name'].widget.attrs['data-required-if'] = '#id_is_business_0'
def clean(self):
data = self.cleaned_data
if not data.get('name') and not data.get('company') and self.event.settings.invoice_address_required:
raise ValidationError(_('You need to provide either a company name or your name.'))
if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
if data.get('vat_id')[:2] != str(data.get('country')):
raise ValidationError(_('Your VAT ID does not match the selected country.'))
try:
result = vat_moss.id.validate(data.get('vat_id'))
if result:
country_code, normalized_id, company_name = result
self.instance.vat_id_validated = True
self.instance.vat_id = normalized_id
except vat_moss.errors.InvalidError:
raise ValidationError(_('This VAT ID is not valid. Please re-check your input.'))
except vat_moss.errors.WebServiceUnavailableError:
logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
self.instance.vat_id_validated = False
if self.request:
messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of '
'your country is currently not available. We will therefore '
'need to charge VAT on your invoice. You can get the tax amount '
'back via the VAT reimbursement process.'))
else:
self.instance.vat_id_validated = False
vat_warning = True
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(BaseQuestionsForm):
"""
This form class is responsible for asking order-related questions. This includes
the attendee name for admission tickets, if the corresponding setting is enabled,
@@ -201,140 +60,6 @@ class QuestionsForm(forms.Form):
"""
required_css_class = 'required'
def __init__(self, *args, **kwargs):
"""
Takes two additional keyword arguments:
:param cartpos: The cart position the form should be for
:param event: The event this belongs to
"""
cartpos = self.cartpos = kwargs.pop('cartpos', None)
orderpos = self.orderpos = kwargs.pop('orderpos', None)
pos = cartpos or orderpos
item = pos.item
questions = pos.item.questions_to_ask
event = kwargs.pop('event')
super().__init__(*args, **kwargs)
if item.admission and event.settings.attendee_names_asked:
self.fields['attendee_name'] = forms.CharField(
max_length=255, required=event.settings.attendee_names_required,
label=_('Attendee name'),
initial=(cartpos.attendee_name if cartpos else orderpos.attendee_name),
)
if item.admission and event.settings.attendee_emails_asked:
self.fields['attendee_email'] = forms.EmailField(
required=event.settings.attendee_emails_required,
label=_('Attendee email'),
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email)
)
for q in questions:
# Do we already have an answer? Provide it as the initial value
answers = [a for a in pos.answerlist if a.question_id == q.id]
if answers:
initial = answers[0]
else:
initial = None
tz = pytz.timezone(event.settings.timezone)
if q.type == Question.TYPE_BOOLEAN:
if q.required:
# For some reason, django-bootstrap3 does not set the required attribute
# itself.
widget = forms.CheckboxInput(attrs={'required': 'required'})
else:
widget = forms.CheckboxInput()
if initial:
initialbool = (initial.answer == "True")
else:
initialbool = False
field = forms.BooleanField(
label=q.question, required=q.required,
help_text=q.help_text,
initial=initialbool, widget=widget,
)
elif q.type == Question.TYPE_NUMBER:
field = forms.DecimalField(
label=q.question, required=q.required,
help_text=q.help_text,
initial=initial.answer if initial else None,
min_value=Decimal('0.00'),
)
elif q.type == Question.TYPE_STRING:
field = forms.CharField(
label=q.question, required=q.required,
help_text=q.help_text,
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_TEXT:
field = forms.CharField(
label=q.question, required=q.required,
help_text=q.help_text,
widget=forms.Textarea,
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField(
queryset=q.options,
label=q.question, required=q.required,
help_text=q.help_text,
widget=forms.Select,
empty_label='',
initial=initial.options.first() if initial else None,
)
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
field = forms.ModelMultipleChoiceField(
queryset=q.options,
label=q.question, required=q.required,
help_text=q.help_text,
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,
help_text=q.help_text,
initial=initial.file if initial else None,
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
)
elif q.type == Question.TYPE_DATE:
field = forms.DateField(
label=q.question, required=q.required,
help_text=q.help_text,
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
widget=DatePickerWidget(),
)
elif q.type == Question.TYPE_TIME:
field = forms.TimeField(
label=q.question, required=q.required,
help_text=q.help_text,
initial=dateutil.parser.parse(initial.answer).astimezone(tz).time() if initial and initial.answer else None,
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
)
elif q.type == Question.TYPE_DATETIME:
field = forms.SplitDateTimeField(
label=q.question, required=q.required,
help_text=q.help_text,
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
)
field.question = q
if answers:
# Cache the answer object for later use
field.answer = answers[0]
self.fields['question_%s' % q.id] = field
responses = question_form_fields.send(sender=event, position=pos)
data = pos.meta_info_data
for r, response in sorted(responses, key=lambda r: str(r[0])):
for key, value in response.items():
# We need to be this explicit, since OrderedDict.update does not retain ordering
self.fields[key] = value
value.initial = data.get('question_form_data', {}).get(key)
class AddOnRadioSelect(forms.RadioSelect):
option_template_name = 'pretixpresale/forms/addon_choice_option.html'

View File

@@ -4,7 +4,7 @@ from decimal import Decimal
from django.contrib import messages
from django.db import transaction
from django.db.models import Prefetch, Sum
from django.db.models import Sum
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator
@@ -14,11 +14,9 @@ from django.utils.translation import ugettext_lazy as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.generic import TemplateView, View
from pretix.base.models import (
CachedTicket, Invoice, Order, OrderPosition, Question, QuestionOption,
)
from pretix.base.models import CachedTicket, Invoice, Order, OrderPosition
from pretix.base.models.orders import (
CachedCombinedTicket, InvoiceAddress, OrderFee, QuestionAnswer,
CachedCombinedTicket, OrderFee, QuestionAnswer,
)
from pretix.base.payment import PaymentException
from pretix.base.services.invoices import (
@@ -29,12 +27,12 @@ from pretix.base.services.tickets import (
get_cachedticket_for_order, get_cachedticket_for_position,
)
from pretix.base.signals import allow_ticket_download, register_ticket_outputs
from pretix.base.views.mixins import OrderQuestionsViewMixin
from pretix.helpers.safedownload import check_token
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
from pretix.presale.forms.checkout import InvoiceAddressForm
from pretix.presale.forms.checkout import InvoiceAddressForm, QuestionsForm
from pretix.presale.views import CartMixin, EventViewMixin
from pretix.presale.views.async import AsyncAction
from pretix.presale.views.questions import QuestionsViewMixin
from pretix.presale.views.robots import NoSearchIndexViewMixin
@@ -425,48 +423,11 @@ class OrderInvoiceCreate(EventViewMixin, OrderDetailMixin, View):
@method_decorator(xframe_options_exempt, 'dispatch')
class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, TemplateView):
class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, TemplateView):
form_class = QuestionsForm
invoice_form_class = InvoiceAddressForm
template_name = "pretixpresale/event/order_modify.html"
@cached_property
def _positions_for_questions(self):
return self.positions
@cached_property
def positions(self):
return list(self.order.positions.select_related(
'item', 'variation'
).prefetch_related(
Prefetch('answers',
QuestionAnswer.objects.prefetch_related('options'),
to_attr='answerlist'),
Prefetch('item__questions',
Question.objects.filter(ask_during_checkin=False).prefetch_related(
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
),
to_attr='questions_to_ask')
))
@cached_property
def invoice_address(self):
try:
return self.order.invoice_address
except InvoiceAddress.DoesNotExist:
return InvoiceAddress(order=self.order)
@cached_property
def invoice_form(self):
return InvoiceAddressForm(data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
instance=self.invoice_address, validate_vat_id=False)
def post(self, request, *args, **kwargs):
failed = not self.save() or not self.invoice_form.is_valid()
if failed:
@@ -497,13 +458,6 @@ class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, Template
return redirect(self.get_order_url())
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['order'] = self.order
ctx['formgroups'] = self.formdict.items()
ctx['invoice_form'] = self.invoice_form
return ctx
@method_decorator(xframe_options_exempt, 'dispatch')
class OrderCancel(EventViewMixin, OrderDetailMixin, TemplateView):

View File

@@ -1,29 +1,14 @@
import json
from collections import OrderedDict
from django import forms
from django.core.files.uploadedfile import UploadedFile
from django.db.models import Prefetch
from django.utils.functional import cached_property
from pretix.base.models import (
CartPosition, OrderPosition, Question, QuestionAnswer, QuestionOption,
)
from pretix.base.models import Question, QuestionAnswer, QuestionOption
from pretix.base.views.mixins import BaseQuestionsViewMixin
from pretix.presale.forms.checkout import QuestionsForm
from pretix.presale.views import get_cart
class QuestionsViewMixin:
@staticmethod
def _keyfunc(pos):
# Sort addons after the item they are an addon to
if isinstance(pos, OrderPosition):
i = pos.addon_to.positionid if pos.addon_to else pos.positionid
else:
i = pos.addon_to.pk if pos.addon_to else pos.pk
addon_penalty = 1 if pos.addon_to else 0
return i, addon_penalty, pos.pk
class QuestionsViewMixin(BaseQuestionsViewMixin):
form_class = QuestionsForm
@cached_property
def _positions_for_questions(self):
@@ -48,112 +33,3 @@ class QuestionsViewMixin:
to_attr='questions_to_ask')
)
return sorted(list(cart), key=self._keyfunc)
@cached_property
def forms(self):
"""
A list of forms with one form for each cart position that has questions
the user can answer. All forms have a custom prefix, so that they can all be
submitted at once.
"""
formlist = []
for cr in self._positions_for_questions:
cartpos = cr if isinstance(cr, CartPosition) else None
orderpos = cr if isinstance(cr, OrderPosition) else None
form = QuestionsForm(event=self.request.event,
prefix=cr.id,
cartpos=cartpos,
orderpos=orderpos,
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)
return formlist
@cached_property
def formdict(self):
storage = OrderedDict()
for f in self.forms:
pos = f.cartpos or f.orderpos
if pos.addon_to_id:
if pos.addon_to not in storage:
storage[pos.addon_to] = []
storage[pos.addon_to].append(f)
else:
if pos not in storage:
storage[pos] = []
storage[pos].append(f)
return storage
def save(self):
failed = False
for form in self.forms:
meta_info = form.pos.meta_info_data
# Every form represents a CartPosition or OrderPosition with questions attached
if not form.is_valid():
failed = True
else:
# This form was correctly filled, so we store the data as
# answers to the questions / in the CartPosition object
for k, v in form.cleaned_data.items():
if k == 'attendee_name':
form.pos.attendee_name = v if v != '' else None
form.pos.save()
elif k == 'attendee_email':
form.pos.attendee_email = v if v != '' else None
form.pos.save()
elif k.startswith('question_') and v is not None:
field = form.fields[k]
if hasattr(field, 'answer'):
# We already have a cached answer object, so we don't
# have to create a new one
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)
field.answer.save()
elif v != '':
answer = QuestionAnswer(
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
question=field.question,
)
self._save_to_answer(field, answer, v)
answer.save()
else:
meta_info.setdefault('question_form_data', {})
if v is None:
if k in meta_info['question_form_data']:
del meta_info['question_form_data'][k]
else:
meta_info['question_form_data'][k] = v
form.pos.meta_info = json.dumps(meta_info)
form.pos.save(update_fields=['meta_info'])
return not failed
def _save_to_answer(self, field, answer, value):
if isinstance(field, forms.ModelMultipleChoiceField):
answstr = ", ".join([str(o) for o in value])
if not answer.pk:
answer.save()
else:
answer.options.clear()
answer.answer = answstr
answer.options.add(*value)
elif isinstance(field, forms.ModelChoiceField):
if not answer.pk:
answer.save()
else:
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