forked from CGM_Public/pretix_original
451 lines
20 KiB
Python
451 lines
20 KiB
Python
#
|
||
# This file is part of pretix (Community Edition).
|
||
#
|
||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||
#
|
||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||
#
|
||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||
# this file, see <https://pretix.eu/about/en/license>.
|
||
#
|
||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||
# details.
|
||
#
|
||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||
# <https://www.gnu.org/licenses/>.
|
||
#
|
||
|
||
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
||
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
||
#
|
||
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
|
||
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
|
||
#
|
||
# This file contains Apache-licensed contributions copyrighted by: Sohalt, Tobias Kunze
|
||
#
|
||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||
# License for the specific language governing permissions and limitations under the License.
|
||
|
||
import csv
|
||
from collections import namedtuple
|
||
from io import StringIO
|
||
|
||
from django import forms
|
||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||
from django.core.validators import EmailValidator
|
||
from django.db.models.functions import Upper
|
||
from django.urls import reverse
|
||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||
from django_scopes.forms import SafeModelChoiceField
|
||
|
||
from pretix.base.email import get_available_placeholders
|
||
from pretix.base.forms import (
|
||
I18nModelForm, MarkdownTextarea, PlaceholderValidator,
|
||
)
|
||
from pretix.base.forms.widgets import format_placeholders_help_text
|
||
from pretix.base.i18n import language
|
||
from pretix.base.models import Item, Voucher
|
||
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
||
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
|
||
from pretix.control.signals import voucher_form_validation
|
||
from pretix.helpers.models import modelcopy
|
||
|
||
|
||
class FakeChoiceField(forms.ChoiceField):
|
||
def valid_value(self, value):
|
||
return True
|
||
|
||
|
||
class VoucherForm(I18nModelForm):
|
||
itemvar = FakeChoiceField(
|
||
label=_("Product"),
|
||
help_text=_(
|
||
"This product is added to the user's cart if the voucher is redeemed. Instead of a specific product, you "
|
||
"can also select a quota. In this case, all products assigned to this quota can be selected."
|
||
),
|
||
required=True
|
||
)
|
||
|
||
class Meta:
|
||
model = Voucher
|
||
localized_fields = '__all__'
|
||
fields = [
|
||
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
|
||
'comment', 'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'all_addons_included',
|
||
'all_bundles_included', 'budget'
|
||
]
|
||
field_classes = {
|
||
'valid_until': SplitDateTimeField,
|
||
'subevent': SafeModelChoiceField,
|
||
}
|
||
widgets = {
|
||
'valid_until': SplitDateTimePickerWidget(),
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
instance = kwargs.get('instance')
|
||
initial = kwargs.get('initial')
|
||
self.initial_instance_data = None
|
||
if instance:
|
||
if instance.pk:
|
||
self.initial_instance_data = modelcopy(instance)
|
||
try:
|
||
if instance.variation:
|
||
initial['itemvar'] = '%d-%d' % (instance.item.pk, instance.variation.pk)
|
||
elif instance.item:
|
||
initial['itemvar'] = str(instance.item.pk)
|
||
elif instance.quota:
|
||
initial['itemvar'] = 'q-%d' % instance.quota.pk
|
||
except Item.DoesNotExist:
|
||
pass
|
||
super().__init__(*args, **kwargs)
|
||
|
||
if instance.event.has_subevents:
|
||
self.fields['subevent'].queryset = instance.event.subevents.all()
|
||
self.fields['subevent'].widget = Select2(
|
||
attrs={
|
||
'data-model-select2': 'event',
|
||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||
'event': instance.event.slug,
|
||
'organizer': instance.event.organizer.slug,
|
||
}),
|
||
'data-placeholder': pgettext_lazy('subevent', 'Date')
|
||
}
|
||
)
|
||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||
self.fields['subevent'].required = False
|
||
elif 'subevent':
|
||
del self.fields['subevent']
|
||
|
||
choices = []
|
||
if 'itemvar' in initial or (self.data and 'itemvar' in self.data):
|
||
iv = self.data.get('itemvar') or initial.get('itemvar', '')
|
||
if iv.startswith('q-'):
|
||
q = self.instance.event.quotas.get(pk=iv[2:])
|
||
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
|
||
elif '-' in iv:
|
||
itemid, varid = iv.split('-')
|
||
i = self.instance.event.items.get(pk=itemid)
|
||
v = i.variations.get(pk=varid)
|
||
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (str(i), v.value)))
|
||
elif iv:
|
||
i = self.instance.event.items.get(pk=iv)
|
||
if i.variations.exists():
|
||
choices.append((str(i.pk), _('{product} – Any variation').format(product=i)))
|
||
else:
|
||
choices.append((str(i.pk), str(i)))
|
||
|
||
self.fields['itemvar'].choices = choices
|
||
self.fields['itemvar'].widget = Select2ItemVarQuota(
|
||
attrs={
|
||
'data-model-select2': 'generic',
|
||
'data-select2-url': reverse('control:event.vouchers.itemselect2', kwargs={
|
||
'event': instance.event.slug,
|
||
'organizer': instance.event.organizer.slug,
|
||
}),
|
||
'data-placeholder': _('All products')
|
||
}
|
||
)
|
||
self.fields['itemvar'].required = False
|
||
self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices
|
||
|
||
if self.instance.event.seating_plan or self.instance.event.subevents.filter(seating_plan__isnull=False).exists():
|
||
self.fields['seat'] = forms.CharField(
|
||
label=_("Specific seat ID"),
|
||
max_length=255,
|
||
required=False,
|
||
widget=forms.TextInput(attrs={'data-seat-guid-field': '1'}),
|
||
initial=self.instance.seat.seat_guid if self.instance.seat else '',
|
||
help_text=str(self.instance.seat) if self.instance.seat else '',
|
||
)
|
||
|
||
def clean(self):
|
||
data = super().clean()
|
||
|
||
if not self._errors:
|
||
try:
|
||
itemid = quotaid = None
|
||
iv = self.data.get('itemvar', '')
|
||
if iv.startswith('q-'):
|
||
quotaid = iv[2:]
|
||
elif '-' in iv:
|
||
itemid, varid = iv.split('-')
|
||
elif iv:
|
||
itemid, varid = iv, None
|
||
else:
|
||
itemid, varid = None, None
|
||
|
||
if itemid:
|
||
self.instance.item = self.instance.event.items.get(pk=itemid)
|
||
if varid:
|
||
self.instance.variation = self.instance.item.variations.get(pk=varid)
|
||
else:
|
||
self.instance.variation = None
|
||
self.instance.quota = None
|
||
elif quotaid:
|
||
self.instance.quota = self.instance.event.quotas.get(pk=quotaid)
|
||
self.instance.item = None
|
||
self.instance.variation = None
|
||
else:
|
||
self.instance.quota = None
|
||
self.instance.item = None
|
||
self.instance.variation = None
|
||
|
||
except ObjectDoesNotExist:
|
||
raise ValidationError(_("Invalid product selected."))
|
||
|
||
if 'codes' in data:
|
||
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
|
||
cnt = len(data['codes']) * data.get('max_usages', 0)
|
||
else:
|
||
cnt = data.get('max_usages', 0)
|
||
if self.instance and self.instance.pk:
|
||
cnt -= self.instance.redeemed # these do not need quota any more
|
||
|
||
Voucher.clean_item_properties(
|
||
data, self.instance.event,
|
||
self.instance.quota, self.instance.item, self.instance.variation,
|
||
seats_given=data.get('seat') or data.get('seats'),
|
||
block_quota=data.get('block_quota')
|
||
)
|
||
if not data.get('show_hidden_items') and (
|
||
(self.instance.quota and all(i.hide_without_voucher for i in self.instance.quota.items.all()))
|
||
or (self.instance.item and self.instance.item.hide_without_voucher)
|
||
):
|
||
raise ValidationError({
|
||
'show_hidden_items': [
|
||
_('The voucher only matches hidden products but you have not selected that it should show '
|
||
'them.')
|
||
]
|
||
})
|
||
Voucher.clean_subevent(
|
||
data, self.instance.event
|
||
)
|
||
Voucher.clean_max_usages(data, self.instance.redeemed)
|
||
check_quota = Voucher.clean_quota_needs_checking(
|
||
data, self.initial_instance_data,
|
||
item_changed=data.get('itemvar') != self.initial.get('itemvar'),
|
||
creating=not self.instance.pk
|
||
)
|
||
if check_quota:
|
||
Voucher.clean_quota_check(
|
||
data, cnt, self.initial_instance_data,
|
||
self.instance.event, self.instance.quota, self.instance.item, self.instance.variation
|
||
)
|
||
Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
|
||
if 'seat' in self.fields and data.get('seat'):
|
||
self.instance.seat = Voucher.clean_seat_id(
|
||
data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
|
||
)
|
||
self.instance.item = self.instance.seat.product
|
||
|
||
voucher_form_validation.send(sender=self.instance.event, form=self, data=data)
|
||
|
||
return data
|
||
|
||
def save(self, commit=True):
|
||
return super().save(commit)
|
||
|
||
|
||
class VoucherBulkForm(VoucherForm):
|
||
codes = forms.CharField(
|
||
widget=forms.Textarea,
|
||
label=_("Codes"),
|
||
help_text=_(
|
||
"Add one voucher code per line. We suggest that you copy this list and save it into a file."
|
||
),
|
||
required=True
|
||
)
|
||
send = forms.BooleanField(
|
||
label=_("Send vouchers via email"),
|
||
required=False
|
||
)
|
||
send_subject = forms.CharField(
|
||
label=_("Subject"),
|
||
widget=forms.TextInput(attrs={'data-display-dependency': '#id_send'}),
|
||
required=False,
|
||
initial=_('Your voucher for {event}')
|
||
)
|
||
send_message = forms.CharField(
|
||
label=_("Message"),
|
||
widget=MarkdownTextarea(attrs={'data-display-dependency': '#id_send'}),
|
||
required=False,
|
||
initial=_('Hello,\n\n'
|
||
'with this email, we\'re sending you one or more vouchers for {event}:\n\n{voucher_list}\n\n'
|
||
'You can redeem them here in our ticket shop:\n\n{url}\n\nBest regards, \n'
|
||
'Your {event} team')
|
||
)
|
||
send_recipients = forms.CharField(
|
||
label=_('Recipients'),
|
||
widget=forms.Textarea(attrs={
|
||
'data-display-dependency': '#id_send',
|
||
'placeholder': 'email,number,name,tag\njohn@example.org,3,John,example\n\n-- {} --\n\njohn@example.org\njane@example.net'.format(
|
||
_('or')
|
||
)
|
||
}),
|
||
required=False,
|
||
help_text=_('You can either supply a list of email addresses with one email address per line, or the contents '
|
||
'of a CSV file with a title column and one or more of the columns "email", "number", "name", '
|
||
'or "tag".')
|
||
)
|
||
Recipient = namedtuple('Recipient', 'email number name tag')
|
||
|
||
def _set_field_placeholders(self, fn, base_parameters):
|
||
placeholders = get_available_placeholders(self.instance.event, base_parameters)
|
||
ht = format_placeholders_help_text(placeholders, self.instance.event)
|
||
|
||
if self.fields[fn].help_text:
|
||
self.fields[fn].help_text += ' ' + str(ht)
|
||
else:
|
||
self.fields[fn].help_text = ht
|
||
self.fields[fn].validators.append(
|
||
PlaceholderValidator(['{%s}' % p for p in placeholders.keys()])
|
||
)
|
||
|
||
class Meta:
|
||
model = Voucher
|
||
localized_fields = '__all__'
|
||
fields = [
|
||
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
|
||
'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'all_addons_included',
|
||
'all_bundles_included', 'budget'
|
||
]
|
||
field_classes = {
|
||
'valid_until': SplitDateTimeField,
|
||
'subevent': SafeModelChoiceField,
|
||
}
|
||
widgets = {
|
||
'valid_until': SplitDateTimePickerWidget(),
|
||
}
|
||
labels = {
|
||
'max_usages': _('Maximum usages per voucher')
|
||
}
|
||
help_texts = {
|
||
'max_usages': _('Number of times times EACH of these vouchers can be redeemed.')
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
self._set_field_placeholders('send_subject', ['event', 'name'])
|
||
self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'])
|
||
|
||
with language(self.instance.event.settings.locale, self.instance.event.settings.region):
|
||
for f in ("send_subject", "send_message"):
|
||
self.fields[f].initial = str(self.fields[f].initial)
|
||
|
||
if 'seat' in self.fields:
|
||
self.fields['seats'] = forms.CharField(
|
||
label=_("Specific seat IDs"),
|
||
required=False,
|
||
widget=forms.Textarea(attrs={'data-seat-guid-field': '1'}),
|
||
initial=self.instance.seat.seat_guid if self.instance.seat else '',
|
||
)
|
||
|
||
def clean_send_recipients(self):
|
||
raw = self.cleaned_data['send_recipients']
|
||
if self.cleaned_data.get('send', None) is False:
|
||
# No need to validate addresses if the section was turned off
|
||
return []
|
||
if not raw:
|
||
return []
|
||
r = raw.split('\n')
|
||
res = []
|
||
if ',' in raw or ';' in raw:
|
||
if '@' in r[0]:
|
||
raise ValidationError(_('CSV input needs to contain a header row in the first line.'))
|
||
try:
|
||
dialect = csv.Sniffer().sniff(raw[:1024])
|
||
reader = csv.DictReader(StringIO(raw), dialect=dialect)
|
||
except csv.Error as e:
|
||
raise ValidationError(_('CSV parsing failed: {error}.').format(error=str(e)))
|
||
if len(reader.fieldnames) == 1 and ',' in reader.fieldnames[0]:
|
||
raise ValidationError(_('CSV input was not recognized to have multiple columns, maybe you have some invalid quoted field in your input.'))
|
||
if 'email' not in reader.fieldnames:
|
||
raise ValidationError(_('CSV input needs to contain a field with the header "{header}".').format(header="email"))
|
||
unknown_fields = [f for f in reader.fieldnames if f not in ('email', 'name', 'tag', 'number')]
|
||
if unknown_fields:
|
||
raise ValidationError(_('CSV input contains an unknown field with the header "{header}".').format(header=unknown_fields[0]))
|
||
for i, row in enumerate(reader):
|
||
try:
|
||
EmailValidator()(row['email'].strip())
|
||
except ValidationError as err:
|
||
raise ValidationError(_('{value} is not a valid email address.').format(value=row['email'].strip())) from err
|
||
try:
|
||
res.append(self.Recipient(
|
||
name=row.get('name', ''),
|
||
email=row['email'].strip(),
|
||
number=int(row.get('number', 1) or ""),
|
||
tag=row.get('tag', None)
|
||
))
|
||
except ValueError as err:
|
||
raise ValidationError(_('Invalid value in row {number}.').format(number=i + 1)) from err
|
||
else:
|
||
for e in r:
|
||
try:
|
||
EmailValidator()(e.strip())
|
||
except ValidationError as err:
|
||
raise ValidationError(_('{value} is not a valid email address.').format(value=e.strip())) from err
|
||
else:
|
||
res.append(self.Recipient(email=e.strip(), number=1, tag=None, name=''))
|
||
return res
|
||
|
||
def clean(self):
|
||
data = super().clean()
|
||
|
||
if 'codes' in data:
|
||
vouchers = self.instance.event.vouchers.annotate(
|
||
code_upper=Upper('code')
|
||
).filter(code_upper__in=[c.upper() for c in data['codes']])
|
||
if vouchers.exists():
|
||
raise ValidationError(_('A voucher with one of these codes already exists.'))
|
||
|
||
codes_seen = set()
|
||
for c in data['codes']:
|
||
if len(c) < 5:
|
||
raise ValidationError({
|
||
'codes': [
|
||
_('The voucher code {code} is too short. Make sure all voucher codes are at least {min_length} characters long.').format(
|
||
code=c,
|
||
min_length=5
|
||
)
|
||
]
|
||
})
|
||
if c in codes_seen:
|
||
raise ValidationError(_('The voucher code {code} appears in your list twice.').format(code=c))
|
||
codes_seen.add(c)
|
||
|
||
if data.get('send') and not all([data.get('send_subject'), data.get('send_message'), data.get('send_recipients')]):
|
||
raise ValidationError(_('If vouchers should be sent by email, subject, message and recipients need to be specified.'))
|
||
|
||
if data.get('codes') and data.get('send'):
|
||
recp = self.cleaned_data.get('send_recipients', [])
|
||
code_len = len(data.get('codes'))
|
||
recp_len = sum(r.number for r in recp)
|
||
if code_len != recp_len:
|
||
raise ValidationError(_('You generated {codes} vouchers, but entered recipients for {recp} vouchers.').format(codes=code_len, recp=recp_len))
|
||
|
||
if data.get('seats'):
|
||
seatids = [s.strip() for s in data.get('seats').strip().split("\n") if s]
|
||
if len(seatids) != len(data.get('codes')):
|
||
raise ValidationError(_('You need to specify as many seats as voucher codes.'))
|
||
data['seats'] = []
|
||
for s in seatids:
|
||
data['seat'] = s
|
||
data['seats'].append(Voucher.clean_seat_id(
|
||
data, self.instance.item, self.instance.quota, self.instance.event, None
|
||
))
|
||
self.instance.seat = data['seats'][0] # Trick model-level validation
|
||
else:
|
||
data['seats'] = []
|
||
|
||
return data
|
||
|
||
def post_bulk_save(self, objs):
|
||
pass
|