mirror of
https://github.com/pretix/pretix.git
synced 2026-08-23 12:51:59 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b5253517 | ||
|
|
4dd6e1a19f | ||
|
|
4cb9d76281 | ||
|
|
f8cc31b120 | ||
|
|
b241adb7d0 | ||
|
|
7b1558b22e | ||
|
|
488f731396 | ||
|
|
b8b02de283 | ||
|
|
c4a5a9a84d | ||
|
|
4e5fbacf6d | ||
|
|
7fe31634e6 |
@@ -7,7 +7,6 @@ on:
|
||||
- 'src/pretix/static/pretixpresale/widget/**'
|
||||
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
|
||||
- 'src/pretix/plugins/webcheckin/**'
|
||||
- 'src/pretix/plugins/wallet/**'
|
||||
- 'eslint.config.mjs'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
@@ -17,7 +16,6 @@ on:
|
||||
- 'src/pretix/static/pretixpresale/widget/**'
|
||||
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
|
||||
- 'src/pretix/plugins/webcheckin/**'
|
||||
- 'src/pretix/plugins/wallet/**'
|
||||
- 'eslint.config.mjs'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
|
||||
+2
-2
@@ -18,12 +18,12 @@
|
||||
"doc": "doc"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:control": "vite --clearScreen=false",
|
||||
"dev:control": "vite",
|
||||
"dev:widget": "vite src/pretix/static/pretixpresale/widget",
|
||||
"build": "npm run build:control -s && npm run build:widget -s",
|
||||
"build:control": "vite build",
|
||||
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
|
||||
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin src/pretix/plugins/wallet",
|
||||
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ dependencies = [
|
||||
"redis==7.4.*",
|
||||
"reportlab==5.0.*",
|
||||
"requests==2.34.*",
|
||||
"sentry-sdk==2.66.*",
|
||||
"sentry-sdk==2.68.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
|
||||
@@ -32,7 +32,6 @@ ignore =
|
||||
src/tests/plugins/stripe/*
|
||||
src/tests/plugins/sendmail/*
|
||||
src/tests/plugins/ticketoutputpdf/*
|
||||
src/tests/plugins/wallet/*
|
||||
.*
|
||||
CODE_OF_CONDUCT.md
|
||||
CONTRIBUTING.md
|
||||
|
||||
@@ -66,7 +66,6 @@ INSTALLED_APPS = [
|
||||
'pretix.plugins.returnurl',
|
||||
'pretix.plugins.autocheckin',
|
||||
'pretix.plugins.webcheckin',
|
||||
'pretix.plugins.wallet',
|
||||
'django_countries',
|
||||
'oauth2_provider',
|
||||
'phonenumber_field',
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
from pretix.base.models import (
|
||||
CachedFile
|
||||
)
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.conf import settings
|
||||
|
||||
def handle_file_upload(data, user, auth, allowed_types):
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
|
||||
file__isnull=False,
|
||||
pk=data[len("file:"):],
|
||||
)
|
||||
except (ValidationError, DjangoValidationError, IndexError): # invalid uuid
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
except CachedFile.DoesNotExist:
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
return cf.file
|
||||
@@ -619,7 +619,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
options_data = validated_data.pop('options') if 'options' in validated_data else []
|
||||
items = validated_data.pop('items', [])
|
||||
|
||||
question = Question.objects.create(**validated_data)
|
||||
question = Question.objects.create(**validated_data, container_type=Question.ContainerType.ORDERPOSITION)
|
||||
question.items.set(items)
|
||||
for opt_data in options_data:
|
||||
QuestionOption.objects.create(question=question, **opt_data)
|
||||
|
||||
@@ -63,7 +63,7 @@ from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.orders import PrintLog
|
||||
@@ -75,7 +75,6 @@ from pretix.base.services.checkin import (
|
||||
from pretix.base.services.media import perform_media_exchange
|
||||
from pretix.base.signals import checkin_annulled
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.api.helpers import handle_file_upload
|
||||
|
||||
with scopes_disabled():
|
||||
class CheckinListFilter(FilterSet):
|
||||
@@ -329,6 +328,27 @@ with scopes_disabled():
|
||||
)
|
||||
|
||||
|
||||
def _handle_file_upload(data, user, auth):
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
|
||||
file__isnull=False,
|
||||
pk=data[len("file:"):],
|
||||
)
|
||||
except (ValidationError, BaseValidationError, IndexError): # invalid uuid
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
except CachedFile.DoesNotExist:
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
|
||||
allowed_types = (
|
||||
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
|
||||
)
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
return cf.file
|
||||
|
||||
|
||||
def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_products=False, pdf_data=False, expand=None):
|
||||
@@ -775,12 +795,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
try:
|
||||
if q.type == Question.TYPE_FILE:
|
||||
if answers_data[str(q.pk)]:
|
||||
given_answers[q] = handle_file_upload(
|
||||
answers_data[str(q.pk)],
|
||||
user,
|
||||
auth,
|
||||
allowed_types=('image/png', 'image/jpeg', 'image/gif', 'application/pdf')
|
||||
)
|
||||
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
|
||||
else:
|
||||
given_answers[q] = None
|
||||
else:
|
||||
|
||||
@@ -475,7 +475,10 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
write_permission = 'event.items:write'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.questions.prefetch_related('options').all()
|
||||
return self.request.event.questions.filter(
|
||||
# the container_type parameter is undocumented, this API is going to change in a later release
|
||||
container_type=self.request.GET.get('container_type', Question.ContainerType.ORDERPOSITION),
|
||||
).prefetch_related('options').all()
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
|
||||
+509
-424
@@ -36,6 +36,7 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import namedtuple
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
@@ -636,449 +637,271 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
FakeQuestion = namedtuple(
|
||||
'FakeQuestion', 'id question position required help_text container_type', defaults=('', Question.ContainerType.ORDERPOSITION)
|
||||
)
|
||||
|
||||
|
||||
def get_fake_attendee_questions(settings):
|
||||
fq = []
|
||||
sqo = settings.system_question_order
|
||||
|
||||
if settings.attendee_names_asked:
|
||||
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), settings.attendee_names_required))
|
||||
|
||||
if settings.attendee_emails_asked:
|
||||
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), settings.attendee_emails_required))
|
||||
|
||||
if settings.attendee_company_asked:
|
||||
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), settings.attendee_company_required))
|
||||
|
||||
if settings.attendee_addresses_asked:
|
||||
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), settings.attendee_addresses_required))
|
||||
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), settings.attendee_addresses_required))
|
||||
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), settings.attendee_addresses_required))
|
||||
fq.append(FakeQuestion('state', _('State'), sqo.get('country', 0), settings.attendee_addresses_required))
|
||||
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), settings.attendee_addresses_required))
|
||||
return fq
|
||||
|
||||
|
||||
class BaseQuestionsForm(forms.Form):
|
||||
"""
|
||||
This form class is responsible for asking order-related questions. This includes
|
||||
the attendee name for admission tickets, if the corresponding setting is enabled,
|
||||
as well as additional questions defined by the organizer.
|
||||
This is the base form class responsible for asking order- or ticket-related questions.
|
||||
"""
|
||||
address_validation = False
|
||||
|
||||
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
|
||||
"""
|
||||
request = kwargs.pop('request', None)
|
||||
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')
|
||||
self.all_optional = kwargs.pop('all_optional', False)
|
||||
self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
|
||||
if item.validity_dynamic_start_choice_day_limit:
|
||||
max_date = time_machine_now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
|
||||
else:
|
||||
max_date = None
|
||||
min_date = time_machine_now()
|
||||
def build_user_question_field(self, request, event, answerlist, q):
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
answers = [a for a in answerlist if a.question_id == q.id]
|
||||
if answers:
|
||||
initial = answers[0]
|
||||
else:
|
||||
initial = None
|
||||
if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership:
|
||||
if pos.used_membership.date_start >= time_machine_now():
|
||||
initial = min_date = pos.used_membership.date_start
|
||||
max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end
|
||||
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
|
||||
attrs = {}
|
||||
if max_date:
|
||||
attrs['data-max'] = max_date.date().isoformat()
|
||||
if min_date:
|
||||
attrs['data-min'] = min_date.date().isoformat()
|
||||
self.fields['requested_valid_from'] = forms.DateField(
|
||||
label=_('Start date'),
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=DatePickerWidget(attrs),
|
||||
validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())]
|
||||
)
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
required = q.required and not self.all_optional
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
# itself.
|
||||
widget = forms.CheckboxInput(attrs={'required': 'required'})
|
||||
else:
|
||||
self.fields['requested_valid_from'] = forms.SplitDateTimeField(
|
||||
label=_('Start date'),
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=min_date,
|
||||
max_date=max_date
|
||||
),
|
||||
validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)]
|
||||
)
|
||||
widget = forms.CheckboxInput()
|
||||
|
||||
add_fields = {}
|
||||
if initial:
|
||||
initialbool = (initial.answer == "True")
|
||||
else:
|
||||
initialbool = False
|
||||
|
||||
if item.ask_attendee_data and event.settings.attendee_names_asked:
|
||||
add_fields['attendee_name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.attendee_names_required and not self.all_optional,
|
||||
scheme=event.settings.name_scheme,
|
||||
titles=event.settings.name_scheme_titles,
|
||||
label=_('Attendee name'),
|
||||
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
|
||||
field = forms.BooleanField(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
if item.ask_attendee_data and event.settings.attendee_emails_asked:
|
||||
add_fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required and not self.all_optional,
|
||||
label=_('Attendee email'),
|
||||
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
'autocomplete': 'email'
|
||||
}
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
label=escape(q.question), required=required,
|
||||
min_value=q.valid_number_min or Decimal('0.00'),
|
||||
max_value=q.valid_number_max,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
if item.ask_attendee_data and event.settings.attendee_company_asked:
|
||||
add_fields['company'] = forms.CharField(
|
||||
required=event.settings.attendee_company_required and not self.all_optional,
|
||||
label=_('Company'),
|
||||
max_length=255,
|
||||
initial=(cartpos.company if cartpos else orderpos.company),
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=escape(q.question), required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
|
||||
if item.ask_attendee_data and event.settings.attendee_addresses_asked:
|
||||
add_fields['street'] = forms.CharField(
|
||||
required=self.attendee_addresses_required,
|
||||
label=_('Address'),
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
'placeholder': _('Street and Number'),
|
||||
'autocomplete': 'street-address'
|
||||
}),
|
||||
initial=(cartpos.street if cartpos else orderpos.street),
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=escape(q.question), required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=rich_text(q.help_text),
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
add_fields['zipcode'] = forms.CharField(
|
||||
required=False,
|
||||
max_length=30,
|
||||
label=_('ZIP code'),
|
||||
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'postal-code',
|
||||
}),
|
||||
)
|
||||
add_fields['city'] = forms.CharField(
|
||||
required=False,
|
||||
label=_('City'),
|
||||
max_length=255,
|
||||
initial=(cartpos.city if cartpos else orderpos.city),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
)
|
||||
country = (cartpos.country if cartpos else orderpos.country) or guess_country_from_request(request, event)
|
||||
add_fields['country'] = CountryField(
|
||||
countries=CachedCountries
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField(
|
||||
countries=CachedCountries,
|
||||
blank=True, null=True, blank_label=' ',
|
||||
).formfield(
|
||||
required=self.attendee_addresses_required,
|
||||
label=_('Country'),
|
||||
initial=country,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'country',
|
||||
'data-trigger-address-info': 'on',
|
||||
}),
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
widget=forms.Select,
|
||||
empty_label=' ',
|
||||
initial=initial.answer if initial else (
|
||||
guess_country_from_request(request, event) if required else None),
|
||||
)
|
||||
c = [('', '---')]
|
||||
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
|
||||
cc = None
|
||||
state = None
|
||||
if fprefix + 'country' in self.data:
|
||||
cc = str(self.data[fprefix + 'country'])
|
||||
elif country:
|
||||
cc = str(country)
|
||||
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
|
||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
|
||||
state = (cartpos.state if cartpos else orderpos.state)
|
||||
elif fprefix + 'state' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
add_fields['state'] = forms.ChoiceField(
|
||||
label=pgettext_lazy('address', 'State'),
|
||||
required=False,
|
||||
choices=c,
|
||||
initial=state,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'address-level1',
|
||||
}),
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
widget=forms.Select,
|
||||
to_field_name='identifier',
|
||||
empty_label='',
|
||||
initial=initial.options.first() if initial else None,
|
||||
)
|
||||
add_fields['state'].widget.is_required = True
|
||||
|
||||
field_positions = list(
|
||||
[
|
||||
(n, event.settings.system_question_order.get(n if n != 'state' else 'country', 0))
|
||||
for n in add_fields.keys()
|
||||
]
|
||||
)
|
||||
|
||||
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]
|
||||
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
to_field_name='identifier',
|
||||
widget=QuestionCheckboxSelectMultiple,
|
||||
initial=initial.options.all() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_FILE:
|
||||
if q.valid_file_portrait:
|
||||
field = PortraitImageField(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=initial.file if initial else None,
|
||||
widget=PortraitImageWidget(answer=initial, request=request,
|
||||
attrs={'data-portrait-photo': 'true'}),
|
||||
)
|
||||
else:
|
||||
initial = None
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
help_text = rich_text(q.help_text)
|
||||
label = escape(q.question) # django-bootstrap3 calls mark_safe
|
||||
required = q.required and not self.all_optional
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if 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=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initialbool, widget=widget,
|
||||
field = ExtFileField(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(answer=initial, request=request),
|
||||
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
label=label, required=required,
|
||||
min_value=q.valid_number_min or Decimal('0.00'),
|
||||
max_value=q.valid_number_max,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField(
|
||||
countries=CachedCountries,
|
||||
blank=True, null=True, blank_label=' ',
|
||||
).formfield(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label=' ',
|
||||
initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None),
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
to_field_name='identifier',
|
||||
empty_label='',
|
||||
initial=initial.options.first() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
to_field_name='identifier',
|
||||
widget=QuestionCheckboxSelectMultiple,
|
||||
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'}),
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
attrs = {}
|
||||
if q.valid_date_min:
|
||||
attrs['data-min'] = q.valid_date_min.isoformat()
|
||||
if q.valid_date_max:
|
||||
attrs['data-max'] = q.valid_date_max.isoformat()
|
||||
help_text = q.help_text
|
||||
if not help_text:
|
||||
if q.valid_date_min and q.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date between {min} and {max}.'),
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
else:
|
||||
field = ExtFileField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
|
||||
elif q.valid_date_min:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date no earlier than {min}.'),
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
attrs = {}
|
||||
if q.valid_date_min:
|
||||
attrs['data-min'] = q.valid_date_min.isoformat()
|
||||
if q.valid_date_max:
|
||||
attrs['data-max'] = q.valid_date_max.isoformat()
|
||||
if not help_text:
|
||||
if q.valid_date_min and q.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date between {min} and {max}.'),
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif q.valid_date_min:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date no earlier than {min}.'),
|
||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif q.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date no later than {max}.'),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).date()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
elif q.valid_date_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date no later than {max}.'),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).date()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
field = forms.DateField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=_initial,
|
||||
widget=DatePickerWidget(attrs),
|
||||
)
|
||||
if q.valid_date_min:
|
||||
field.validators.append(MinDateValidator(q.valid_date_min))
|
||||
if q.valid_date_max:
|
||||
field.validators.append(MaxDateValidator(q.valid_date_max))
|
||||
elif q.type == Question.TYPE_TIME:
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).time()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.DateField(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(help_text),
|
||||
initial=_initial,
|
||||
widget=DatePickerWidget(attrs),
|
||||
)
|
||||
if q.valid_date_min:
|
||||
field.validators.append(MinDateValidator(q.valid_date_min))
|
||||
if q.valid_date_max:
|
||||
field.validators.append(MaxDateValidator(q.valid_date_max))
|
||||
elif q.type == Question.TYPE_TIME:
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).time()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
field = forms.TimeField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=_initial,
|
||||
widget=TimePickerWidget(without_seconds=True),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
if not help_text:
|
||||
if q.valid_datetime_min and q.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time between {min} and {max}.'),
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif q.valid_datetime_min:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time no earlier than {min}.'),
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif q.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time no later than {max}.'),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.TimeField(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
initial=_initial,
|
||||
widget=TimePickerWidget(without_seconds=True),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
help_text = q.help_text
|
||||
if not help_text:
|
||||
if q.valid_datetime_min and q.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time between {min} and {max}.'),
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif q.valid_datetime_min:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time no earlier than {min}.'),
|
||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif q.valid_datetime_max:
|
||||
help_text = format_lazy(
|
||||
_('Please enter a date and time no later than {max}.'),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).astimezone(tz)
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).astimezone(tz)
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
|
||||
field = SplitDateTimeField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=_initial,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=q.valid_datetime_min,
|
||||
max_date=q.valid_datetime_max
|
||||
),
|
||||
)
|
||||
if q.valid_datetime_min:
|
||||
field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
|
||||
if q.valid_datetime_max:
|
||||
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
||||
elif q.type == Question.TYPE_PHONENUMBER:
|
||||
if initial:
|
||||
try:
|
||||
initial = PhoneNumber().from_string(initial.answer)
|
||||
except NumberParseException:
|
||||
initial = None
|
||||
field = SplitDateTimeField(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(help_text),
|
||||
initial=_initial,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=q.valid_datetime_min,
|
||||
max_date=q.valid_datetime_max
|
||||
),
|
||||
)
|
||||
if q.valid_datetime_min:
|
||||
field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
|
||||
if q.valid_datetime_max:
|
||||
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
||||
elif q.type == Question.TYPE_PHONENUMBER:
|
||||
if initial:
|
||||
try:
|
||||
initial = PhoneNumber().from_string(initial.answer)
|
||||
except NumberParseException:
|
||||
initial = None
|
||||
|
||||
if not initial:
|
||||
phone_prefix = guess_phone_prefix_from_request(request, event)
|
||||
if phone_prefix:
|
||||
initial = "+{}.".format(phone_prefix)
|
||||
if not initial:
|
||||
phone_prefix = guess_phone_prefix_from_request(request, event)
|
||||
if phone_prefix:
|
||||
initial = "+{}.".format(phone_prefix)
|
||||
|
||||
field = PhoneNumberField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
||||
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
||||
# the future.
|
||||
initial=initial,
|
||||
widget=WrappedPhoneNumberPrefixWidget()
|
||||
)
|
||||
field.question = q
|
||||
if answers:
|
||||
# Cache the answer object for later use
|
||||
field.answer = answers[0]
|
||||
field = PhoneNumberField(
|
||||
label=escape(q.question), required=required,
|
||||
help_text=rich_text(q.help_text),
|
||||
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
||||
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
||||
# the future.
|
||||
initial=initial,
|
||||
widget=WrappedPhoneNumberPrefixWidget()
|
||||
)
|
||||
field.question = q
|
||||
if answers:
|
||||
# Cache the answer object for later use
|
||||
field.answer = answers[0]
|
||||
|
||||
if q.dependency_question_id:
|
||||
field.widget.attrs['data-question-dependency'] = q.dependency_question_id
|
||||
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
|
||||
if q.type != 'M':
|
||||
field.widget.attrs['required'] = q.required and not self.all_optional
|
||||
field._required = q.required and not self.all_optional
|
||||
field.required = False
|
||||
|
||||
add_fields['question_%s' % q.id] = field
|
||||
field_positions.append(('question_%s' % q.id, q.position))
|
||||
|
||||
field_positions.sort(key=lambda e: e[1])
|
||||
for fname, p in field_positions:
|
||||
self.fields[fname] = add_fields[fname]
|
||||
|
||||
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)
|
||||
|
||||
for k, v in self.fields.items():
|
||||
if isinstance(v.widget, forms.MultiWidget):
|
||||
for w in v.widget.widgets:
|
||||
autocomplete = w.attrs.get('autocomplete', '')
|
||||
if autocomplete.strip() == "off":
|
||||
w.attrs['autocomplete'] = 'off'
|
||||
else:
|
||||
w.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
|
||||
if v.widget.attrs.get('autocomplete') or k == 'attendee_name_parts':
|
||||
autocomplete = v.widget.attrs.get('autocomplete', '')
|
||||
if autocomplete.strip() == "off":
|
||||
v.widget.attrs['autocomplete'] = 'off'
|
||||
else:
|
||||
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
|
||||
|
||||
def clean(self):
|
||||
from pretix.base.addressvalidation import \
|
||||
validate_address # local import to prevent impact on startup time
|
||||
|
||||
d = super().clean()
|
||||
|
||||
if self.address_validation:
|
||||
self.cleaned_data = d = validate_address(d, all_optional=not self.attendee_addresses_required)
|
||||
|
||||
if d.get('street') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
if not d.get('state'):
|
||||
self.add_error('state', _('This field is required.'))
|
||||
if q.dependency_question_id:
|
||||
field.widget.attrs['data-question-dependency'] = q.dependency_question_id
|
||||
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
|
||||
if q.type != 'M':
|
||||
field.widget.attrs['required'] = q.required and not self.all_optional
|
||||
field._required = q.required and not self.all_optional
|
||||
field.required = False
|
||||
return field
|
||||
|
||||
def check_user_questions(self, d):
|
||||
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
|
||||
|
||||
def question_is_visible(parentid, qvals):
|
||||
@@ -1123,6 +946,268 @@ class BaseQuestionsForm(forms.Form):
|
||||
if 'question_%d' % q.pk in d and d['question_%d' % q.pk] is False:
|
||||
d['question_%d' % q.pk] = None
|
||||
|
||||
|
||||
class OrderLevelQuestionsForm(BaseQuestionsForm):
|
||||
def __init__(self, container, *args, **kwargs):
|
||||
"""
|
||||
Takes two additional keyword arguments:
|
||||
|
||||
:param checkoutsession: The checkout session the form should be for
|
||||
:param order: The order the form should be for
|
||||
:param event: The event this belongs to
|
||||
"""
|
||||
request = kwargs.pop('request', None)
|
||||
event = kwargs.pop('event')
|
||||
self.all_optional = kwargs.pop('all_optional', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
questions = Question.objects.filter(
|
||||
event=event, container_type=Question.ContainerType.ORDER,
|
||||
ask_during_checkin=False, hidden=False,
|
||||
).order_by('position')
|
||||
answerlist = container.answers.prefetch_related('options')
|
||||
|
||||
for q in questions:
|
||||
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, answerlist, q)
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
self.check_user_questions(d)
|
||||
return d
|
||||
|
||||
|
||||
class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
"""
|
||||
This form class is responsible for asking ticket-related questions. This includes
|
||||
the attendee name for admission tickets, if the corresponding setting is enabled,
|
||||
as well as additional questions defined by the organizer.
|
||||
"""
|
||||
|
||||
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
|
||||
"""
|
||||
request = kwargs.pop('request', None)
|
||||
cartpos = self.cartpos = kwargs.pop('cartpos', None)
|
||||
orderpos = self.orderpos = kwargs.pop('orderpos', None)
|
||||
pos = cartpos or orderpos
|
||||
item = pos.item
|
||||
event = kwargs.pop('event')
|
||||
self.all_optional = kwargs.pop('all_optional', False)
|
||||
self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
|
||||
self.fields['requested_valid_from'] = self.build_requested_valid_from_field(event, pos, item)
|
||||
|
||||
questions = []
|
||||
if item.ask_attendee_data:
|
||||
questions += get_fake_attendee_questions(event.settings)
|
||||
questions += pos.item.questions_to_ask
|
||||
|
||||
questions.sort(key=lambda q: q.position)
|
||||
|
||||
for q in questions:
|
||||
if isinstance(q, FakeQuestion):
|
||||
self.fields[q.id] = self.build_system_question_field(request, event, pos, q)
|
||||
else:
|
||||
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos.answerlist, q)
|
||||
|
||||
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)
|
||||
|
||||
for k, v in self.fields.items():
|
||||
if isinstance(v.widget, forms.MultiWidget):
|
||||
for w in v.widget.widgets:
|
||||
autocomplete = w.attrs.get('autocomplete', '')
|
||||
if autocomplete.strip() == "off":
|
||||
w.attrs['autocomplete'] = 'off'
|
||||
else:
|
||||
w.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
|
||||
if v.widget.attrs.get('autocomplete') or k == 'attendee_name_parts':
|
||||
autocomplete = v.widget.attrs.get('autocomplete', '')
|
||||
if autocomplete.strip() == "off":
|
||||
v.widget.attrs['autocomplete'] = 'off'
|
||||
else:
|
||||
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
|
||||
|
||||
def build_requested_valid_from_field(self, event, pos, item):
|
||||
if item.validity_dynamic_start_choice_day_limit:
|
||||
max_date = time_machine_now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
|
||||
else:
|
||||
max_date = None
|
||||
min_date = time_machine_now()
|
||||
initial = None
|
||||
if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership:
|
||||
if pos.used_membership.date_start >= time_machine_now():
|
||||
initial = min_date = pos.used_membership.date_start
|
||||
max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end
|
||||
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
|
||||
attrs = {}
|
||||
if max_date:
|
||||
attrs['data-max'] = max_date.date().isoformat()
|
||||
if min_date:
|
||||
attrs['data-min'] = min_date.date().isoformat()
|
||||
return forms.DateField(
|
||||
label=_('Start date'),
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=DatePickerWidget(attrs),
|
||||
validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())]
|
||||
)
|
||||
else:
|
||||
return forms.SplitDateTimeField(
|
||||
label=_('Start date'),
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=min_date,
|
||||
max_date=max_date
|
||||
),
|
||||
validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)]
|
||||
)
|
||||
|
||||
def build_system_question_field(self, request, event, pos, qc):
|
||||
field_name = qc.id
|
||||
if field_name == 'attendee_name_parts':
|
||||
return NamePartsFormField(
|
||||
max_length=255,
|
||||
required=qc.required and not self.all_optional,
|
||||
scheme=event.settings.name_scheme,
|
||||
titles=event.settings.name_scheme_titles,
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=pos.attendee_name_parts,
|
||||
)
|
||||
if field_name == 'attendee_email':
|
||||
return forms.EmailField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=pos.attendee_email,
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
'autocomplete': 'email'
|
||||
}
|
||||
)
|
||||
)
|
||||
if field_name == 'company':
|
||||
return forms.CharField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
max_length=255,
|
||||
initial=pos.company,
|
||||
)
|
||||
|
||||
if field_name == 'street':
|
||||
return forms.CharField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
'placeholder': _('Street and Number'),
|
||||
'autocomplete': 'street-address'
|
||||
}),
|
||||
initial=pos.street,
|
||||
)
|
||||
if field_name == 'zipcode':
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
max_length=30,
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=pos.zipcode,
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'postal-code',
|
||||
}),
|
||||
)
|
||||
if field_name == 'city':
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
max_length=255,
|
||||
initial=pos.city,
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
)
|
||||
if field_name == 'country':
|
||||
country = pos.country or guess_country_from_request(request, event)
|
||||
return CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
initial=country,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'country',
|
||||
'data-trigger-address-info': 'on',
|
||||
}),
|
||||
)
|
||||
if field_name == 'state':
|
||||
country = pos.country or guess_country_from_request(request, event)
|
||||
c = [('', '---')]
|
||||
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
|
||||
cc = None
|
||||
state = None
|
||||
if fprefix + 'country' in self.data:
|
||||
cc = str(self.data[fprefix + 'country'])
|
||||
elif country:
|
||||
cc = str(country)
|
||||
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
|
||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
|
||||
state = (pos.state)
|
||||
elif fprefix + 'state' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
field = forms.ChoiceField(
|
||||
label=escape(qc.question),
|
||||
help_text=rich_text(qc.help_text),
|
||||
required=False,
|
||||
choices=c,
|
||||
initial=state,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'address-level1',
|
||||
}),
|
||||
)
|
||||
field.widget.is_required = True
|
||||
return field
|
||||
|
||||
def clean(self):
|
||||
from pretix.base.addressvalidation import \
|
||||
validate_address # local import to prevent impact on startup time
|
||||
|
||||
d = super().clean()
|
||||
|
||||
if self.address_validation:
|
||||
self.cleaned_data = d = validate_address(d, all_optional=not self.attendee_addresses_required)
|
||||
|
||||
if d.get('street') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
if not d.get('state'):
|
||||
self.add_error('state', _('This field is required.'))
|
||||
|
||||
self.check_user_questions(d)
|
||||
|
||||
return d
|
||||
|
||||
|
||||
@@ -1201,15 +1286,15 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if not self.ask_vat_id:
|
||||
del self.fields['vat_id']
|
||||
elif self.validate_vat_id:
|
||||
self.fields['vat_id'].help_text = '<br/>'.join([
|
||||
str(_('Optional, but depending on the country you reside in we might need to charge you '
|
||||
'additional taxes if you do not enter it.')),
|
||||
])
|
||||
self.fields['vat_id'].help_text = _(
|
||||
'Optional, but depending on the country you reside in we might need to charge you '
|
||||
'additional taxes if you do not enter it.'
|
||||
)
|
||||
else:
|
||||
self.fields['vat_id'].help_text = '<br/>'.join([
|
||||
str(_('Optional, but it might be required for you to claim tax benefits on your invoice '
|
||||
'depending on your and the seller’s country of residence.')),
|
||||
])
|
||||
self.fields['vat_id'].help_text = _(
|
||||
'Optional, but it might be required for you to claim tax benefits on your invoice '
|
||||
'depending on your and the seller’s country of residence.'
|
||||
)
|
||||
|
||||
transmission_type_choices = [
|
||||
(t.identifier, t.public_name) for t in get_transmission_types()
|
||||
@@ -1295,8 +1380,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
del self.fields['beneficiary']
|
||||
|
||||
if event.settings.invoice_address_custom_field:
|
||||
self.fields['custom_field'].label = event.settings.invoice_address_custom_field
|
||||
self.fields['custom_field'].help_text = event.settings.invoice_address_custom_field_helptext
|
||||
self.fields['custom_field'].label = escape(event.settings.invoice_address_custom_field)
|
||||
self.fields['custom_field'].help_text = rich_text(event.settings.invoice_address_custom_field_helptext)
|
||||
else:
|
||||
del self.fields['custom_field']
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ from pretix.helpers.i18n import (
|
||||
get_format_without_seconds, get_javascript_format,
|
||||
get_javascript_format_without_seconds,
|
||||
)
|
||||
from pretix.helpers.safedownload import get_token
|
||||
|
||||
|
||||
def replace_arabic_numbers(inp):
|
||||
@@ -157,36 +158,26 @@ class TimePickerWidget(forms.TimeInput):
|
||||
|
||||
class UploadedFileWidget(forms.ClearableFileInput):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.position = kwargs.pop('position')
|
||||
self.event = kwargs.pop('event')
|
||||
self.answer = kwargs.pop('answer')
|
||||
self.request = kwargs.pop('request')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class FakeFile:
|
||||
def __init__(self, file, position, event, answer):
|
||||
def __init__(self, file, answer, request):
|
||||
self.file = file
|
||||
self.position = position
|
||||
self.event = event
|
||||
self.answer = answer
|
||||
self.request = request
|
||||
|
||||
def __str__(self):
|
||||
return os.path.basename(self.file.name).split('.', 1)[-1]
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
from pretix.base.models import OrderPosition
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
|
||||
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,
|
||||
})
|
||||
token = get_token(self.request, self.answer)
|
||||
if self.request.resolver_match.namespace == 'control':
|
||||
return self.answer.backend_file_url + '?token=' + token
|
||||
else:
|
||||
return eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={
|
||||
'answer': self.answer.pk,
|
||||
})
|
||||
return self.answer.frontend_file_url + '?token=' + token
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
# Browsers can't recognize that the server already has a file uploaded
|
||||
@@ -199,7 +190,7 @@ class UploadedFileWidget(forms.ClearableFileInput):
|
||||
|
||||
def format_value(self, value):
|
||||
if self.is_initial(value):
|
||||
return self.FakeFile(value, self.position, self.event, self.answer)
|
||||
return self.FakeFile(value, self.answer, self.request)
|
||||
|
||||
|
||||
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
@@ -314,3 +305,18 @@ class BusinessBooleanRadio(forms.RadioSelect):
|
||||
'False': False,
|
||||
False: False,
|
||||
}.get(value)
|
||||
|
||||
|
||||
class OptionAttrsSelect(forms.Select):
|
||||
def __init__(self, *args, option_attrs=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.option_attrs = option_attrs or {}
|
||||
|
||||
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
|
||||
option = super().create_option(
|
||||
name, value, label, selected, index, subindex=subindex, attrs=attrs
|
||||
)
|
||||
extra = self.option_attrs.get(str(value))
|
||||
if extra:
|
||||
option["attrs"].update(extra)
|
||||
return option
|
||||
|
||||
@@ -109,7 +109,7 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
|
||||
|
||||
|
||||
def get_babel_locale():
|
||||
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such as de_Informal
|
||||
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal
|
||||
# Also, this returns best-effort region information for number formatting etc
|
||||
current_language = translation.get_language()
|
||||
current_region = getattr(_active_region, "value", None)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 4.2.17 on 2025-01-01 20:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0307_devicelastseen"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CheckoutSession",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("cart_id", models.CharField(max_length=255, unique=True)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("testmode", models.BooleanField(default=False)),
|
||||
("session_data", models.JSONField(default=dict)),
|
||||
(
|
||||
"customer",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="checkout_sessions",
|
||||
to="pretixbase.customer",
|
||||
),
|
||||
),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="checkout_sessions",
|
||||
to="pretixbase.event",
|
||||
),
|
||||
),
|
||||
(
|
||||
"sales_channel",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="pretixbase.saleschannel",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="invoiceaddress",
|
||||
name="checkout_session",
|
||||
field=models.OneToOneField(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="invoice_address",
|
||||
to="pretixbase.checkoutsession",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.15 on 2026-08-07 20:01
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0308_checkoutsession_invoiceaddress_checkout_session'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='container_type',
|
||||
field=models.CharField(default='P', max_length=5),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='questionanswer',
|
||||
name='checkoutsession',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.checkoutsession'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='questionanswer',
|
||||
name='order',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.order'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='questionanswer',
|
||||
unique_together={('cartposition', 'question'), ('checkoutsession', 'question'), ('order', 'question'), ('orderposition', 'question')},
|
||||
),
|
||||
]
|
||||
@@ -33,6 +33,7 @@ from django.db.models.aggregates import Sum
|
||||
from django.db.models.expressions import OuterRef, Subquery
|
||||
from django.db.models.functions.comparison import Coalesce
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
@@ -409,6 +410,17 @@ class AttendeeProfile(models.Model):
|
||||
|
||||
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
|
||||
|
||||
@cached_property
|
||||
def answers_key_to_index(self):
|
||||
return {a.get('field_name'): i for i, a in enumerate(self.answers)}
|
||||
|
||||
def store_answer(self, answer_dict):
|
||||
k = answer_dict['field_name']
|
||||
if k in self.answers_key_to_index:
|
||||
self.answers[self.answers_key_to_index[k]] = answer_dict
|
||||
else:
|
||||
self.answers.append(answer_dict)
|
||||
|
||||
|
||||
def generate_client_id():
|
||||
return get_random_string(40)
|
||||
|
||||
@@ -736,7 +736,7 @@ class Event(EventMixin, LoggedModel):
|
||||
self.settings.mail_send_order_paid_attendee = True
|
||||
self.settings.mail_send_order_approved_attendee = True
|
||||
self.settings.mail_send_order_approved_free_attendee = True
|
||||
self.settings.mail_text_download_reminder_attendee = True
|
||||
self.settings.mail_send_download_reminder_attendee = True
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
|
||||
@@ -1606,6 +1606,9 @@ class Question(LoggedModel):
|
||||
:param dependency_values: The values that `dependency_question` needs to be set to for this question to be applicable.
|
||||
:type dependency_values: list[str]
|
||||
"""
|
||||
class ContainerType(models.TextChoices):
|
||||
ORDER = "O", _("Order")
|
||||
ORDERPOSITION = "P", _("Order position")
|
||||
TYPE_NUMBER = "N"
|
||||
TYPE_STRING = "S"
|
||||
TYPE_TEXT = "T"
|
||||
@@ -1641,6 +1644,12 @@ class Question(LoggedModel):
|
||||
related_name="questions",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
container_type = models.CharField(
|
||||
max_length=5,
|
||||
choices=ContainerType.choices,
|
||||
verbose_name=_("Asked on"),
|
||||
default=ContainerType.ORDERPOSITION,
|
||||
)
|
||||
question = I18nTextField(
|
||||
verbose_name=_("Question")
|
||||
)
|
||||
|
||||
@@ -1304,10 +1304,9 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
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,
|
||||
org=instance.event.organizer.slug,
|
||||
ev=instance.event.slug,
|
||||
secret=secret,
|
||||
filename=escape_uri_path(filename),
|
||||
)
|
||||
@@ -1336,6 +1335,14 @@ class QuestionAnswer(models.Model):
|
||||
'CartPosition', null=True, blank=True,
|
||||
related_name='answers', on_delete=models.CASCADE
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
'Order', null=True, blank=True,
|
||||
related_name='answers', on_delete=models.CASCADE
|
||||
)
|
||||
checkoutsession = models.ForeignKey(
|
||||
'CheckoutSession', null=True, blank=True,
|
||||
related_name='answers', on_delete=models.CASCADE
|
||||
)
|
||||
question = models.ForeignKey(
|
||||
Question, related_name='answers', on_delete=models.CASCADE
|
||||
)
|
||||
@@ -1351,16 +1358,21 @@ class QuestionAnswer(models.Model):
|
||||
objects = ScopedManager(organizer='question__event__organizer')
|
||||
|
||||
class Meta:
|
||||
unique_together = [['orderposition', 'question'], ['cartposition', 'question']]
|
||||
unique_together = [
|
||||
['orderposition', 'question'],
|
||||
['cartposition', 'question'],
|
||||
['order', 'question'],
|
||||
['checkoutsession', 'question'],
|
||||
]
|
||||
|
||||
@property
|
||||
def backend_file_url(self):
|
||||
if self.file:
|
||||
if self.orderposition:
|
||||
if self.associated_order:
|
||||
return reverse('control:event.order.download.answer', kwargs={
|
||||
'code': self.orderposition.order.code,
|
||||
'event': self.orderposition.order.event.slug,
|
||||
'organizer': self.orderposition.order.event.organizer.slug,
|
||||
'code': self.associated_order.code,
|
||||
'event': self.associated_order.event.slug,
|
||||
'organizer': self.associated_order.event.organizer.slug,
|
||||
'answer': self.pk,
|
||||
})
|
||||
return ""
|
||||
@@ -1370,14 +1382,14 @@ class QuestionAnswer(models.Model):
|
||||
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,
|
||||
if self.associated_order:
|
||||
url = eventreverse(self.associated_order.event, 'presale:event.order.download.answer', kwargs={
|
||||
'order': self.associated_order.code,
|
||||
'secret': self.associated_order.secret,
|
||||
'answer': self.pk,
|
||||
})
|
||||
else:
|
||||
url = eventreverse(self.cartposition.event, 'presale:event.cart.download.answer', kwargs={
|
||||
url = eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={
|
||||
'answer': self.pk,
|
||||
})
|
||||
|
||||
@@ -1392,6 +1404,24 @@ class QuestionAnswer(models.Model):
|
||||
def file_name(self):
|
||||
return self.file.name.split('.', 1)[-1]
|
||||
|
||||
@property
|
||||
def associated_order(self):
|
||||
if self.orderposition:
|
||||
return self.orderposition.order
|
||||
elif self.order:
|
||||
return self.order
|
||||
|
||||
@property
|
||||
def event(self):
|
||||
if self.orderposition:
|
||||
return self.orderposition.order.event
|
||||
elif self.cartposition:
|
||||
return self.cartposition.event
|
||||
elif self.order:
|
||||
return self.order.event
|
||||
elif self.checkoutsession:
|
||||
return self.checkoutsession.event
|
||||
|
||||
def __str__(self):
|
||||
return self.to_string(use_cached=True)
|
||||
|
||||
@@ -3177,6 +3207,39 @@ class Transaction(models.Model):
|
||||
return self.tax_value_includes_rounding_correction * self.count
|
||||
|
||||
|
||||
class CheckoutSession(models.Model):
|
||||
"""
|
||||
A checkout session optionally bundles cart positions with additional information. This is historically
|
||||
not required in pretix and currently only used in the Storefront API.
|
||||
"""
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
verbose_name=_("Event"),
|
||||
related_name="checkout_sessions",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
cart_id = models.CharField(
|
||||
max_length=255, unique=True,
|
||||
verbose_name=_("Cart ID (e.g. session key)"),
|
||||
)
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_("Date"),
|
||||
auto_now_add=True,
|
||||
)
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
related_name='checkout_sessions',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
sales_channel = models.ForeignKey(
|
||||
"SalesChannel",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
testmode = models.BooleanField(default=False)
|
||||
session_data = models.JSONField(default=dict)
|
||||
|
||||
|
||||
class CartPosition(AbstractPosition):
|
||||
"""
|
||||
A cart position is similar to an order line, except that it is not
|
||||
@@ -3381,6 +3444,13 @@ class CartPosition(AbstractPosition):
|
||||
|
||||
class InvoiceAddress(models.Model):
|
||||
last_modified = models.DateTimeField(auto_now=True)
|
||||
checkout_session = models.OneToOneField(
|
||||
CheckoutSession,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='invoice_address',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
|
||||
@@ -330,9 +330,24 @@ class BasePaymentProvider:
|
||||
payment method. This returns ``False`` by default which is no guarantee that
|
||||
aborting a pending payment can never happen, it just hides the frontend button
|
||||
to avoid users accidentally committing double payments.
|
||||
If the decision doesn't depend on the specific payment, then only implementing
|
||||
``abort_pending_allowed`` is enough, ``payment_abort_pending_allowed(payment: OrderPayment)``
|
||||
is expected to take this into account.
|
||||
As a consumer only evaluate ``payment_abort_pending_allowed(payment: OrderPayment)``
|
||||
to check if aborting this pending payment is possible.
|
||||
"""
|
||||
return False
|
||||
|
||||
def _payment_abort_pending_allowed(self, payment: OrderPayment) -> bool:
|
||||
"""
|
||||
Experimental: This might change during upcomming releases.
|
||||
Whether or not a user can abort a payment in pending state to switch to another
|
||||
payment method. This returns ``self.abort_pending_allowed`` by default which is
|
||||
no guarantee that aborting a pending payment can never happen, it just hides the
|
||||
frontend button to avoid users accidentally committing double payments.
|
||||
"""
|
||||
return self.abort_pending_allowed
|
||||
|
||||
@property
|
||||
def requires_invoice_immediately(self):
|
||||
"""
|
||||
@@ -1019,10 +1034,12 @@ class BasePaymentProvider:
|
||||
On success, you should set ``payment.state = OrderPayment.PAYMENT_STATE_CANCELED`` (or call the super method).
|
||||
On failure, you should raise a PaymentException.
|
||||
"""
|
||||
if payment.state == OrderPayment.PAYMENT_STATE_PENDING and not self.abort_pending_allowed:
|
||||
raise PaymentException(_(
|
||||
"This payment is already being processed and can not be canceled any more."
|
||||
))
|
||||
|
||||
if payment.state == OrderPayment.PAYMENT_STATE_PENDING:
|
||||
if not self._payment_abort_pending_allowed(payment):
|
||||
raise PaymentException(_(
|
||||
"This payment is already being processed and cannot be canceled any more."
|
||||
))
|
||||
|
||||
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||
payment.save(update_fields=['state'])
|
||||
|
||||
+373
-155
@@ -19,30 +19,245 @@
|
||||
# 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/>.
|
||||
#
|
||||
import copy
|
||||
import datetime
|
||||
import os
|
||||
import warnings
|
||||
from collections import namedtuple
|
||||
from typing import Union
|
||||
from dataclasses import dataclass
|
||||
from typing import (
|
||||
TYPE_CHECKING, Iterable, List, Literal, Optional, Tuple, Union, cast,
|
||||
)
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from dateutil import parser
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.forms.widgets import Widget
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.functional import Promise, lazy
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
BASE_CHOICES = (
|
||||
('date_from', _('Event start')),
|
||||
('date_to', _('Event end')),
|
||||
('date_admission', _('Event admission')),
|
||||
('presale_start', _('Presale start')),
|
||||
('presale_end', _('Presale end')),
|
||||
)
|
||||
from pretix.base.forms.widgets import OptionAttrsSelect
|
||||
|
||||
RelativeDate = namedtuple('RelativeDate', ['days', 'minutes', 'time', 'is_after', 'base_date_name'], defaults=(0, None, None, False, 'date_from'))
|
||||
if TYPE_CHECKING:
|
||||
from .models import Event, Order, SubEvent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BaseChoice:
|
||||
base: Literal["event", "order"]
|
||||
attribute: str
|
||||
text: Promise
|
||||
supports_before: bool
|
||||
supports_after: bool
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
key = f"{self.base}__{self.attribute}"
|
||||
return key
|
||||
|
||||
@staticmethod
|
||||
def find(objects: Iterable["BaseChoice"], key: str) -> "BaseChoice":
|
||||
if "__" in key:
|
||||
choice = next((obj for obj in objects if obj.key == key), None)
|
||||
else:
|
||||
# fallback for RelativeDateFields stored, before support for bases other than event was added
|
||||
choice = next((obj for obj in objects if obj.attribute == key and obj.base == "event"), None)
|
||||
|
||||
if choice is None:
|
||||
raise TypeError(f"key {key} must be a valid key in BASE_CHOICES")
|
||||
|
||||
return choice
|
||||
|
||||
|
||||
BASE_CHOICES: List[BaseChoice] = [
|
||||
BaseChoice('event', 'date_from', _('Event start'), True, True),
|
||||
BaseChoice('event', 'date_to', _('Event end'), True, True),
|
||||
BaseChoice('event', 'date_admission', _('Event admission'), True, True),
|
||||
BaseChoice('event', 'presale_start', _('Presale start'), True, True),
|
||||
BaseChoice('event', 'presale_end', _('Presale end'), True, True),
|
||||
BaseChoice('order', 'datetime', _('Order creation'), False, True),
|
||||
BaseChoice('order', 'expires', _('Order expiry'), True, True),
|
||||
]
|
||||
|
||||
EVENT_BASE_CHOICES = [
|
||||
x for x in BASE_CHOICES if x.base == 'event'
|
||||
]
|
||||
|
||||
ORDER_BASE_CHOICES = [
|
||||
x for x in BASE_CHOICES if x.base == 'order'
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RelativeDate:
|
||||
"""
|
||||
This contains information on a date that is defined in relation to a fixed base point.
|
||||
This means that the underlying data is a fixed date as the base point and a number of days or a time interval
|
||||
to calculate the date.
|
||||
|
||||
The list of valid base date choices is defined in BASE_CHOICES.
|
||||
If the base_date_name is not set, the date_from attribute of Event is used.
|
||||
"""
|
||||
|
||||
days: int = 0
|
||||
minutes: Optional[int] = None
|
||||
time: Optional[datetime.time] = None
|
||||
is_after: bool = False
|
||||
base_date_name: str = 'event__date_from__'
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.is_after and not self._choice.supports_after:
|
||||
raise ValueError(
|
||||
"The selected base date and attribute combination does not support relative dates placed after the base date"
|
||||
)
|
||||
if not self.is_after and not self._choice.supports_before:
|
||||
raise ValueError(
|
||||
"The selected base date and attribute combination does not support relative dates placed before the base date")
|
||||
|
||||
@property
|
||||
def _choice(self):
|
||||
return BaseChoice.find(BASE_CHOICES, self.base_date_name)
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
return self._choice.key
|
||||
|
||||
def __eq__(self, o: object) -> bool:
|
||||
if not isinstance(o, RelativeDate):
|
||||
return False
|
||||
return self.to_string() == o.to_string()
|
||||
|
||||
def _resolve_date(self, base: "Event | Order | SubEvent") -> Tuple[datetime.datetime, ZoneInfo]:
|
||||
"""
|
||||
Resolves the datetime and timezone information of this RelativeDate object in relation to the provided reference.
|
||||
|
||||
:param base: the reference which should be used to resolve the relative date
|
||||
:return:
|
||||
"""
|
||||
from .models import Event, Order, SubEvent
|
||||
|
||||
choice = self._choice
|
||||
|
||||
if choice.base == "order" and isinstance(base, Order):
|
||||
event = base.event
|
||||
base_date = getattr(base, choice.attribute)
|
||||
elif choice.base == "event" and isinstance(base, SubEvent):
|
||||
event = base.event
|
||||
base_date = (getattr(base, choice.attribute) or
|
||||
getattr(base.event, choice.attribute) or
|
||||
base.date_from)
|
||||
elif choice.base == "event" and isinstance(base, Event):
|
||||
event = base
|
||||
base_date = getattr(base, choice.attribute) or event.date_from
|
||||
else:
|
||||
raise TypeError("The base defined by data does not match the passed in base")
|
||||
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
return base_date, tz
|
||||
|
||||
def date(self, base: "Event | Order | SubEvent") -> datetime.date:
|
||||
"""
|
||||
Resolves the effective date of this RelativeDate object in relation to the provided reference.
|
||||
|
||||
:param base: the reference which should be used to resolve the date
|
||||
:return: datetime.date
|
||||
"""
|
||||
if self.minutes is not None:
|
||||
raise ValueError('A minute-based relative datetime can not be used as a date')
|
||||
|
||||
base_date, tz = self._resolve_date(base)
|
||||
|
||||
if self.is_after:
|
||||
new_date = base_date.astimezone(tz) + datetime.timedelta(days=self.days)
|
||||
else:
|
||||
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.days)
|
||||
return new_date.date()
|
||||
|
||||
def datetime(self, base: "Event | Order | SubEvent") -> datetime.datetime:
|
||||
"""
|
||||
Resolves the effective datetime of this RelativeDate object in relation to the provided reference.
|
||||
|
||||
:param base: the reference which should be used to resolve the datetime
|
||||
:return: datetime.datetime
|
||||
"""
|
||||
base_date, tz = self._resolve_date(base)
|
||||
|
||||
if self.minutes is not None:
|
||||
if self.is_after:
|
||||
return base_date.astimezone(tz) + datetime.timedelta(minutes=self.minutes)
|
||||
else:
|
||||
return base_date.astimezone(tz) - datetime.timedelta(minutes=self.minutes)
|
||||
else:
|
||||
if self.is_after:
|
||||
new_date = (base_date.astimezone(tz) + datetime.timedelta(days=self.days)).astimezone(tz)
|
||||
else:
|
||||
new_date = (base_date.astimezone(tz) - datetime.timedelta(days=self.days)).astimezone(tz)
|
||||
if self.time:
|
||||
new_date = new_date.replace(
|
||||
hour=self.time.hour,
|
||||
minute=self.time.minute,
|
||||
second=self.time.second
|
||||
)
|
||||
new_date = new_date.astimezone(tz)
|
||||
return new_date
|
||||
|
||||
def to_string(self) -> str:
|
||||
if self.minutes is not None:
|
||||
return 'RELDATE/minutes/{}/{}/{}'.format( #
|
||||
self.minutes,
|
||||
self._choice.key,
|
||||
'after' if self.is_after else '',
|
||||
)
|
||||
return 'RELDATE/{}/{}/{}/{}'.format( #
|
||||
self.days,
|
||||
self.time.strftime('%H:%M:%S') if self.time else '-',
|
||||
self._choice.key,
|
||||
'after' if self.is_after else '',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, input: str):
|
||||
if not input.startswith('RELDATE/'):
|
||||
raise TypeError("Invalid input for RelativeDate.from_string()")
|
||||
|
||||
parts = input.split('/')
|
||||
if parts[1] == 'minutes':
|
||||
data = RelativeDate(
|
||||
days=0,
|
||||
minutes=int(parts[2]),
|
||||
base_date_name=parts[3],
|
||||
time=None,
|
||||
is_after=len(parts) > 4 and parts[4] == "after",
|
||||
)
|
||||
else:
|
||||
if parts[2] == '-':
|
||||
time = None
|
||||
else:
|
||||
timeparts = parts[2].split(':')
|
||||
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
|
||||
try:
|
||||
data = RelativeDate(
|
||||
days=int(parts[1] or 0),
|
||||
base_date_name=parts[3],
|
||||
time=time,
|
||||
minutes=None,
|
||||
is_after=len(parts) > 4 and parts[4] == "after",
|
||||
)
|
||||
except ValueError:
|
||||
data = RelativeDate(
|
||||
days=0,
|
||||
base_date_name=parts[3],
|
||||
time=time,
|
||||
minutes=None,
|
||||
is_after=len(parts) > 4 and parts[4] == "after",
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class RelativeDateWrapper:
|
||||
@@ -50,130 +265,53 @@ class RelativeDateWrapper:
|
||||
This contains information on a date that might be relative to an event. This means
|
||||
that the underlying data is either a fixed date or a number of days and a wall clock
|
||||
time to calculate the date based on a base point.
|
||||
|
||||
The base point can be the date_from, date_to, date_admission, presale_start or presale_end
|
||||
attribute of an event or subevent. If the respective attribute is not set, ``date_from``
|
||||
will be used.
|
||||
"""
|
||||
|
||||
def __init__(self, data: Union[datetime.datetime, RelativeDate]):
|
||||
self.data = data
|
||||
|
||||
def date(self, event) -> datetime.date:
|
||||
from .models import SubEvent
|
||||
def date(self, base: "Event | Order | SubEvent") -> datetime.date:
|
||||
"""
|
||||
If the RelativeDateWrapper wraps a RelativeDate object:
|
||||
Resolves the effective date of this object in relation to the provided reference.
|
||||
If the RelativeDateWrapper wraps an absolute date or datetime:
|
||||
Returns the wrapped absolute date.
|
||||
|
||||
:param base: the reference which should be used to resolve the date in case it is a relative date.
|
||||
:return: datetime.date
|
||||
"""
|
||||
if isinstance(self.data, datetime.datetime):
|
||||
return self.data.date()
|
||||
elif isinstance(self.data, datetime.date):
|
||||
return self.data
|
||||
else:
|
||||
if self.data.minutes is not None:
|
||||
raise ValueError('A minute-based relative datetime can not be used as a date')
|
||||
return self.data.date(base)
|
||||
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
if isinstance(event, SubEvent):
|
||||
base_date = (
|
||||
getattr(event, self.data.base_date_name)
|
||||
or getattr(event.event, self.data.base_date_name)
|
||||
or event.date_from
|
||||
)
|
||||
else:
|
||||
base_date = getattr(event, self.data.base_date_name) or event.date_from
|
||||
|
||||
if self.data.is_after:
|
||||
new_date = base_date.astimezone(tz) + datetime.timedelta(days=self.data.days)
|
||||
else:
|
||||
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days)
|
||||
return new_date.date()
|
||||
|
||||
def datetime(self, event) -> datetime.datetime:
|
||||
from .models import SubEvent
|
||||
def datetime(self, base: "Event | Order | SubEvent") -> datetime.datetime:
|
||||
"""
|
||||
If the RelativeDateWrapper wraps a RelativeDate object:
|
||||
Resolves the effective datetime of this object in relation to the provided reference.
|
||||
If the RelativeDateWrapper wraps an absolute date or datetime:
|
||||
Returns the wrapped absolute datetime.
|
||||
|
||||
:param base: the reference which should be used to resolve the datetime in case it is a relative datetime.
|
||||
:return: datetime.datetime
|
||||
"""
|
||||
if isinstance(self.data, (datetime.datetime, datetime.date)):
|
||||
return self.data
|
||||
else:
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
if isinstance(event, SubEvent):
|
||||
base_date = (
|
||||
getattr(event, self.data.base_date_name)
|
||||
or getattr(event.event, self.data.base_date_name)
|
||||
or event.date_from
|
||||
)
|
||||
else:
|
||||
base_date = getattr(event, self.data.base_date_name) or event.date_from
|
||||
|
||||
if self.data.minutes is not None:
|
||||
if self.data.is_after:
|
||||
return base_date.astimezone(tz) + datetime.timedelta(minutes=self.data.minutes)
|
||||
else:
|
||||
return base_date.astimezone(tz) - datetime.timedelta(minutes=self.data.minutes)
|
||||
else:
|
||||
if self.data.is_after:
|
||||
new_date = (base_date.astimezone(tz) + datetime.timedelta(days=self.data.days)).astimezone(tz)
|
||||
else:
|
||||
new_date = (base_date.astimezone(tz) - datetime.timedelta(days=self.data.days)).astimezone(tz)
|
||||
if self.data.time:
|
||||
new_date = new_date.replace(
|
||||
hour=self.data.time.hour,
|
||||
minute=self.data.time.minute,
|
||||
second=self.data.time.second
|
||||
)
|
||||
new_date = new_date.astimezone(tz)
|
||||
return new_date
|
||||
return self.data.datetime(base)
|
||||
|
||||
def to_string(self) -> str:
|
||||
if isinstance(self.data, (datetime.datetime, datetime.date)):
|
||||
return self.data.isoformat()
|
||||
else:
|
||||
if self.data.minutes is not None:
|
||||
return 'RELDATE/minutes/{}/{}/{}'.format( #
|
||||
self.data.minutes,
|
||||
self.data.base_date_name,
|
||||
'after' if self.data.is_after else '',
|
||||
)
|
||||
return 'RELDATE/{}/{}/{}/{}'.format( #
|
||||
self.data.days,
|
||||
self.data.time.strftime('%H:%M:%S') if self.data.time else '-',
|
||||
self.data.base_date_name,
|
||||
'after' if self.data.is_after else '',
|
||||
)
|
||||
return self.data.to_string()
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, input: str):
|
||||
if input.startswith('RELDATE/'):
|
||||
parts = input.split('/')
|
||||
if parts[1] == 'minutes':
|
||||
data = RelativeDate(
|
||||
days=0,
|
||||
minutes=int(parts[2]),
|
||||
base_date_name=parts[3],
|
||||
time=None,
|
||||
is_after=len(parts) > 4 and parts[4] == "after",
|
||||
)
|
||||
else:
|
||||
if parts[2] == '-':
|
||||
time = None
|
||||
else:
|
||||
timeparts = parts[2].split(':')
|
||||
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
|
||||
try:
|
||||
data = RelativeDate(
|
||||
days=int(parts[1] or 0),
|
||||
base_date_name=parts[3],
|
||||
time=time,
|
||||
minutes=None,
|
||||
is_after=len(parts) > 4 and parts[4] == "after",
|
||||
)
|
||||
except ValueError:
|
||||
data = RelativeDate(
|
||||
days=0,
|
||||
base_date_name=parts[3],
|
||||
time=time,
|
||||
minutes=None,
|
||||
is_after=len(parts) > 4 and parts[4] == "after",
|
||||
)
|
||||
if data.base_date_name not in [k[0] for k in BASE_CHOICES]:
|
||||
raise ValueError('{} is not a valid base date'.format(data.base_date_name))
|
||||
data = RelativeDate.from_string(input)
|
||||
else:
|
||||
data = parser.parse(input)
|
||||
return RelativeDateWrapper(data)
|
||||
@@ -187,7 +325,6 @@ BEFORE_AFTER_CHOICE = (
|
||||
('after', _('after')),
|
||||
)
|
||||
|
||||
|
||||
reldatetimeparts = namedtuple('reldatetimeparts', (
|
||||
"status", # 0
|
||||
"absolute", # 1
|
||||
@@ -202,6 +339,14 @@ reldatetimeparts = namedtuple('reldatetimeparts', (
|
||||
reldatetimeparts.indizes = reldatetimeparts(*range(9))
|
||||
|
||||
|
||||
def _get_choices(base_choices: List[BaseChoice]) -> List[Tuple[str, Promise]]:
|
||||
return [(c.key, c.text) for c in base_choices]
|
||||
|
||||
|
||||
def _get_choice_validation_obj(choices: List[BaseChoice]):
|
||||
return {c.key: {"data-supports-before": c.supports_before, "data-supports-after": c.supports_after} for c in choices}
|
||||
|
||||
|
||||
class RelativeDateTimeWidget(forms.MultiWidget):
|
||||
template_name = 'pretixbase/forms/widgets/reldatetime.html'
|
||||
parts = reldatetimeparts
|
||||
@@ -209,6 +354,7 @@ class RelativeDateTimeWidget(forms.MultiWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.status_choices = kwargs.pop('status_choices')
|
||||
base_choices = kwargs.pop('base_choices')
|
||||
choices = _get_choices(base_choices)
|
||||
|
||||
def placeholder_datetime_format():
|
||||
df = get_format('DATETIME_INPUT_FORMATS')[0]
|
||||
@@ -220,21 +366,29 @@ class RelativeDateTimeWidget(forms.MultiWidget):
|
||||
tf = get_format('TIME_INPUT_FORMATS')[0]
|
||||
return datetime.time(8, 30, 0).strftime(tf)
|
||||
|
||||
widgets = reldatetimeparts(
|
||||
widgets = cast(dict[str, Widget | type[Widget]], cast(object, reldatetimeparts(
|
||||
status=forms.RadioSelect(choices=self.status_choices),
|
||||
absolute=forms.DateTimeInput(
|
||||
attrs={'placeholder': lazy(placeholder_datetime_format, str), 'class': 'datetimepicker'}
|
||||
),
|
||||
rel_days_number=forms.NumberInput(),
|
||||
rel_mins_relationto=forms.Select(choices=base_choices),
|
||||
rel_mins_relationto=OptionAttrsSelect(
|
||||
attrs={'data-relative-choice': True},
|
||||
choices=choices,
|
||||
option_attrs=_get_choice_validation_obj(base_choices)
|
||||
),
|
||||
rel_days_timeofday=forms.TimeInput(
|
||||
attrs={'placeholder': lazy(placeholder_time_format, str), 'class': 'timepickerfield'}
|
||||
),
|
||||
rel_mins_number=forms.NumberInput(),
|
||||
rel_days_relationto=forms.Select(choices=base_choices),
|
||||
rel_mins_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
|
||||
rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
|
||||
)
|
||||
rel_days_relationto=OptionAttrsSelect(
|
||||
attrs={'data-relative-choice': True},
|
||||
choices=choices,
|
||||
option_attrs=_get_choice_validation_obj(base_choices)
|
||||
),
|
||||
rel_mins_relation=forms.Select(attrs={'data-relation-choice': True}, choices=BEFORE_AFTER_CHOICE),
|
||||
rel_days_relation=forms.Select(attrs={'data-relation-choice': True}, choices=BEFORE_AFTER_CHOICE),
|
||||
)))
|
||||
super().__init__(widgets=widgets, *args, **kwargs)
|
||||
|
||||
def decompress(self, value):
|
||||
@@ -247,10 +401,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
|
||||
status="unset",
|
||||
absolute=None,
|
||||
rel_days_number=1,
|
||||
rel_mins_relationto="date_from",
|
||||
rel_mins_relationto="event__date_from",
|
||||
rel_days_timeofday=None,
|
||||
rel_mins_number=0,
|
||||
rel_days_relationto="date_from",
|
||||
rel_days_relationto="event__date_from",
|
||||
rel_mins_relation="before",
|
||||
rel_days_relation="before"
|
||||
)
|
||||
@@ -259,10 +413,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
|
||||
status="absolute",
|
||||
absolute=value.data,
|
||||
rel_days_number=1,
|
||||
rel_mins_relationto="date_from",
|
||||
rel_mins_relationto="event__date_from",
|
||||
rel_days_timeofday=None,
|
||||
rel_mins_number=0,
|
||||
rel_days_relationto="date_from",
|
||||
rel_days_relationto="event__date_from",
|
||||
rel_mins_relation="before",
|
||||
rel_days_relation="before"
|
||||
)
|
||||
@@ -271,10 +425,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
|
||||
status="relative_minutes",
|
||||
absolute=None,
|
||||
rel_days_number=None,
|
||||
rel_mins_relationto=value.data.base_date_name,
|
||||
rel_mins_relationto=value.data.key,
|
||||
rel_days_timeofday=None,
|
||||
rel_mins_number=value.data.minutes,
|
||||
rel_days_relationto=value.data.base_date_name,
|
||||
rel_days_relationto=value.data.key,
|
||||
rel_mins_relation="after" if value.data.is_after else "before",
|
||||
rel_days_relation="after" if value.data.is_after else "before"
|
||||
)
|
||||
@@ -282,10 +436,10 @@ class RelativeDateTimeWidget(forms.MultiWidget):
|
||||
status="relative",
|
||||
absolute=None,
|
||||
rel_days_number=value.data.days,
|
||||
rel_mins_relationto=value.data.base_date_name,
|
||||
rel_mins_relationto=value.data.key,
|
||||
rel_days_timeofday=value.data.time,
|
||||
rel_mins_number=0,
|
||||
rel_days_relationto=value.data.base_date_name,
|
||||
rel_days_relationto=value.data.key,
|
||||
rel_mins_relation="after" if value.data.is_after else "before",
|
||||
rel_days_relation="after" if value.data.is_after else "before"
|
||||
)
|
||||
@@ -309,17 +463,41 @@ class RelativeDateTimeField(forms.MultiValueField):
|
||||
('relative', _('Relative date:')),
|
||||
('relative_minutes', _('Relative time:')),
|
||||
]
|
||||
self.relative_to_order = kwargs.pop('relative_to_order', False)
|
||||
|
||||
possible_choices = copy.deepcopy(EVENT_BASE_CHOICES)
|
||||
if self.relative_to_order:
|
||||
possible_choices.extend(ORDER_BASE_CHOICES)
|
||||
|
||||
if kwargs.get('limit_choices'):
|
||||
limit = kwargs.pop('limit_choices')
|
||||
choices = [(k, v) for k, v in BASE_CHOICES if k in limit]
|
||||
else:
|
||||
choices = BASE_CHOICES
|
||||
if any(["__" not in l for l in limit]):
|
||||
_warn_skips = (str(os.path.dirname(__file__)),)
|
||||
|
||||
warnings.warn(
|
||||
message="Please prefix limit_choices with the base the attributes refer to, for example event__date_from",
|
||||
category=DeprecationWarning,
|
||||
skip_file_prefixes=_warn_skips
|
||||
)
|
||||
|
||||
possible_choices = [
|
||||
c for c in possible_choices if
|
||||
# new base case as we want limit_choices to be expressed as base__attribute
|
||||
(c.key in limit) or
|
||||
# fallback for old event based entries
|
||||
# if the base is an event, then using only attribute is fine
|
||||
(c.base == "event" and c.attribute in limit)
|
||||
]
|
||||
|
||||
if not kwargs.get('required', True):
|
||||
status_choices.insert(0, ('unset', _('Not set')))
|
||||
|
||||
choices = _get_choices(possible_choices)
|
||||
|
||||
fields = reldatetimeparts(
|
||||
status=forms.ChoiceField(
|
||||
choices=status_choices,
|
||||
required=True
|
||||
required=True,
|
||||
),
|
||||
absolute=forms.DateTimeField(
|
||||
required=False
|
||||
@@ -329,7 +507,7 @@ class RelativeDateTimeField(forms.MultiValueField):
|
||||
),
|
||||
rel_mins_relationto=forms.ChoiceField(
|
||||
choices=choices,
|
||||
required=False
|
||||
required=False,
|
||||
),
|
||||
rel_days_timeofday=forms.TimeField(
|
||||
required=False,
|
||||
@@ -339,7 +517,7 @@ class RelativeDateTimeField(forms.MultiValueField):
|
||||
),
|
||||
rel_days_relationto=forms.ChoiceField(
|
||||
choices=choices,
|
||||
required=False
|
||||
required=False,
|
||||
),
|
||||
rel_mins_relation=forms.ChoiceField(
|
||||
choices=BEFORE_AFTER_CHOICE,
|
||||
@@ -350,8 +528,9 @@ class RelativeDateTimeField(forms.MultiValueField):
|
||||
required=False
|
||||
),
|
||||
)
|
||||
|
||||
if 'widget' not in kwargs:
|
||||
kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=choices)
|
||||
kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=possible_choices)
|
||||
kwargs.pop('max_length', 0)
|
||||
kwargs.pop('empty_value', 0)
|
||||
super().__init__(
|
||||
@@ -359,21 +538,24 @@ class RelativeDateTimeField(forms.MultiValueField):
|
||||
)
|
||||
|
||||
def set_event(self, event):
|
||||
self.widget.widgets[reldatetimeparts.indizes.rel_days_relationto].choices = [
|
||||
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
|
||||
]
|
||||
self.widget.widgets[reldatetimeparts.indizes.rel_mins_relationto].choices = [
|
||||
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
|
||||
]
|
||||
possible_choices = copy.deepcopy(EVENT_BASE_CHOICES)
|
||||
if self.relative_to_order:
|
||||
possible_choices.extend(ORDER_BASE_CHOICES)
|
||||
|
||||
possible_choices = possible_choices
|
||||
choices = _get_choices(possible_choices)
|
||||
|
||||
self.widget.widgets[reldatetimeparts.indizes.rel_days_relationto].choices = choices
|
||||
self.widget.widgets[reldatetimeparts.indizes.rel_mins_relationto].choices = choices
|
||||
|
||||
def compress(self, data_list):
|
||||
if not data_list:
|
||||
return None
|
||||
data = reldatetimeparts(*data_list)
|
||||
if data.status == 'absolute':
|
||||
return RelativeDateWrapper(data.absolute)
|
||||
elif data.status == 'unset':
|
||||
if data.status == 'unset':
|
||||
return None
|
||||
elif data.status == 'absolute':
|
||||
return RelativeDateWrapper(data.absolute)
|
||||
elif data.status == 'relative_minutes':
|
||||
return RelativeDateWrapper(RelativeDate(
|
||||
days=0,
|
||||
@@ -404,6 +586,18 @@ class RelativeDateTimeField(forms.MultiValueField):
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
elif data.status == 'relative_minutes' and (data.rel_mins_number is None or not data.rel_mins_relationto):
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
elif data.status == 'relative':
|
||||
choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto)
|
||||
if data.rel_days_relation == "before" and not choice.supports_before:
|
||||
raise ValidationError(_('A relative date cannot be expressed as "before" for "{}"'.format(choice.text)))
|
||||
elif data.status == 'relative' and data.rel_days_relation == "after" and not choice.supports_after:
|
||||
raise ValidationError(_('A relative date cannot be expressed as "after" for "{}"'.format(choice.text)))
|
||||
elif data.status == 'relative_minutes':
|
||||
choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto)
|
||||
if data.rel_days_relation == "before" and not choice.supports_before:
|
||||
raise ValidationError(_('A relative time cannot be expressed as "before" for "{}"'.format(choice.text)))
|
||||
elif data.rel_days_relation == "after" and not choice.supports_after:
|
||||
raise ValidationError(_('A relative time cannot be expressed as "after" for "{}"'.format(choice.text)))
|
||||
|
||||
return super().clean(value)
|
||||
|
||||
@@ -424,15 +618,20 @@ class RelativeDateWidget(RelativeDateTimeWidget):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.status_choices = kwargs.pop('status_choices')
|
||||
widgets = reldateparts(
|
||||
self.base_choices = kwargs.pop('base_choices')
|
||||
widgets = cast(dict[str, Widget | type[Widget]], cast(object, reldateparts(
|
||||
status=forms.RadioSelect(choices=self.status_choices),
|
||||
absolute=forms.DateInput(
|
||||
attrs={'class': 'datepickerfield'}
|
||||
),
|
||||
rel_days_number=forms.NumberInput(),
|
||||
rel_days_relationto=forms.Select(choices=kwargs.pop('base_choices')),
|
||||
rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
|
||||
)
|
||||
rel_days_relationto=OptionAttrsSelect(
|
||||
choices=self.base_choices,
|
||||
option_attrs=_get_choice_validation_obj(self.base_choices),
|
||||
attrs={'data-relative-choice': True},
|
||||
),
|
||||
rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE, attrs={'data-relation-choice': True},),
|
||||
)))
|
||||
forms.MultiWidget.__init__(self, widgets=widgets, *args, **kwargs)
|
||||
|
||||
def decompress(self, value):
|
||||
@@ -460,7 +659,7 @@ class RelativeDateWidget(RelativeDateTimeWidget):
|
||||
status="relative",
|
||||
absolute=None,
|
||||
rel_days_number=value.data.days,
|
||||
rel_days_relationto=value.data.base_date_name,
|
||||
rel_days_relationto=value.data.key,
|
||||
rel_days_relation="after" if value.data.is_after else "before"
|
||||
)
|
||||
|
||||
@@ -474,6 +673,15 @@ class RelativeDateField(RelativeDateTimeField):
|
||||
]
|
||||
if not kwargs.get('required', True):
|
||||
status_choices.insert(0, ('unset', _('Not set')))
|
||||
|
||||
self.relative_to_order = kwargs.pop('relative_to_order', False)
|
||||
|
||||
possible_choices = copy.deepcopy(EVENT_BASE_CHOICES)
|
||||
if self.relative_to_order:
|
||||
possible_choices.extend(ORDER_BASE_CHOICES)
|
||||
|
||||
choices = _get_choices(possible_choices)
|
||||
|
||||
fields = reldateparts(
|
||||
status=forms.ChoiceField(
|
||||
choices=status_choices,
|
||||
@@ -486,33 +694,37 @@ class RelativeDateField(RelativeDateTimeField):
|
||||
required=False
|
||||
),
|
||||
rel_days_relationto=forms.ChoiceField(
|
||||
choices=BASE_CHOICES,
|
||||
required=False
|
||||
choices=choices,
|
||||
required=False,
|
||||
),
|
||||
rel_days_relation=forms.ChoiceField(
|
||||
choices=BEFORE_AFTER_CHOICE,
|
||||
required=False
|
||||
),
|
||||
)
|
||||
|
||||
if 'widget' not in kwargs:
|
||||
kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=BASE_CHOICES)
|
||||
kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=possible_choices)
|
||||
forms.MultiValueField.__init__(
|
||||
self, fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
|
||||
def set_event(self, event):
|
||||
self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = [
|
||||
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
|
||||
choices = [
|
||||
(c.key, c.text) for c in EVENT_BASE_CHOICES if getattr(event, c.attribute, None)
|
||||
]
|
||||
if self.relative_to_order:
|
||||
choices += [(c.key, c.text) for c in ORDER_BASE_CHOICES]
|
||||
self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = choices
|
||||
|
||||
def compress(self, data_list):
|
||||
if not data_list:
|
||||
return None
|
||||
data = reldateparts(*data_list)
|
||||
if data.status == 'absolute':
|
||||
return RelativeDateWrapper(data.absolute)
|
||||
elif data.status == 'unset':
|
||||
if data.status == 'unset':
|
||||
return None
|
||||
elif data.status == 'absolute':
|
||||
return RelativeDateWrapper(data.absolute)
|
||||
else:
|
||||
return RelativeDateWrapper(RelativeDate(
|
||||
days=data.rel_days_number,
|
||||
@@ -525,8 +737,14 @@ class RelativeDateField(RelativeDateTimeField):
|
||||
data = reldateparts(*value)
|
||||
if data.status == 'absolute' and not data.absolute:
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
elif data.status == 'relative' and (data.rel_days_number is None or not data.rel_days_relationto):
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
if data.status == 'relative':
|
||||
choice = BaseChoice.find(BASE_CHOICES, data.rel_days_relationto)
|
||||
if data.rel_days_number is None or not data.rel_days_relationto:
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
elif data.rel_days_relation == "before" and not choice.supports_before:
|
||||
raise ValidationError(_("A relative date cannot be expressed as 'before' for '{}'".format(choice.text)))
|
||||
elif data.rel_days_relation == "after" and not choice.supports_after:
|
||||
raise ValidationError(_("A relative date cannot be expressed as 'after' for '{}'".format(choice.text)))
|
||||
|
||||
return forms.MultiValueField.clean(self, value)
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ from pretix.base.models import (
|
||||
Seat, SeatCategoryMapping, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import OrderFee
|
||||
from pretix.base.models.orders import CheckoutSession, OrderFee
|
||||
from pretix.base.models.tax import TaxRule
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.checkin import _save_answers
|
||||
@@ -472,6 +472,16 @@ class CartManager:
|
||||
if term_last < time_machine_now(self.real_now_dt):
|
||||
raise CartError(error_messages['payment_ended'])
|
||||
|
||||
def _ensure_checkout_session(self):
|
||||
CheckoutSession.objects.get_or_create(
|
||||
event=self.event,
|
||||
cart_id=self.cart_id,
|
||||
defaults={
|
||||
"sales_channel": self._sales_channel,
|
||||
"testmode": self.event.testmode,
|
||||
},
|
||||
)
|
||||
|
||||
def _extend_expiry_of_valid_existing_positions(self):
|
||||
# real_now_dt is initialized at CartManager instantiation, so it's slightly in the past. Add a small
|
||||
# delta to reduce risk of extending already expired CartPositions.
|
||||
@@ -1559,6 +1569,7 @@ class CartManager:
|
||||
|
||||
def commit(self):
|
||||
self._check_presale_dates()
|
||||
self._ensure_checkout_session()
|
||||
self._check_max_cart_size()
|
||||
|
||||
err = self._delete_out_of_timeframe()
|
||||
|
||||
@@ -892,6 +892,7 @@ def _save_answers(op, answers, given_answers):
|
||||
qa.answer = answer
|
||||
qa.save(update_fields=['answer'])
|
||||
qa.options.clear()
|
||||
return qa
|
||||
|
||||
written = False
|
||||
for q, a in given_answers.items():
|
||||
|
||||
@@ -33,6 +33,7 @@ from pretix.base.models.customers import CustomerSSOGrant
|
||||
|
||||
from ..models import CachedFile, CartPosition, InvoiceAddress
|
||||
from ..models.auth import UserKnownLoginSource
|
||||
from ..models.orders import CheckoutSession
|
||||
from ..signals import periodic_task
|
||||
|
||||
|
||||
@@ -43,6 +44,10 @@ def clean_cart_positions(sender, **kwargs):
|
||||
cp.delete()
|
||||
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
|
||||
cp.delete()
|
||||
for cs in CheckoutSession.objects.filter(created__lt=now() - timedelta(days=14)).exclude(
|
||||
Exists(CartPosition.objects.filter(cart_id=OuterRef("cart_id")))
|
||||
):
|
||||
cs.delete()
|
||||
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
|
||||
ia.delete()
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.event import Event_SettingsStore, SubEvent
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
|
||||
generate_secret,
|
||||
BlockedTicketSecret, CheckoutSession, InvoiceAddress, OrderFee,
|
||||
OrderRefund, generate_secret,
|
||||
)
|
||||
from pretix.base.models.organizer import SalesChannel, TeamAPIToken
|
||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||
@@ -1030,7 +1030,8 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
|
||||
def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
|
||||
address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
|
||||
customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None):
|
||||
customer=None, valid_if_pending=False, api_meta: dict=None, tax_rounding_mode=None,
|
||||
cart_id: str=None):
|
||||
payments = []
|
||||
|
||||
try:
|
||||
@@ -1113,6 +1114,14 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
|
||||
if meta_info:
|
||||
for msg in meta_info.get('confirm_messages', []):
|
||||
order.log_action('pretix.event.order.consent', data={'msg': msg})
|
||||
if cart_id:
|
||||
try:
|
||||
session = CheckoutSession.objects.get(event=event, cart_id=cart_id)
|
||||
except CheckoutSession.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
session.answers.update(order=order, checkoutsession=None)
|
||||
session.delete()
|
||||
|
||||
order_placed.send(event, order=order, bulk=False)
|
||||
return order, payments
|
||||
@@ -1160,7 +1169,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
|
||||
|
||||
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
|
||||
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
|
||||
shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None):
|
||||
shown_total=None, customer=None, api_meta: dict=None, tax_rounding_mode=None, cart_id: str=None):
|
||||
for p in payment_requests:
|
||||
p['pprov'] = event.get_payment_providers(cached=True)[p['provider']]
|
||||
if not p['pprov']:
|
||||
@@ -1267,6 +1276,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
|
||||
valid_if_pending=valid_if_pending,
|
||||
api_meta=api_meta,
|
||||
tax_rounding_mode=tax_rounding_mode,
|
||||
cart_id=cart_id,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -3190,12 +3200,12 @@ class OrderChangeManager:
|
||||
def perform_order(self, event: Event, payments: List[dict], positions: List[str],
|
||||
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
|
||||
sales_channel: str='web', shown_total=None, customer=None, override_now_dt: datetime=None,
|
||||
api_meta: dict=None):
|
||||
api_meta: dict=None, cart_id: str=None):
|
||||
with language(locale), time_machine_now_assigned(override_now_dt):
|
||||
try:
|
||||
try:
|
||||
return _perform_order(event, payments, positions, email, locale, address, meta_info,
|
||||
sales_channel, shown_total, customer, api_meta)
|
||||
sales_channel, shown_total, customer, api_meta, cart_id=cart_id)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
|
||||
@@ -193,7 +193,7 @@ class QuotaAvailability:
|
||||
lock_name = '_'.join([str(p) for p in sorted([q.pk for q in quotas])])
|
||||
if rc.exists(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}'):
|
||||
return
|
||||
rc.setex(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}', '1', 10)
|
||||
rc.set(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}', '1', ex=10)
|
||||
|
||||
update = defaultdict(list)
|
||||
for q in quotas:
|
||||
|
||||
@@ -38,7 +38,6 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,43 +90,36 @@ def generate(model: str, pk: int, provider: str):
|
||||
class DummyRollbackException(Exception):
|
||||
pass
|
||||
|
||||
def get_preview_position(event):
|
||||
connection = transaction.get_connection()
|
||||
if not connection.in_atomic_block:
|
||||
raise RuntimeError("get_preview_position needs to be called in a rolledback_transaction")
|
||||
|
||||
item = event.items.create(name=_("Sample product"), default_price=Decimal('42.23'),
|
||||
description=_("Sample product description"))
|
||||
item2 = event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40'))
|
||||
|
||||
from pretix.base.models import Order
|
||||
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
|
||||
email='sample@pretix.eu',
|
||||
locale=event.settings.locale,
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
expires=now(), code="PREVIEW1234", total=119)
|
||||
|
||||
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
sample = {k: str(v) for k, v in scheme['sample'].items()}
|
||||
position = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price)
|
||||
s = event.subevents.first()
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=position, subevent=s)
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=position, subevent=s)
|
||||
|
||||
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))
|
||||
return position
|
||||
|
||||
def preview(event: int, provider: str, provider_arguments: dict = {}):
|
||||
def preview(event: int, provider: str):
|
||||
event = Event.objects.get(id=event)
|
||||
|
||||
with rolledback_transaction(), language(event.settings.locale, event.settings.region):
|
||||
p = get_preview_position(event)
|
||||
item = event.items.create(name=_("Sample product"), default_price=Decimal('42.23'),
|
||||
description=_("Sample product description"))
|
||||
item2 = event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40'))
|
||||
|
||||
from pretix.base.models import Order
|
||||
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
|
||||
email='sample@pretix.eu',
|
||||
locale=event.settings.locale,
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
expires=now(), code="PREVIEW1234", total=119)
|
||||
|
||||
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
sample = {k: str(v) for k, v in scheme['sample'].items()}
|
||||
p = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price)
|
||||
s = event.subevents.first()
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p, subevent=s)
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p, subevent=s)
|
||||
|
||||
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))
|
||||
|
||||
responses = register_ticket_outputs.send(event)
|
||||
for receiver, response in responses:
|
||||
prov = response(event)
|
||||
if prov.identifier == provider:
|
||||
return prov.generate(p, **provider_arguments)
|
||||
return prov.generate(p)
|
||||
|
||||
|
||||
def get_tickets_for_order(order, base_position=None):
|
||||
|
||||
@@ -114,7 +114,7 @@ class BaseTicketOutput:
|
||||
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
|
||||
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
|
||||
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
|
||||
If you want, you can just iterate over ``self.get_tickets_to_print`` which applies the
|
||||
If you want, you can just iterate over ``order.positions_with_tickets`` which applies the
|
||||
appropriate filters for you.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
@@ -192,17 +192,6 @@ class BaseTicketOutput:
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_meta(self) -> bool:
|
||||
"""
|
||||
Returns whether or whether not this output is a "meta" output that only works as a settings holder
|
||||
and should never be used directly. This is a trick to implement outputs with multiple formats but
|
||||
unified settings.
|
||||
|
||||
.. note:: You should set is_enabled to False for meta outputs.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def download_button_text(self) -> str:
|
||||
"""
|
||||
|
||||
+112
-72
@@ -32,18 +32,21 @@ from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware
|
||||
|
||||
from pretix.base.forms.questions import (
|
||||
BaseInvoiceAddressForm, BaseInvoiceNameForm, BaseQuestionsForm,
|
||||
BaseInvoiceAddressForm, BaseInvoiceNameForm, OrderLevelQuestionsForm,
|
||||
TicketLevelQuestionsForm,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
|
||||
QuestionOption,
|
||||
)
|
||||
from pretix.base.models.customers import AttendeeProfile
|
||||
from pretix.base.models.orders import CheckoutSession, Order
|
||||
from pretix.presale.signals import contact_form_fields_overrides
|
||||
|
||||
|
||||
class BaseQuestionsViewMixin:
|
||||
form_class = BaseQuestionsForm
|
||||
order_form_class = OrderLevelQuestionsForm
|
||||
orderposition_form_class = TicketLevelQuestionsForm
|
||||
all_optional = False
|
||||
|
||||
@cached_property
|
||||
@@ -56,6 +59,28 @@ class BaseQuestionsViewMixin:
|
||||
def question_form_kwargs(self, cr):
|
||||
return {}
|
||||
|
||||
@property
|
||||
def order_question_container(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@cached_property
|
||||
def order_questions_form(self):
|
||||
container = self.order_question_container
|
||||
if container is None:
|
||||
return None
|
||||
kwargs = {} # self.question_form_kwargs(cr)
|
||||
form = self.order_form_class(
|
||||
event=self.request.event,
|
||||
prefix='order',
|
||||
request=self.request,
|
||||
container=container,
|
||||
all_optional=self.all_optional,
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
files=(self.request.FILES if self.request.method == 'POST' else None),
|
||||
**kwargs
|
||||
)
|
||||
return form
|
||||
|
||||
@cached_property
|
||||
def forms(self):
|
||||
"""
|
||||
@@ -69,15 +94,17 @@ class BaseQuestionsViewMixin:
|
||||
orderpos = cr if isinstance(cr, OrderPosition) else None
|
||||
|
||||
kwargs = self.question_form_kwargs(cr)
|
||||
form = self.form_class(event=self.request.event,
|
||||
prefix=cr.id,
|
||||
request=self.request,
|
||||
cartpos=cartpos,
|
||||
orderpos=orderpos,
|
||||
all_optional=self.all_optional,
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
files=(self.request.FILES if self.request.method == 'POST' else None),
|
||||
**kwargs)
|
||||
form = self.orderposition_form_class(
|
||||
event=self.request.event,
|
||||
prefix=cr.id,
|
||||
request=self.request,
|
||||
cartpos=cartpos,
|
||||
orderpos=orderpos,
|
||||
all_optional=self.all_optional,
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
files=(self.request.FILES if self.request.method == 'POST' else None),
|
||||
**kwargs
|
||||
)
|
||||
form.pos = cartpos or orderpos
|
||||
form.show_copy_answers_to_addon_button = form.pos.addon_to and (
|
||||
set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or
|
||||
@@ -130,8 +157,38 @@ class BaseQuestionsViewMixin:
|
||||
|
||||
def save(self):
|
||||
failed = False
|
||||
if self.order_questions_form:
|
||||
if not self.order_questions_form.is_valid():
|
||||
failed = True
|
||||
else:
|
||||
checkoutsession = self.order_question_container if isinstance(self.order_question_container, CheckoutSession) else None
|
||||
order = self.order_question_container if isinstance(self.order_question_container, Order) else None
|
||||
for k, v in self.order_questions_form.cleaned_data.items():
|
||||
if k.startswith('question_'):
|
||||
field = self.order_questions_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) \
|
||||
or (isinstance(v, QuerySet) and not v.exists()):
|
||||
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 != '' and v is not None:
|
||||
self._upsert_answer(
|
||||
field, v,
|
||||
checkoutsession=checkoutsession,
|
||||
order=order,
|
||||
question=field.question,
|
||||
)
|
||||
|
||||
for form in self.forms:
|
||||
meta_info = form.pos.meta_info_data
|
||||
cartposition = form.pos if isinstance(form.pos, CartPosition) else None
|
||||
orderposition = form.pos if isinstance(form.pos, OrderPosition) else None
|
||||
# Every form represents a CartPosition or OrderPosition with questions attached
|
||||
if not form.is_valid():
|
||||
failed = True
|
||||
@@ -140,10 +197,8 @@ class BaseQuestionsViewMixin:
|
||||
prof = AttendeeProfile.objects.filter(
|
||||
customer=self.cart_customer, pk=form.cleaned_data.get('saved_id')
|
||||
).first() or AttendeeProfile(customer=getattr(self, 'cart_customer', None))
|
||||
answers_key_to_index = {a.get('field_name'): i for i, a in enumerate(prof.answers)}
|
||||
else:
|
||||
prof = AttendeeProfile(customer=getattr(self, 'cart_customer', None))
|
||||
answers_key_to_index = {}
|
||||
|
||||
# This form was correctly filled, so we store the data as
|
||||
# answers to the questions / in the CartPosition object
|
||||
@@ -181,64 +236,19 @@ class BaseQuestionsViewMixin:
|
||||
else:
|
||||
self._save_to_answer(field, field.answer, v)
|
||||
field.answer.save()
|
||||
if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
|
||||
answer_value = {o.identifier: str(o) for o in field.answer.options.all()}
|
||||
elif isinstance(field, forms.BooleanField):
|
||||
answer_value = bool(field.answer.answer)
|
||||
else:
|
||||
answer_value = str(field.answer.answer)
|
||||
answer_dict = {
|
||||
'field_name': k,
|
||||
'field_label': str(field.label),
|
||||
'value': answer_value,
|
||||
'question_type': field.question.type,
|
||||
'question_identifier': field.question.identifier,
|
||||
}
|
||||
if k in answers_key_to_index:
|
||||
prof.answers[answers_key_to_index[k]] = answer_dict
|
||||
else:
|
||||
prof.answers.append(answer_dict)
|
||||
|
||||
answer_dict = self._build_answer_dict(field, field.answer, k)
|
||||
prof.store_answer(answer_dict)
|
||||
elif v != '' and v is not None:
|
||||
answer = QuestionAnswer(
|
||||
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
|
||||
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
|
||||
answer = self._upsert_answer(
|
||||
field, v,
|
||||
cartposition=cartposition,
|
||||
orderposition=orderposition,
|
||||
question=field.question,
|
||||
)
|
||||
try:
|
||||
self._save_to_answer(field, answer, v)
|
||||
answer.save()
|
||||
except IntegrityError:
|
||||
# Since we prefill ``field.answer`` at form creation time, there's a possible race condition
|
||||
# here if the users submits their save request a second time while the first one is still running,
|
||||
# thus leading to duplicate QuestionAnswer objects. Since Django doesn't support UPSERT, the "proper"
|
||||
# fix would be a transaction with select_for_update(), or at least fetching using get_or_create here
|
||||
# again. However, both of these approaches have a significant performance overhead for *all* requests,
|
||||
# while the issue happens very very rarely. So we opt for just catching the error and retrying properly.
|
||||
answer = QuestionAnswer.objects.get(
|
||||
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()
|
||||
|
||||
if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
|
||||
answer_value = {o.identifier: str(o) for o in answer.options.all()}
|
||||
elif isinstance(field, forms.BooleanField):
|
||||
answer_value = bool(answer.answer)
|
||||
else:
|
||||
answer_value = str(answer.answer)
|
||||
answer_dict = {
|
||||
'field_name': k,
|
||||
'field_label': str(field.label),
|
||||
'value': answer_value,
|
||||
'question_type': field.question.type,
|
||||
'question_identifier': field.question.identifier,
|
||||
}
|
||||
if k in answers_key_to_index:
|
||||
prof.answers[answers_key_to_index[k]] = answer_dict
|
||||
else:
|
||||
prof.answers.append(answer_dict)
|
||||
answer_dict = self._build_answer_dict(field, answer, k)
|
||||
prof.store_answer(answer_dict)
|
||||
|
||||
else:
|
||||
field = form.fields[k]
|
||||
@@ -257,10 +267,7 @@ class BaseQuestionsViewMixin:
|
||||
'question_type': None,
|
||||
'question_identifier': None,
|
||||
}
|
||||
if k in answers_key_to_index:
|
||||
prof.answers[answers_key_to_index[k]] = answer_dict
|
||||
else:
|
||||
prof.answers.append(answer_dict)
|
||||
prof.store_answer(answer_dict)
|
||||
|
||||
form.pos.meta_info = json.dumps(meta_info)
|
||||
form.pos.save()
|
||||
@@ -271,6 +278,23 @@ class BaseQuestionsViewMixin:
|
||||
|
||||
return not failed
|
||||
|
||||
def _upsert_answer(self, field, v, **answer_kwargs):
|
||||
answer = QuestionAnswer(**answer_kwargs)
|
||||
try:
|
||||
self._save_to_answer(field, answer, v)
|
||||
answer.save()
|
||||
except IntegrityError:
|
||||
# Since we prefill ``field.answer`` at form creation time, there's a possible race condition
|
||||
# here if the users submits their save request a second time while the first one is still running,
|
||||
# thus leading to duplicate QuestionAnswer objects. Since Django doesn't support UPSERT, the "proper"
|
||||
# fix would be a transaction with select_for_update(), or at least fetching using get_or_create here
|
||||
# again. However, both of these approaches have a significant performance overhead for *all* requests,
|
||||
# while the issue happens very very rarely. So we opt for just catching the error and retrying properly.
|
||||
answer = QuestionAnswer.objects.get(**answer_kwargs)
|
||||
self._save_to_answer(field, answer, v)
|
||||
answer.save()
|
||||
return answer
|
||||
|
||||
def _save_to_answer(self, field, answer, value):
|
||||
if isinstance(field, forms.ModelMultipleChoiceField):
|
||||
answstr = ", ".join([str(o) for o in value])
|
||||
@@ -294,6 +318,21 @@ class BaseQuestionsViewMixin:
|
||||
else:
|
||||
answer.answer = value
|
||||
|
||||
def _build_answer_dict(self, field, answer, k):
|
||||
if isinstance(field, forms.ModelMultipleChoiceField) or isinstance(field, forms.ModelChoiceField):
|
||||
answer_value = {o.identifier: str(o) for o in answer.options.all()}
|
||||
elif isinstance(field, forms.BooleanField):
|
||||
answer_value = bool(answer.answer)
|
||||
else:
|
||||
answer_value = str(answer.answer)
|
||||
return {
|
||||
'field_name': k,
|
||||
'field_label': str(field.label),
|
||||
'value': answer_value,
|
||||
'question_type': field.question.type,
|
||||
'question_identifier': field.question.identifier,
|
||||
}
|
||||
|
||||
|
||||
class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
invoice_form_class = BaseInvoiceAddressForm
|
||||
@@ -309,7 +348,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
def positions(self):
|
||||
qqs = self.request.event.questions.all()
|
||||
if self.only_user_visible:
|
||||
qqs = qqs.filter(ask_during_checkin=False, hidden=False)
|
||||
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
|
||||
return list(self.order.positions.select_related(
|
||||
'item', 'variation'
|
||||
).prefetch_related(
|
||||
@@ -397,6 +436,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['order'] = self.order
|
||||
ctx['order_questions_form'] = self.order_questions_form
|
||||
ctx['formgroups'] = self.formdict.items()
|
||||
ctx['invoice_form'] = self.invoice_form
|
||||
ctx['invoice_address_asked'] = self.address_asked
|
||||
|
||||
@@ -951,7 +951,7 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
class ProviderForm(SettingsForm):
|
||||
"""
|
||||
This is a SettingsForm, but if fields are set to required=True, validation
|
||||
errors are only raised if the provider is enabled.
|
||||
errors are only raised if the payment method is enabled.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -153,11 +153,19 @@ class QuestionForm(I18nModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['items'].queryset = self.instance.event.items.all()
|
||||
self.fields['items'].required = True
|
||||
if self.instance.container_type == Question.ContainerType.ORDERPOSITION:
|
||||
self.fields['items'].queryset = self.instance.event.items.all()
|
||||
self.fields['items'].required = True
|
||||
else:
|
||||
del self.fields['items']
|
||||
del self.fields['ask_during_checkin']
|
||||
del self.fields['show_during_checkin']
|
||||
del self.fields['print_on_invoice']
|
||||
self.fields['dependency_question'].widget.attrs['data-container-type'] = self.instance.container_type
|
||||
self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
|
||||
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
|
||||
ask_during_checkin=False
|
||||
ask_during_checkin=False,
|
||||
container_type=self.instance.container_type,
|
||||
)
|
||||
if self.instance.pk:
|
||||
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
|
||||
|
||||
@@ -94,14 +94,14 @@ class SubEventBulkForm(SubEventForm):
|
||||
label=_('Start of presale'),
|
||||
help_text=_('Optional. No products will be sold before this date.'),
|
||||
required=False,
|
||||
limit_choices=('date_from', 'date_to'),
|
||||
limit_choices=('event__date_from', 'event__date_to'),
|
||||
)
|
||||
rel_presale_end = RelativeDateTimeField(
|
||||
label=_('End of presale'),
|
||||
help_text=_('Optional. No products will be sold after this date. If you do not set this value, the presale '
|
||||
'will end after the end date of your event.'),
|
||||
required=False,
|
||||
limit_choices=('date_from', 'date_to'),
|
||||
limit_choices=('event__date_from', 'event__date_to'),
|
||||
)
|
||||
skip_if_overlap = forms.BooleanField(
|
||||
label=pgettext_lazy('subevent', 'Skip dates that overlap with any existing date'),
|
||||
@@ -333,12 +333,12 @@ class BulkSubEventItemForm(SubEventItemForm):
|
||||
rel_available_from = RelativeDateTimeField(
|
||||
label=_('Available from'),
|
||||
required=False,
|
||||
limit_choices=('date_from', 'date_to'),
|
||||
limit_choices=('event__date_from', 'event__date_to'),
|
||||
)
|
||||
rel_available_until = RelativeDateTimeField(
|
||||
label=_('Available until'),
|
||||
required=False,
|
||||
limit_choices=('date_from', 'date_to'),
|
||||
limit_choices=('event__date_from', 'event__date_to'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -355,12 +355,12 @@ class BulkSubEventItemVariationForm(SubEventItemVariationForm):
|
||||
rel_available_from = RelativeDateTimeField(
|
||||
label=_('Available from'),
|
||||
required=False,
|
||||
limit_choices=('date_from', 'date_to'),
|
||||
limit_choices=('event__date_from', 'event__date_to'),
|
||||
)
|
||||
rel_available_until = RelativeDateTimeField(
|
||||
label=_('Available_until'),
|
||||
required=False,
|
||||
limit_choices=('date_from', 'date_to'),
|
||||
limit_choices=('event__date_from', 'event__date_to'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
<script type="text/javascript" src="{% static "leaflet/leaflet.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/geo.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/reldate.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "sortable/Sortable.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
{% blocktrans with url=edit_url|add:"#tab-0-1-open" %}If you want to keep the answers, <a href="{{url}}">edit the question</a> and set it to hidden.{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
{% for item in dependent %}
|
||||
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.pk %}">{{ item }}</a></li>
|
||||
{% endfor %}
|
||||
<ul>
|
||||
{% for item in dependent %}
|
||||
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.pk %}">{{ item }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:event.items.questions" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
<legend>{% trans "General" %}</legend>
|
||||
{% bootstrap_field form.question layout="control" %}
|
||||
{% bootstrap_field form.type layout="control" %}
|
||||
{% bootstrap_field form.items layout="control" %}
|
||||
{% if form.items %}
|
||||
{% bootstrap_field form.items layout="control" %}
|
||||
{% endif %}
|
||||
{% bootstrap_field form.required layout="control" %}
|
||||
<div class="alert alert-info alert-required-boolean">
|
||||
{% blocktrans trimmed %}
|
||||
@@ -128,10 +130,16 @@
|
||||
<legend>{% trans "Advanced" %}</legend>
|
||||
{% bootstrap_field form.help_text layout="control" %}
|
||||
{% bootstrap_field form.identifier layout="control" %}
|
||||
{% bootstrap_field form.ask_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.show_during_checkin layout="control" %}
|
||||
{% if form.ask_during_checkin %}
|
||||
{% bootstrap_field form.ask_during_checkin layout="control" %}
|
||||
{% endif %}
|
||||
{% if form.show_during_checkin %}
|
||||
{% bootstrap_field form.show_during_checkin layout="control" %}
|
||||
{% endif %}
|
||||
{% bootstrap_field form.hidden layout="control" %}
|
||||
{% bootstrap_field form.print_on_invoice layout="control" %}
|
||||
{% if form.print_on_invoice %}
|
||||
{% bootstrap_field form.print_on_invoice layout="control" %}
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="id_dependency_question">
|
||||
|
||||
@@ -10,11 +10,26 @@
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% csrf_token %}
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<p>
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{% if request.event.settings.feature_flag_order_level_questions %}
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<p>
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket question" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level question" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>{% trans "Per-ticket questions" %}</h2>
|
||||
<p>{% trans "These questions are asked for every ticket, so possibly multiple times in the same order." %}</p>
|
||||
{% else %}
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<p>
|
||||
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
@@ -32,8 +47,8 @@
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||
{% for q in questions %}
|
||||
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P">
|
||||
{% for q in questions %}{% if q.container_type == "P" %}
|
||||
<tr data-dnd-id="{{ q.id }}">
|
||||
<td>
|
||||
<strong>
|
||||
@@ -63,7 +78,6 @@
|
||||
{% if q.pk and q.ask_during_checkin %}
|
||||
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
|
||||
{% endif %}
|
||||
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.hidden %}
|
||||
@@ -102,8 +116,90 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if request.event.settings.feature_flag_order_level_questions %}
|
||||
<h2>
|
||||
{% trans "Per-order questions" %}
|
||||
<small><span class="label label-info" title="
|
||||
{% trans "This functionality is in active development and expected to change significantly over the coming months." %}
|
||||
{% trans "Per-order questions are currently not supported and will not be displayed in pretixPOS." %}
|
||||
" data-toggle="tooltip">
|
||||
<span class="fa fa-flask" aria-hidden="true"></span>
|
||||
{% trans "Experimental feature" %}
|
||||
</span></small>
|
||||
</h2>
|
||||
<p>{% trans "These questions are asked once per order." %}</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Question" %}</th>
|
||||
<th>{% trans "Type" %}</th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<th class="action-col-2"></th>
|
||||
{% endif %}
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O">
|
||||
{% for q in questions %}{% if q.container_type == "O" %}
|
||||
<tr data-dnd-id="{{ q.id }}">
|
||||
<td>
|
||||
<strong>
|
||||
{{ q.question }}
|
||||
</strong><br>
|
||||
<small class="text-muted">{{ q.identifier }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk %}
|
||||
{{ q.get_type_display }}
|
||||
{% else %}
|
||||
{% trans "System question" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.required %}
|
||||
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.ask_during_checkin %}
|
||||
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.hidden %}
|
||||
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<td class="dnd-container">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-right flip">
|
||||
{% if q.pk %}
|
||||
{% if 'event.items:write' in request.eventpermset %}
|
||||
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if 'event.settings.general:write' in request.eventpermset %}
|
||||
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
|
||||
class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -35,6 +35,18 @@
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% if order_questions_form.fields %}
|
||||
<details class="panel panel-default" open>
|
||||
<summary class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<strong>{% trans "Additional order information" %}</strong>
|
||||
</h4>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
{% bootstrap_form order_questions_form layout="horizontal" %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% for pos, forms in formgroups %}
|
||||
<details class="panel panel-default" open>
|
||||
<summary class="panel-heading">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{% load safelink %}
|
||||
{% load i18n %}
|
||||
{% load rich_text %}
|
||||
{% if answer %}
|
||||
{% if answer.file %}
|
||||
<span class="fa fa-file"></span>
|
||||
<a href="{{ answer.backend_file_url }}?token={% answer_token request answer %}">
|
||||
{{ answer.file_name }}
|
||||
</a>
|
||||
<span class="label label-danger" data-toggle="tooltip"
|
||||
title="{% trans "This file has been uploaded by a user and could contain viruses or other malicious content." %}">
|
||||
{% trans "UNSAFE" %}
|
||||
</span>
|
||||
{% if answer.is_image %}
|
||||
<br>
|
||||
<a href="{{ answer.backend_file_url }}?token={% answer_token request answer %}" data-lightbox="order"
|
||||
class="answer-thumb">
|
||||
<img src="{{ answer.backend_file_url }}?token={% answer_token request answer %}">
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif question.type == "M" %}
|
||||
{{ answer.to_string_i18n|rich_text_snippet }}
|
||||
{% else %}
|
||||
{{ answer.to_string_i18n|linebreaksbr }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<em>{% trans "not answered" %}</em>
|
||||
{% endif %}
|
||||
@@ -427,6 +427,14 @@
|
||||
</form>
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if order.answers.all %}
|
||||
<hr>
|
||||
{% endif %}
|
||||
{% for ans in order.answers.all %}
|
||||
<dt>{{ ans.question.internal_name|default:ans.question.question }}</dt>
|
||||
<dd>{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=ans.question answer=ans %}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -664,31 +672,7 @@
|
||||
{% endif %}
|
||||
</dt>
|
||||
<dd>
|
||||
{% if q.answer %}
|
||||
{% if q.answer.file %}
|
||||
<span class="fa fa-file"></span>
|
||||
<a href="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}">
|
||||
{{ q.answer.file_name }}
|
||||
</a>
|
||||
<span class="label label-danger" data-toggle="tooltip"
|
||||
title="{% trans "This file has been uploaded by a user and could contain viruses or other malicious content." %}">
|
||||
{% trans "UNSAFE" %}
|
||||
</span>
|
||||
{% if q.answer.is_image %}
|
||||
<br>
|
||||
<a href="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}" data-lightbox="order"
|
||||
class="answer-thumb">
|
||||
<img src="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}">
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif q.type == "M" %}
|
||||
{{ q.answer.to_string_i18n|rich_text_snippet }}
|
||||
{% else %}
|
||||
{{ q.answer.to_string_i18n|linebreaksbr }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<em>{% trans "not answered" %}</em>
|
||||
{% endif %}
|
||||
{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=q answer=q.answer %}
|
||||
</dd>
|
||||
{% endfor %}
|
||||
{% for q in line.additional_fields %}
|
||||
|
||||
@@ -982,7 +982,7 @@ class TicketSettingsPreview(EventPermissionRequiredMixin, View):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
if provider.identifier == self.kwargs.get('output') and not provider.is_meta:
|
||||
if provider.identifier == self.kwargs.get('output'):
|
||||
return provider
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
@@ -1085,11 +1085,6 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
provider_settings_fields = provider.settings_form_fields
|
||||
provider_settings_content = provider.settings_content_render(self.request)
|
||||
if not provider_settings_fields and not provider_settings_content:
|
||||
continue
|
||||
|
||||
provider.form = ProviderForm(
|
||||
obj=self.request.event,
|
||||
settingspref='ticketoutput_%s_' % provider.identifier,
|
||||
@@ -1099,17 +1094,17 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
|
||||
provider.form.fields = OrderedDict(
|
||||
[
|
||||
('ticketoutput_%s_%s' % (provider.identifier, k), v)
|
||||
for k, v in provider_settings_fields.items()
|
||||
for k, v in provider.settings_form_fields.items()
|
||||
]
|
||||
)
|
||||
provider.settings_content = provider_settings_content
|
||||
provider.settings_content = provider.settings_content_render(self.request)
|
||||
provider.form.prepare_fields()
|
||||
|
||||
provider.evaluated_preview_allowed = True
|
||||
if not provider.preview_allowed:
|
||||
provider.evaluated_preview_allowed = False
|
||||
else:
|
||||
for k, v in provider_settings_fields.items():
|
||||
for k, v in provider.settings_form_fields.items():
|
||||
if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)):
|
||||
provider.evaluated_preview_allowed = False
|
||||
break
|
||||
|
||||
@@ -65,6 +65,7 @@ from pretix.api.serializers.item import (
|
||||
ItemVariationSerializer,
|
||||
)
|
||||
from pretix.base.forms import I18nFormSet
|
||||
from pretix.base.forms.questions import get_fake_attendee_questions
|
||||
from pretix.base.models import (
|
||||
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry,
|
||||
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
|
||||
@@ -426,7 +427,7 @@ def reorder_categories(request, organizer, event):
|
||||
|
||||
|
||||
FakeQuestion = namedtuple(
|
||||
'FakeQuestion', 'id question position required'
|
||||
'FakeQuestion', 'id question position required container_type'
|
||||
)
|
||||
|
||||
|
||||
@@ -440,85 +441,8 @@ class QuestionList(ListView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
questions = []
|
||||
|
||||
if self.request.event.settings.attendee_names_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='attendee_name_parts',
|
||||
question=_('Attendee name'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'attendee_name_parts', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_names_required,
|
||||
)
|
||||
)
|
||||
|
||||
if self.request.event.settings.attendee_emails_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='attendee_email',
|
||||
question=_('Attendee email'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'attendee_email', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_emails_required,
|
||||
)
|
||||
)
|
||||
|
||||
if self.request.event.settings.attendee_company_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='company',
|
||||
question=_('Company'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'company', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_company_required,
|
||||
)
|
||||
)
|
||||
|
||||
if self.request.event.settings.attendee_addresses_asked:
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='street',
|
||||
question=_('Street'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'street', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='zipcode',
|
||||
question=_('ZIP code'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'zipcode', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='city',
|
||||
question=_('City'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'city', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
questions.append(
|
||||
FakeQuestion(
|
||||
id='country',
|
||||
question=_('Country'),
|
||||
position=self.request.event.settings.system_question_order.get(
|
||||
'country', 0
|
||||
),
|
||||
required=self.request.event.settings.attendee_addresses_required,
|
||||
)
|
||||
)
|
||||
questions = get_fake_attendee_questions(self.request.event.settings)
|
||||
|
||||
questions += list(ctx['questions'])
|
||||
questions.sort(key=lambda q: q.position)
|
||||
@@ -535,14 +459,16 @@ def reorder_questions(request, organizer, event):
|
||||
except (JSONDecodeError, KeyError, ValueError):
|
||||
return HttpResponseBadRequest("expected JSON: {ids:[]}")
|
||||
|
||||
qs = request.event.questions.filter(container_type=request.GET['container_type'])
|
||||
|
||||
# filter system_questions - normal questions are int/digit, system_questions strings
|
||||
custom_question_ids = [i for i in ids if i.isdigit()]
|
||||
input_questions = list(request.event.questions.filter(id__in=custom_question_ids))
|
||||
input_questions = list(qs.filter(id__in=custom_question_ids))
|
||||
|
||||
if len(input_questions) != len(custom_question_ids):
|
||||
raise Http404(_("Some of the provided object ids are invalid."))
|
||||
|
||||
if len(input_questions) != request.event.questions.count():
|
||||
if len(input_questions) != qs.count():
|
||||
raise Http404(_("Not all objects have been selected."))
|
||||
|
||||
for q in input_questions:
|
||||
@@ -556,18 +482,19 @@ def reorder_questions(request, organizer, event):
|
||||
}
|
||||
)
|
||||
|
||||
system_question_order = {}
|
||||
for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'):
|
||||
if s in ids:
|
||||
system_question_order[s] = ids.index(s)
|
||||
else:
|
||||
system_question_order[s] = -1
|
||||
request.event.settings.system_question_order = system_question_order
|
||||
request.event.log_action(
|
||||
'pretix.event.settings', user=request.user, data={
|
||||
'system_question_order': system_question_order,
|
||||
}
|
||||
)
|
||||
if request.GET['container_type'] == Question.ContainerType.ORDERPOSITION:
|
||||
system_question_order = {}
|
||||
for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'):
|
||||
if s in ids:
|
||||
system_question_order[s] = ids.index(s)
|
||||
else:
|
||||
system_question_order[s] = -1
|
||||
request.event.settings.system_question_order = system_question_order
|
||||
request.event.log_action(
|
||||
'pretix.event.settings', user=request.user, data={
|
||||
'system_question_order': system_question_order,
|
||||
}
|
||||
)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
@@ -805,6 +732,9 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['instance'] = Question(event=self.request.event)
|
||||
kwargs['instance'].container_type = self.request.GET.get('container_type', Question.ContainerType.ORDERPOSITION)
|
||||
if kwargs['instance'].container_type not in Question.ContainerType.values:
|
||||
raise PermissionDenied
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
|
||||
@@ -582,8 +582,6 @@ class OrderDetail(OrderView):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
if provider.is_meta:
|
||||
continue
|
||||
buttons.append({
|
||||
'text': provider.download_button_text or 'Ticket',
|
||||
'icon': provider.download_button_icon or 'fa-download',
|
||||
@@ -2228,6 +2226,10 @@ class OrderModifyInformation(OrderQuestionsViewMixin, OrderView):
|
||||
only_user_visible = False
|
||||
all_optional = True
|
||||
|
||||
@property
|
||||
def order_question_container(self):
|
||||
return self.order
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['other_form'] = self.other_form
|
||||
@@ -2589,7 +2591,11 @@ class AnswerDownload(EventPermissionRequiredMixin, OrderViewMixin, ListView):
|
||||
answid = kwargs.get('answer')
|
||||
token = request.GET.get('token', '')
|
||||
|
||||
answer = get_object_or_404(QuestionAnswer, orderposition__order=self.order, id=answid)
|
||||
answer = get_object_or_404(
|
||||
QuestionAnswer,
|
||||
Q(orderposition__order=self.order) | Q(order=self.order),
|
||||
id=answid
|
||||
)
|
||||
if not answer.file:
|
||||
raise Http404()
|
||||
if not check_token(request, answer, token):
|
||||
@@ -2599,7 +2605,7 @@ class AnswerDownload(EventPermissionRequiredMixin, OrderViewMixin, ListView):
|
||||
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,
|
||||
answer.orderposition.positionid if answer.orderposition else '',
|
||||
os.path.basename(answer.file.name).split('.', 1)[1]
|
||||
)
|
||||
return resp
|
||||
|
||||
@@ -431,7 +431,7 @@ class SubEventEditorMixin(MetaDataEditorMixin):
|
||||
days = (self.copy_from.date_from.astimezone(tz).date() - value.astimezone(tz).date()).days
|
||||
return RelativeDateWrapper(RelativeDate(
|
||||
days=abs(days),
|
||||
base_date_name='date_from',
|
||||
base_date_name='event__date_from',
|
||||
time=value.astimezone(tz).time(),
|
||||
minutes=None,
|
||||
is_after=days < 0,
|
||||
|
||||
@@ -21,13 +21,11 @@
|
||||
#
|
||||
import copy
|
||||
from decimal import Decimal
|
||||
import typing
|
||||
|
||||
from django.core.files import File
|
||||
from django.db import models
|
||||
from django.db.models.fields import DecimalField
|
||||
|
||||
T = typing.TypeVar('T', bound=models.Model)
|
||||
|
||||
class Thumbnail(models.Model):
|
||||
source = models.CharField(max_length=255)
|
||||
@@ -49,13 +47,6 @@ def modelcopy(obj: models.Model, **kwargs):
|
||||
setattr(n, f.name, copy.deepcopy(val))
|
||||
return n
|
||||
|
||||
def modelclone(obj: T, **kwargs) -> T:
|
||||
new = copy.copy(obj)
|
||||
new.pk = None
|
||||
new._state.adding = True
|
||||
for k,v in kwargs.items():
|
||||
setattr(new, k, v)
|
||||
return new
|
||||
|
||||
# django 5 contains this in django.utils.choices.flatten_choices
|
||||
def flatten_choices(choices):
|
||||
|
||||
@@ -24,17 +24,21 @@ import hashlib
|
||||
from django.core.signing import BadSignature, TimestampSigner
|
||||
|
||||
|
||||
class SafeDownloadSigner(TimestampSigner):
|
||||
pass
|
||||
|
||||
|
||||
def get_token(request, answer):
|
||||
if not request.session.session_key:
|
||||
request.session.create()
|
||||
payload = '{}:{}'.format(request.session.session_key, answer.pk)
|
||||
signer = TimestampSigner()
|
||||
signer = SafeDownloadSigner()
|
||||
return signer.sign(hashlib.sha1(payload.encode()).hexdigest())
|
||||
|
||||
|
||||
def check_token(request, answer, token):
|
||||
payload = hashlib.sha1('{}:{}'.format(request.session.session_key, answer.pk).encode()).hexdigest()
|
||||
signer = TimestampSigner()
|
||||
signer = SafeDownloadSigner()
|
||||
try:
|
||||
return payload == signer.unsign(token, max_age=3600 * 24)
|
||||
except BadSignature:
|
||||
|
||||
@@ -23,7 +23,7 @@ import json
|
||||
import logging
|
||||
import urllib.parse
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
@@ -193,6 +193,31 @@ class PaypalSettingsHolder(BasePaymentProvider):
|
||||
}
|
||||
)
|
||||
)),
|
||||
('allow_retries_during_compliance_hold',
|
||||
forms.BooleanField(
|
||||
label=_('Allow further payments during compliance hold'),
|
||||
help_text=_(
|
||||
'PayPals fraud prevention might block processing of individual payments for a considerable amount '
|
||||
'of time. The payment is marked as "pending" during this time window. You can allow your customers to '
|
||||
'start another payment attempts during that window. This might result in them being charged twice if the'
|
||||
'original payment is approved.'
|
||||
),
|
||||
required=False
|
||||
)),
|
||||
('timeout_payment_during_compliance_hold',
|
||||
forms.IntegerField(
|
||||
label=_('Timeout further payment attempts'),
|
||||
help_text=_(
|
||||
'Time duration in minutes after which another payment attempt is possible, while the last payment is '
|
||||
'still under investigation.'
|
||||
),
|
||||
required=False,
|
||||
widget=forms.NumberInput(
|
||||
attrs={
|
||||
'data-checkbox-dependency': '#id_payment_paypal_allow_retries_during_compliance_hold',
|
||||
}
|
||||
)
|
||||
)),
|
||||
|
||||
]
|
||||
|
||||
@@ -515,8 +540,16 @@ class PaypalMethod(BasePaymentProvider):
|
||||
'XPF': 0,
|
||||
}))
|
||||
|
||||
@property
|
||||
def abort_pending_allowed(self):
|
||||
def _payment_abort_pending_allowed(self, payment) -> bool:
|
||||
if not self.settings.get('allow_retries_during_compliance_hold', as_type=bool, default=True):
|
||||
return False
|
||||
|
||||
if payment.info_data.get('create_time', False):
|
||||
create_time = datetime.fromisoformat(payment.info_data['create_time'])
|
||||
duration = self.settings.get('timeout_payment_during_compliance_hold', as_type=int, default=10)
|
||||
if datetime.now(tz=timezone.utc) - create_time > timedelta(minutes=duration):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _create_paypal_order(self, request, payment=None, cart_total=None):
|
||||
@@ -678,6 +711,8 @@ class PaypalMethod(BasePaymentProvider):
|
||||
else:
|
||||
pp_captured_order = response.result
|
||||
payment.info = json.dumps(pp_captured_order.dict())
|
||||
if pp_captured_order.status == 'APPROVED':
|
||||
payment.state = OrderPayment.PAYMENT_STATE_PENDING
|
||||
payment.save()
|
||||
|
||||
try:
|
||||
@@ -857,14 +892,20 @@ class PaypalMethod(BasePaymentProvider):
|
||||
logger.info('{}: {} - paypal payment processing time'.format(str(payment.global_id), str(duration)))
|
||||
|
||||
def payment_pending_render(self, request, payment) -> str:
|
||||
retry = True
|
||||
stuck_in_compliance = False
|
||||
retry = self._payment_abort_pending_allowed(payment)
|
||||
try:
|
||||
if (
|
||||
payment.info
|
||||
and payment.info_data['purchase_units'][0]['payments']['captures'][0]['status'] == 'PENDING'
|
||||
):
|
||||
retry = False
|
||||
except (KeyError, IndexError):
|
||||
for purchase_unit in payment.info_data['purchase_units']:
|
||||
for capture in purchase_unit['payments']['captures']:
|
||||
if capture['status'] == "PENDING":
|
||||
stuck_in_compliance = True
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
if payment.info_data.get('status') == "APPROVED":
|
||||
stuck_in_compliance = True
|
||||
except (KeyError):
|
||||
pass
|
||||
|
||||
error = payment.info_data.get("error", {})
|
||||
@@ -872,7 +913,8 @@ class PaypalMethod(BasePaymentProvider):
|
||||
|
||||
template = get_template('pretixplugins/paypal2/pending.html')
|
||||
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
|
||||
'retry': retry, 'order': payment.order, 'is_known_issue': is_known_issue}
|
||||
'stuck_in_compliance': stuck_in_compliance, 'retry': retry, 'order': payment.order,
|
||||
'is_known_issue': is_known_issue}
|
||||
return template.render(ctx)
|
||||
|
||||
def matching_id(self, payment: OrderPayment):
|
||||
|
||||
@@ -166,6 +166,8 @@ def signal_process_response(sender, request: HttpRequest, response: HttpResponse
|
||||
|
||||
settings_hierarkey.add_default('payment_paypal_debug_buyer_country', '', str)
|
||||
settings_hierarkey.add_default('payment_paypal_method_wallet', True, bool)
|
||||
settings_hierarkey.add_default('payment_paypal_allow_retries_during_compliance_hold', True, bool)
|
||||
settings_hierarkey.add_default('payment_paypal_timeout_payment_during_compliance_hold', 10, int)
|
||||
|
||||
|
||||
def _nonce(request):
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
Your payment has failed due to a known issue within PayPal. Please try again, there is a high chance of the
|
||||
payment succeeding on a second or third attempt. You can also try other payment methods, if available.
|
||||
{% endblocktrans %}</div>
|
||||
{% else %}
|
||||
{% elif stuck_in_compliance %}
|
||||
<p>{% blocktrans trimmed %}
|
||||
Our attempt to execute your payment via PayPal has failed. Please try again or contact us.
|
||||
Your payment is being processed by PayPal. This takes longer than usual. You can wait until PayPal
|
||||
acknowledges the payment or you can try paying again with this or another payment method.
|
||||
This might result in you being charged twice in case PayPal allows your initial payment attempt.
|
||||
Please contact us to resolve this case.
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
|
||||
@@ -471,8 +471,8 @@ def webhook(request, *args, **kwargs):
|
||||
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED,
|
||||
OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED):
|
||||
if sale['status'] == 'COMPLETED':
|
||||
any_captures = False
|
||||
all_captures_completed = True
|
||||
any_pending_review = False
|
||||
for purchaseunit in sale['purchase_units']:
|
||||
for capture in purchaseunit['payments']['captures']:
|
||||
try:
|
||||
@@ -483,9 +483,9 @@ def webhook(request, *args, **kwargs):
|
||||
|
||||
if capture['status'] not in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'):
|
||||
all_captures_completed = False
|
||||
else:
|
||||
any_captures = True
|
||||
if any_captures and all_captures_completed:
|
||||
if capture['status_details']['reason'] == "PENDING_REVIEW":
|
||||
any_pending_review = True
|
||||
if all_captures_completed:
|
||||
try:
|
||||
payment.info = json.dumps(sale.dict())
|
||||
payment.save(update_fields=['info'])
|
||||
@@ -493,6 +493,9 @@ def webhook(request, *args, **kwargs):
|
||||
prov.log_payment_duration(payment)
|
||||
except Quota.QuotaExceededException:
|
||||
pass
|
||||
if any_pending_review and payment.state != OrderPayment.PAYMENT_STATE_PENDING:
|
||||
payment.state = OrderPayment.PAYMENT_STATE_PENDING
|
||||
payment.save(update_fields=['state'])
|
||||
elif sale['status'] == 'APPROVED':
|
||||
try:
|
||||
request.session['payment_paypal_oid'] = payment.info_data['id']
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix 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/>.
|
||||
#
|
||||
@@ -1,127 +0,0 @@
|
||||
from rest_framework import viewsets
|
||||
from django.db import transaction
|
||||
from .styles import AVAILABLE_STYLES_DICT, AVAILABLE_PLATFORMS
|
||||
from .models import WalletLayout, WalletPlatformLayout
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
import os
|
||||
|
||||
|
||||
class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
|
||||
platform = serializers.ChoiceField(
|
||||
choices=[p.identifier for p in AVAILABLE_PLATFORMS]
|
||||
)
|
||||
style = serializers.CharField(allow_null=True, required=False)
|
||||
file_settings = serializers.JSONField(default=dict, required=False)
|
||||
|
||||
class Meta:
|
||||
model = WalletPlatformLayout
|
||||
fields = ("platform", "style", "layout", "file_settings")
|
||||
|
||||
def validate_layout(self, value):
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError(_("Layout must be a dict"))
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
platform = data.get("platform")
|
||||
style = data.get("style")
|
||||
layout = data.get("layout")
|
||||
if platform and style and layout:
|
||||
platform_styles = AVAILABLE_STYLES_DICT[platform]
|
||||
|
||||
if data["style"] not in platform_styles:
|
||||
raise ValidationError(_("Invalid style"))
|
||||
style = platform_styles[data["style"]]
|
||||
|
||||
style = style(event=self.context["event"], layout=data["layout"])
|
||||
style.validate()
|
||||
data["file_settings"] = style.extract_file_settings(
|
||||
self.context["request"], data.get("file_settings", {})
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
ret["file_settings"] = {}
|
||||
for file_setting in instance.file_settings.all():
|
||||
try:
|
||||
url = file_setting.file.url
|
||||
except AttributeError:
|
||||
continue
|
||||
request = self.context["request"]
|
||||
ret["file_settings"][file_setting.key] = {
|
||||
"url": request.build_absolute_uri(url),
|
||||
"name": os.path.basename(file_setting.file.name).split('.', 1)[-1]
|
||||
}
|
||||
return ret
|
||||
|
||||
|
||||
class WalletLayoutSerializer(I18nAwareModelSerializer):
|
||||
platform_layouts = WalletPlatformLayoutSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = WalletLayout
|
||||
fields = ("id", "name", "platform_layouts")
|
||||
read_only_fields = ("id",)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs, event=self.context["event"])
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
platform_layouts = validated_data.pop("platform_layouts")
|
||||
for layout in platform_layouts:
|
||||
if layout["style"]:
|
||||
file_settings = layout.pop("file_settings", {})
|
||||
obj, _ = instance.platform_layouts.update_or_create(
|
||||
platform=layout["platform"], defaults=layout
|
||||
)
|
||||
for key, file in file_settings.items():
|
||||
if not file:
|
||||
obj.file_settings.filter(key=key).delete()
|
||||
|
||||
elif file != "keep":
|
||||
obj, _ = obj.file_settings.get_or_create(
|
||||
key=key
|
||||
)
|
||||
obj.file.save(os.path.basename(file.name), file)
|
||||
|
||||
instance.platform_layouts.exclude(
|
||||
platform__in={
|
||||
layout["platform"]
|
||||
for layout in platform_layouts
|
||||
if layout["style"] is not None
|
||||
}
|
||||
).delete()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WalletLayoutViewSet(viewsets.ModelViewSet):
|
||||
model = WalletLayout
|
||||
queryset = WalletLayout.objects.none()
|
||||
serializer_class = WalletLayoutSerializer
|
||||
permission = "event.settings.general:write"
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.wallet_layouts.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx["event"] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
super().perform_update(serializer)
|
||||
serializer.instance.log_action(
|
||||
action="pretix.plugins.wallet.layout.changed",
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
)
|
||||
@@ -1,41 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix 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/>.
|
||||
#
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix import __version__ as version
|
||||
|
||||
|
||||
class WalletApp(AppConfig):
|
||||
name = 'pretix.plugins.wallet'
|
||||
verbose_name = _("wallet")
|
||||
|
||||
class PretixPluginMeta:
|
||||
name = _("wallet")
|
||||
author = _("the pretix team")
|
||||
version = version
|
||||
category = 'FORMAT'
|
||||
description = _("Issue wallet passes for tickets (e.g. apple wallet, google wallet)")
|
||||
|
||||
def ready(self):
|
||||
from . import receivers # NOQA
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from pretix.control.forms import ClearableBasenameFileInput
|
||||
from django.core.files import File
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_rsa_privkey(value: File):
|
||||
value = value.read().strip()
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode()
|
||||
if not value:
|
||||
return
|
||||
if not re.match(r"^-----BEGIN( (RSA |ENCRYPTED )?PRIVATE KEY-----).*-----END\1$", value, re.DOTALL):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"This does not look like an RSA private key in PEM format (it misses the correct begin or end signifiers)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CertificateFileField(forms.FileField):
|
||||
widget = ClearableBasenameFileInput
|
||||
|
||||
def clean(self, value, *args, **kwargs):
|
||||
value = super().clean(value, *args, **kwargs)
|
||||
if isinstance(value, UploadedFile):
|
||||
value.open("rb")
|
||||
value.seek(0)
|
||||
content = value.read()
|
||||
if (
|
||||
content.startswith(b"-----BEGIN CERTIFICATE-----")
|
||||
and b"-----BEGIN CERTIFICATE-----" in content
|
||||
):
|
||||
return SimpleUploadedFile("cert.pem", content, "text/plain")
|
||||
|
||||
openssl_cmd = [
|
||||
"openssl",
|
||||
"x509",
|
||||
"-inform",
|
||||
"DER",
|
||||
"-outform",
|
||||
"PEM",
|
||||
]
|
||||
process = subprocess.Popen(
|
||||
openssl_cmd,
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE,
|
||||
)
|
||||
process.stdin.write(content)
|
||||
pem, error = process.communicate()
|
||||
if process.returncode != 0:
|
||||
logger.info("Trying to convert a DER to PEM failed: {}".format(error))
|
||||
raise ValidationError(
|
||||
_(
|
||||
"This does not look like a X509 certificate in either PEM or DER format"
|
||||
),
|
||||
)
|
||||
|
||||
return SimpleUploadedFile("cert.pem", pem, "text/plain")
|
||||
return value
|
||||
|
||||
|
||||
class PNGImageField(forms.FileField):
|
||||
widget = ClearableBasenameFileInput
|
||||
|
||||
def clean(self, value, *args, **kwargs):
|
||||
value = super().clean(value, *args, **kwargs)
|
||||
if isinstance(value, UploadedFile):
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
return value
|
||||
|
||||
value.open("rb")
|
||||
value.seek(0)
|
||||
try:
|
||||
with (
|
||||
Image.open(value, formats=settings.PILLOW_FORMATS_IMAGE) as im,
|
||||
tempfile.NamedTemporaryFile("rb", suffix=".png") as tmpfile,
|
||||
):
|
||||
im.save(tmpfile.name)
|
||||
tmpfile.seek(0)
|
||||
return SimpleUploadedFile(
|
||||
"picture.png", tmpfile.read(), "image png"
|
||||
)
|
||||
except IOError:
|
||||
logger.exception("Could not convert image to PNG.")
|
||||
raise ValidationError(
|
||||
_("The file you uploaded could not be converted to PNG format.")
|
||||
)
|
||||
|
||||
return value
|
||||
@@ -1,98 +0,0 @@
|
||||
# Generated by Django 5.2.13 on 2026-05-19 15:39
|
||||
|
||||
import django.db.models.deletion
|
||||
import pretix.base.models.base
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0297_outgoingmail"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WalletLayout",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=190)),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="wallet_layouts",
|
||||
to="pretixbase.event",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WalletLayoutItem",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"item",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="walletlayout_assignments",
|
||||
to="pretixbase.item",
|
||||
),
|
||||
),
|
||||
(
|
||||
"layout",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="item_assignments",
|
||||
to="wallet.walletlayout",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("item", "layout")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WalletPlatformLayout",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("platform", models.CharField(max_length=10)),
|
||||
("style", models.CharField(max_length=255)),
|
||||
("layout", models.JSONField(default=dict)),
|
||||
(
|
||||
"parent",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="platform_layouts",
|
||||
to="wallet.walletlayout",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("parent", "platform")},
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
]
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-09 18:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("wallet", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="walletlayoutitem",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="walletlayout",
|
||||
name="default",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="walletlayoutitem",
|
||||
name="item",
|
||||
field=models.OneToOneField(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="walletlayout",
|
||||
to="pretixbase.item",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="walletlayout",
|
||||
constraint=models.UniqueConstraint(
|
||||
models.F("event"),
|
||||
condition=models.Q(("default", True)),
|
||||
name="one_default_wallet_per_event",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 5.2.13 on 2026-08-11 15:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("wallet", "0002_alter_walletlayoutitem_unique_together_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WalletLayoutFileSetting",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("key", models.CharField()),
|
||||
("file", models.FileField(upload_to="")),
|
||||
(
|
||||
"layout",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="file_settings",
|
||||
to="wallet.walletplatformlayout",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,98 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix 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/>.
|
||||
#
|
||||
from django.db import models
|
||||
from django.db.models import constraints, Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import LoggedModel, OrderPosition
|
||||
from django_scopes import ScopedManager
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
|
||||
class WalletLayout(LoggedModel):
|
||||
event = models.ForeignKey(
|
||||
'pretixbase.Event',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='wallet_layouts'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=190,
|
||||
verbose_name=_('Name')
|
||||
)
|
||||
default = models.BooleanField(
|
||||
verbose_name=_('Default'),
|
||||
default=False,
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
constraints.UniqueConstraint("event", condition=Q(default=True), name="one_default_wallet_per_event")
|
||||
]
|
||||
|
||||
|
||||
class WalletPlatformLayout(LoggedModel):
|
||||
parent = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name="platform_layouts")
|
||||
|
||||
platform = models.CharField(max_length=10)
|
||||
style = models.CharField(max_length=255)
|
||||
layout = models.JSONField(default=dict)
|
||||
|
||||
objects = ScopedManager(organizer='parent__event__organizer')
|
||||
|
||||
class Meta:
|
||||
unique_together = (('parent', 'platform'),)
|
||||
|
||||
@property
|
||||
def pass_layout(self):
|
||||
from pretix.plugins.wallet.styles import get_style
|
||||
|
||||
style = get_style(self.platform, self.style)
|
||||
if style:
|
||||
file_settings = dict(self.file_settings.values_list("key", "file"))
|
||||
print(file_settings)
|
||||
return style(event=self.parent.event, layout=self.layout, file_settings=file_settings)
|
||||
else:
|
||||
raise RuntimeError(f"Style {self.platform}.{self.style} not found")
|
||||
|
||||
class WalletLayoutItem(models.Model):
|
||||
item = models.OneToOneField('pretixbase.Item', null=True, blank=True, related_name='walletlayout',
|
||||
on_delete=models.CASCADE)
|
||||
layout = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name='item_assignments')
|
||||
|
||||
def clean(self):
|
||||
if self.item.event != self.layout.event:
|
||||
raise ValidationError("cannot bind layout to item of different event")
|
||||
|
||||
class WalletLayoutFileSetting(models.Model):
|
||||
layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.CASCADE, related_name="file_settings")
|
||||
key = models.CharField()
|
||||
file = models.FileField()
|
||||
|
||||
# smth like this for apple, lets see what the best architecture for google will be
|
||||
# class AppleWalletPass(models.Model):
|
||||
# platform_layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.PROTECT)
|
||||
# order_position = models.ForeignKey(OrderPosition, on_delete=models.PROTECT)
|
||||
# content = models.BinaryField()
|
||||
# updated_at = models.DateTimeField(null=True, auto_now=True)
|
||||
@@ -1,280 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from .signals import (
|
||||
register_wallet_text_placeholders,
|
||||
register_wallet_image_placeholders,
|
||||
)
|
||||
from django.core.files import File
|
||||
from django.dispatch import receiver
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.templatetags.static import static
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
|
||||
class BaseWalletPlaceholder:
|
||||
"""
|
||||
This is the base class for all wallet placeholders.
|
||||
"""
|
||||
|
||||
@property
|
||||
def required_context(self) -> set[str]:
|
||||
"""
|
||||
A a set of all attribute names that need to be contained in the base context so that this placeholder is available.
|
||||
"""
|
||||
return set()
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""The unique identifier of this placeholder"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def label(self) -> LazyI18nString:
|
||||
"""The human readable name of this placeholder"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def control_label(self) -> LazyI18nString:
|
||||
"""
|
||||
The human readable name of this placeholder shown in the backend.
|
||||
|
||||
Defaults to `label`
|
||||
"""
|
||||
return self.label
|
||||
|
||||
def render(self, **context):
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, **context):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
|
||||
class BaseWalletTextPlaceholder(BaseWalletPlaceholder):
|
||||
def render(self, **context) -> str | None:
|
||||
"""
|
||||
This method is called to generate the text that is being shown on the pass.
|
||||
You will be passed the keyword arguments specified in ``required_context``.
|
||||
You are expected to return a plain-text string.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, **context) -> str:
|
||||
"""
|
||||
This method is called to generate a text to be used in previews.
|
||||
|
||||
You will be passed sample instances of the arguments specified in ``required_context``.
|
||||
If those instances contain all data needed, you do not need to implement this.
|
||||
"""
|
||||
sample = self.render(**context)
|
||||
if sample is None:
|
||||
raise RuntimeError("`render` returned None when rendering a sample")
|
||||
return sample
|
||||
|
||||
|
||||
class BaseWalletImagePlaceholder(BaseWalletPlaceholder):
|
||||
def render(self, **context) -> File | None:
|
||||
"""
|
||||
This method is called to generate the image that is being shown on the pass.
|
||||
You will be passed the keyword arguments specified in ``required_context``.
|
||||
You are expected to return a `File` object.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, **context) -> str | None:
|
||||
"""
|
||||
This method is called to generate a text to be used in previews.
|
||||
|
||||
You will be passed sample instances of the arguments specified in ``required_context``.
|
||||
You are expected to return a URL to a sample image or `None` if no sample can be shown.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class FunctionalWalletTextPlaceholder(BaseWalletTextPlaceholder):
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
label: LazyI18nString,
|
||||
args: set[str],
|
||||
func: Callable[..., str | None],
|
||||
sample: None | str | Callable[..., str] = None,
|
||||
):
|
||||
self._identifier = identifier
|
||||
self._label = label
|
||||
self._args = args
|
||||
self.render = func
|
||||
self._sample = sample
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return self._label
|
||||
|
||||
@property
|
||||
def required_context(self) -> set[str]:
|
||||
return self._args
|
||||
|
||||
def render_sample(self, **context) -> str:
|
||||
if isinstance(self._sample, Callable):
|
||||
return self._sample(**context)
|
||||
elif self._sample:
|
||||
return self._sample
|
||||
else:
|
||||
return super().render_sample(**context)
|
||||
|
||||
|
||||
class FunctionalWalletImagePlaceholder(BaseWalletImagePlaceholder):
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
label: LazyI18nString,
|
||||
args: set[str],
|
||||
func: Callable[..., File | None],
|
||||
sample: None | str | Callable[..., str] = None,
|
||||
):
|
||||
self._identifier = identifier
|
||||
self._label = label
|
||||
self._args = args
|
||||
self.render = func
|
||||
self._sample = sample
|
||||
|
||||
@property
|
||||
def required_context(self) -> set[str]:
|
||||
return self._args
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return self._label
|
||||
|
||||
def render_sample(self, **context) -> str | None:
|
||||
if isinstance(self._sample, Callable):
|
||||
return self._sample(**context)
|
||||
return self._sample
|
||||
|
||||
|
||||
class MissingContextException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WalletPlaceholderContext:
|
||||
def __init__(self, **kwargs):
|
||||
self.context_args = kwargs
|
||||
self.cache = {}
|
||||
|
||||
def _get_placeholder_context(self, placeholder: BaseWalletPlaceholder):
|
||||
missing_context = placeholder.required_context - self.context_args.keys()
|
||||
if missing_context:
|
||||
raise MissingContextException(
|
||||
f"Missing context args for '{placeholder.identifier}': {', '.join(missing_context)}"
|
||||
)
|
||||
|
||||
return {
|
||||
k: v for k, v in self.context_args.items() if k in placeholder.required_context
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def is_available(cls, placeholder: BaseWalletPlaceholder, context_args: set[str]):
|
||||
missing_context = placeholder.required_context - context_args
|
||||
return not missing_context
|
||||
|
||||
def render_placeholder(self, placeholder: BaseWalletPlaceholder):
|
||||
if placeholder.identifier in self.cache:
|
||||
return self.cache[placeholder.identifier]
|
||||
|
||||
value = self.cache[placeholder.identifier] = placeholder.render(**self._get_placeholder_context(placeholder))
|
||||
return value
|
||||
|
||||
def render_sample(self, placeholder: BaseWalletPlaceholder):
|
||||
return placeholder.render_sample(**self._get_placeholder_context(placeholder))
|
||||
|
||||
|
||||
def get_wallet_placeholders(event) -> dict[str, dict[str, BaseWalletPlaceholder]]:
|
||||
placeholders = {
|
||||
"text": {
|
||||
v.identifier: v
|
||||
for r, vs in register_wallet_text_placeholders.send(sender=event)
|
||||
for v in vs
|
||||
},
|
||||
"image": {
|
||||
v.identifier: v
|
||||
for r, vs in register_wallet_image_placeholders.send(sender=event)
|
||||
for v in vs
|
||||
},
|
||||
}
|
||||
return placeholders
|
||||
|
||||
|
||||
|
||||
def get_static_file(name) -> File | None:
|
||||
path: str | None = finders.find(name) # type: ignore
|
||||
if not path:
|
||||
return
|
||||
return File(open(path, "rb"))
|
||||
|
||||
|
||||
@receiver(
|
||||
register_wallet_text_placeholders,
|
||||
dispatch_uid="plugin_wallet_register_wallet_text_placeholders",
|
||||
)
|
||||
def base_text_placeholders(sender, **kwargs):
|
||||
return [
|
||||
FunctionalWalletTextPlaceholder("name", LazyI18nString.from_gettext("Event Name"), {"event"}, lambda event: event.name),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"event_slug", LazyI18nString.from_gettext("Event Slug"), {"event"}, lambda event: event.slug
|
||||
),
|
||||
FunctionalWalletTextPlaceholder("order", LazyI18nString.from_gettext("Order Code"), {"order"}, lambda order: order.code),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"total",
|
||||
LazyI18nString.from_gettext("Order Total"),
|
||||
{"event", "order"},
|
||||
lambda event, order: money_filter(order.total, event.currency),
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"order_email",LazyI18nString.from_gettext("Order Email"), {"order"}, lambda order: order.email
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"price",LazyI18nString.from_gettext("Item Price"), {"event", "order_position"}, lambda event, order_position: money_filter(order_position.price, event.currency)
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"secret",LazyI18nString.from_gettext("Order Secret (QR-Code-Content)"), {"order_position"}, lambda order_position: order_position.secret
|
||||
),
|
||||
]
|
||||
|
||||
@receiver(
|
||||
register_wallet_image_placeholders,
|
||||
dispatch_uid="plugin_wallet_register_wallet_image_placeholders",
|
||||
)
|
||||
def base_image_placeholders(sender, **kwargs):
|
||||
return [
|
||||
FunctionalWalletImagePlaceholder(
|
||||
"poweredby",
|
||||
LazyI18nString.from_gettext("Logo"),
|
||||
set(),
|
||||
# TODO: replace with paths not from another plugin
|
||||
lambda: get_static_file("pretix_passbook/logo.png"),
|
||||
static("pretix_passbook/logo.png"),
|
||||
),
|
||||
FunctionalWalletImagePlaceholder(
|
||||
"poweredby_icon",
|
||||
LazyI18nString.from_gettext("Icon"),
|
||||
set(),
|
||||
lambda: get_static_file("pretix_passbook/icon.png"),
|
||||
static("pretix_passbook/icon.png"),
|
||||
),
|
||||
FunctionalWalletImagePlaceholder(
|
||||
"example_no_preview",
|
||||
LazyI18nString.from_gettext("Image with no preview"),
|
||||
set(),
|
||||
lambda: get_static_file("pretix_passbook/icon.png"),
|
||||
),
|
||||
# TODO: Image upload
|
||||
]
|
||||
@@ -1,37 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix 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/>.
|
||||
#
|
||||
|
||||
from pretix.base.signals import register_ticket_outputs, register_global_settings, EventPluginSignal
|
||||
from .ticketoutput import OUTPUTS
|
||||
|
||||
def connect_signals():
|
||||
for output in OUTPUTS:
|
||||
# DIY functools.partial to make get_defining_app happy
|
||||
def get_register_func(o):
|
||||
def register(sender, **kwargs):
|
||||
return o
|
||||
return register
|
||||
register_ticket_outputs.connect(get_register_func(output), dispatch_uid=f"wallet_output_{output.identifier}")
|
||||
if hasattr(output, "get_global_settings"):
|
||||
register_global_settings.connect(output.get_global_settings, dispatch_uid=f"wallet_global_settings_{output.identifier}")
|
||||
|
||||
connect_signals()
|
||||
@@ -1,39 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix 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/>.
|
||||
#
|
||||
|
||||
from pretix.base.signals import EventPluginSignal
|
||||
|
||||
register_wallet_text_placeholders = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to get all known wallet placeholders. Receivers should return
|
||||
an list of subclasses of pretix.plugins.wallet.placeholders.BaseWalletTextPlaceholder.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
register_wallet_image_placeholders = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to get all known wallet placeholders. Receivers should return
|
||||
an list of subclasses of pretix.plugins.wallet.placeholders.BaseWalletImagePlaceholder.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
@@ -1,106 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watchEffect } from "vue";
|
||||
import StyleSettings from "./style-settings.vue";
|
||||
import Select from "./input/select.vue";
|
||||
import Input from "./input/input.vue";
|
||||
import PassPreview from "./preview/pass-preview.vue";
|
||||
import { StoreKey } from "../walletStore";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
function openForm(url: string, data: Record<string, string>) {
|
||||
let form = document.createElement("form");
|
||||
form.target = "_blank";
|
||||
form.method = "POST";
|
||||
form.action = url;
|
||||
form.style.display = "none";
|
||||
|
||||
for (var key in data) {
|
||||
var input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = key;
|
||||
input.value = data[key];
|
||||
form.appendChild(input);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
}
|
||||
|
||||
function openPreview(e: Event) {
|
||||
e.preventDefault();
|
||||
openForm("../../preview/", {
|
||||
csrfmiddlewaretoken: store.csrfToken,
|
||||
platform: store.currentPlatform,
|
||||
style: store.layout.style,
|
||||
layout: JSON.stringify(store.layout.layout),
|
||||
});
|
||||
}
|
||||
|
||||
const platformChoices = computed(() => {
|
||||
return [
|
||||
[null, "Do not generate pass"],
|
||||
...Object.values(store.platform.styles).map((x) => [x.identifier, x.name]),
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
// TODO: add :key for all `v-for`s
|
||||
// TODO: i18n textfields
|
||||
// TODO: proper spinner
|
||||
|
||||
template(v-if="!store.loaded") {{ gettext("Loading...") }}
|
||||
form(v-else @submit.prevent="store.save")
|
||||
.form-group
|
||||
Input(label="Name" v-model="store.name")
|
||||
nav
|
||||
ul.nav.nav-tabs
|
||||
li(v-for="platform in store.platforms" :class="{'active': store.platform.identifier === platform.identifier}")
|
||||
a(role="tab" @click="store.setPlatform(platform.identifier)") {{ platform.name }}
|
||||
.tabbed-form.tab-content
|
||||
.tab-pane.active.row
|
||||
.col-md-6.col-lg-8
|
||||
Select.form-group(label="Style" :modelValue="store.style?.identifier || null" @update:modelValue="store.setStyle" :choices="platformChoices")
|
||||
StyleSettings(v-if="!!store.style")
|
||||
.col-md-6.col-lg-4
|
||||
.panel.panel-default
|
||||
.panel-heading Preview
|
||||
.panel-body
|
||||
div(v-if="!!store.style?.preview_layout")
|
||||
span.text-muted {{ gettext("The preview below is only a rough representation of what the pass might look like. Please check the generated pass.") }}
|
||||
div(style="display: grid; gap: 1em; grid-template-columns: repeat(auto-fit, minmax(auto, 360px));")
|
||||
PassPreview(v-for="layout in store.style.preview_layout" :layout="layout")
|
||||
div(v-else) Preview not supported
|
||||
//- pre
|
||||
//- code {{ store.currentPlatformLayout }}
|
||||
//- pre(v-if="store.currentPlatformLayout.style")
|
||||
//- code {{ store.currentPlatformStyles[store.currentPlatformLayout.style] }}
|
||||
//- pre
|
||||
//- code {{ store.walletLayout }}
|
||||
.form-group.submit-group
|
||||
button.btn.btn-lg.btn-default(type="button" @click="openPreview") Preview
|
||||
button.btn.btn-primary.btn-save(type="submit") Submit
|
||||
|
||||
</template>
|
||||
|
||||
<style lang="css">
|
||||
.walletsettings-panel .panel-heading {
|
||||
.checkbox {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
input[type="checkbox"] {
|
||||
margin-top: 0;
|
||||
margin-right: 5px;
|
||||
margin-left: 0px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
label {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
}>()
|
||||
const modelValue = defineModel<boolean|null>();
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.checkbox
|
||||
label
|
||||
input(:id="id" v-model="modelValue" v-bind="$attrs" type="checkbox")
|
||||
| {{ props.label }}
|
||||
</template>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useId, watchEffect } from "vue";
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
const emit = defineEmits<{change: [File]}>()
|
||||
const props = defineProps<{
|
||||
label?: I18nString;
|
||||
errors?: string[];
|
||||
help_text?: string;
|
||||
filename?: string;
|
||||
current_url?: string;
|
||||
}>();
|
||||
const id = useId();
|
||||
function onChange(e) {
|
||||
emit("change", (e.target as HTMLInputElement).files[0])
|
||||
}
|
||||
|
||||
// Reset input field if a url is provided
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
watchEffect(() => {
|
||||
if (props.current_url && inputRef.value?.value) {
|
||||
inputRef.value.value = '';
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.form-group.row
|
||||
label.control-label.col-md-3(:for="id", v-if="!!label") {{ label }}
|
||||
br(v-if="!$attrs.required")
|
||||
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
|
||||
div.col-md-9
|
||||
template(v-if="!!current_url")
|
||||
| {{ gettext("Currently") + ': ' }}
|
||||
a(:href="current_url") {{ filename }}
|
||||
| {{ " " }}
|
||||
button.btn.btn-sm(@click.prevent="() => {console.log('clear'); emit('change', null)}") {{ gettext("Clear") }}
|
||||
//- br
|
||||
//- a(:href="modelValue" data-lightbox="input")
|
||||
//- img.thumb-img(:src="modelValue")
|
||||
br
|
||||
| {{ gettext("Change") + ': ' }}
|
||||
input(:id="id" @change="onChange" v-bind="$attrs" type="file" style="display: inline" ref="inputRef")
|
||||
.help-block(v-if="!!help_text") {{ help_text }}
|
||||
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.thumb-img {
|
||||
max-height: 100px;
|
||||
max-width: 200px;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, watchEffect } from 'vue'
|
||||
import { StoreKey } from "../../walletStore";
|
||||
|
||||
const store = inject(StoreKey)!
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
errors?: string[],
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel<Record<string, string> | string>();
|
||||
watchEffect(() => {
|
||||
if (typeof modelValue.value === "string") {
|
||||
const oldVal = modelValue.value;
|
||||
modelValue.value = Object.fromEntries(Object.keys(store.locales).map((x): [string, string] => [x, oldVal]))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
input.form-control(v-for="(human_readable, locale) in store.locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="human_readable")
|
||||
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
|
||||
</template>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {label, errors, type = "text"} = defineProps<{
|
||||
label?: I18nString,
|
||||
errors?: string[],
|
||||
type?: string
|
||||
help_text?: string,
|
||||
}>()
|
||||
const modelValue = defineModel<string|null>();
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
label.control-label(:for="id", v-if="label") {{ label }}
|
||||
br(v-if="!$attrs.required")
|
||||
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
|
||||
div
|
||||
input(:id="id" v-model="modelValue" v-bind="$attrs" :type="type" :class="{'form-control': type == 'text'}")
|
||||
.help-block(v-if="!!help_text") {{ help_text }}
|
||||
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
|
||||
</template>
|
||||
@@ -1,32 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useId, watchEffect } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
choices: Array<[string, string]>
|
||||
errors?: string[],
|
||||
class?: string
|
||||
}>()
|
||||
const modelValue = defineModel<string|null>();
|
||||
const id = useId()
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.choices.length === 1) {
|
||||
modelValue.value = props.choices[0][0]
|
||||
} else if (props.choices.length < 1) {
|
||||
modelValue.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
template(v-if="choices.length >= 1" :class="props.class")
|
||||
label.control-label(v-if="props.label" :for="id") {{ props.label }}
|
||||
select.form-control(:id="id" v-model="modelValue" v-bind="$attrs" required)
|
||||
option(v-for="choice in props.choices" :key="choice[0]" :value="choice[0]") {{ choice[1] }}
|
||||
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
|
||||
</template>
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, watchEffect } from "vue";
|
||||
import Select from "./input/select.vue";
|
||||
import Checkbox from "./input/checkbox.vue";
|
||||
import I18nInput from "./input/i18ninput.vue";
|
||||
import TextContent from "./text-content.vue";
|
||||
import { StoreKey } from "../walletStore";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const props = defineProps<{
|
||||
fieldgroup: PlaceholderFieldGroupDefinition;
|
||||
overflows: FieldGroupDefinition[];
|
||||
}>();
|
||||
const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({
|
||||
required: true,
|
||||
});
|
||||
|
||||
const overflowOptions = computed((): Array<[string | null, string]> => {
|
||||
if (props.overflows.length) {
|
||||
return [
|
||||
...props.overflows.map((x): [string, string] => [x.identifier, x.name]),
|
||||
[null, "Do not overflow"],
|
||||
];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
function addVariable() {
|
||||
fieldConfig.value.entries.push({ type: "placeholder", label: "" });
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!fieldConfig.value) {
|
||||
fieldConfig.value = {
|
||||
overflow: null,
|
||||
entries: JSON.parse(JSON.stringify(props.fieldgroup.default_entries)),
|
||||
active:
|
||||
props.fieldgroup.required ||
|
||||
props.fieldgroup.default_entries.length > 0,
|
||||
};
|
||||
}
|
||||
if (fieldConfig.value && !fieldConfig.value.entries) {
|
||||
fieldConfig.value.entries = JSON.parse(
|
||||
JSON.stringify(props.fieldgroup.default_entries),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const placeholderChoices = computed(() => {
|
||||
const availableContext = new Set(props.fieldgroup.context_args);
|
||||
const choices = Object.entries(store.variables.text)
|
||||
.filter(([_, { required_context }]) =>
|
||||
new Set(required_context).isSubsetOf(availableContext),
|
||||
)
|
||||
.map(([k, v]): [string, string] => [k, v.label]);
|
||||
choices.push(["other", gettext("Other…")]);
|
||||
return choices;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.panel.panel-default.walletsettings-panel
|
||||
.panel-heading
|
||||
h3.panel-title {{ fieldgroup.name }}
|
||||
.panel-body(v-if="fieldConfig")
|
||||
.form-group()
|
||||
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
|
||||
h4 {{ gettext("Content") }}
|
||||
table.table.table-hover
|
||||
thead
|
||||
tr
|
||||
th.col-md-5(v-if="fieldgroup.display == 'with_label'") {{ gettext('Label') }}
|
||||
th(:class="'col-md-' + (fieldgroup.display == 'with_label' ? '6' : '11')") {{ gettext('Content') }}
|
||||
th.col-xs-1
|
||||
tbody
|
||||
tr(v-for="n, i in fieldConfig.entries.length" :key="i")
|
||||
td(v-if="fieldgroup.display == 'with_label'")
|
||||
.i18n-form-group
|
||||
I18nInput(v-model="fieldConfig.entries[n - 1].label")
|
||||
td
|
||||
TextContent(v-if='fieldgroup.content_type == "text"'
|
||||
v-model="fieldConfig.entries[n - 1]"
|
||||
:placeholderChoices="placeholderChoices")
|
||||
Select(v-else-if='fieldgroup.content_type == "image"'
|
||||
v-model="fieldConfig.entries[n - 1].content"
|
||||
:choices="Object.entries(store.variables.image).map(([k, v]) => [k, v.label])"
|
||||
)
|
||||
td.text-right
|
||||
button.btn.btn-danger.form-control-static(type="button" @click="fieldConfig.entries.splice(n - 1, 1)")
|
||||
i.fa.fa-trash
|
||||
span.sr-only {{ gettext('Delete') }}
|
||||
|
||||
button.btn.btn-default(type="button" @click="addVariable")
|
||||
i.fa.fa-plus
|
||||
| {{ gettext("Add field") }}
|
||||
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
|
||||
</template>
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { watchEffect } from 'vue';
|
||||
import Input from './input/input.vue';
|
||||
import Checkbox from './input/checkbox.vue';
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const props = defineProps<{
|
||||
fieldgroup: FieldGroupDefinition;
|
||||
}>();
|
||||
const fieldConfig = defineModel<PredefinedFieldGroupConfig>({ required: true });
|
||||
|
||||
watchEffect(() => {
|
||||
if (!fieldConfig.value) {
|
||||
fieldConfig.value = {active: props.fieldgroup.required};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.panel.panel-default.walletsettings-panel
|
||||
.panel-heading
|
||||
h3.panel-title.form-inline
|
||||
.form-group
|
||||
Checkbox(v-if="!!fieldConfig" :label="fieldgroup.name" v-model="fieldConfig.active")
|
||||
.panel-body(v-if="!!fieldgroup.description")
|
||||
.form-group
|
||||
span.text-muted {{ fieldgroup.description }}
|
||||
</template>
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import { i18nstringLocalize } from "../../helpers";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
label?: I18nString;
|
||||
content: I18nString;
|
||||
content_type: FieldContentType;
|
||||
display: FieldGroupDisplay;
|
||||
display_class?: string | string[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-item
|
||||
div.fieldgroup-item-qrcode(v-if="display == 'code'") {{ label }}
|
||||
|
||||
template(v-else)
|
||||
div.fieldgroup-label.nowrap(v-if="display == 'with_label'") {{ i18nstringLocalize(label) }}
|
||||
div.nowrap.content(v-if="content_type == 'text'" :class="display_class") {{ i18nstringLocalize(content) }}
|
||||
img.fieldgroup-item-image(v-else-if="content_type == 'image' && content" :src="i18nstringLocalize(content)")
|
||||
</template>
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import PlaceholderFieldgroupPreview from "./placeholder-fieldgroup-preview.vue";
|
||||
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
config: PreviewFieldgroup;
|
||||
}>();
|
||||
|
||||
const style_def = computed(() => {
|
||||
return Object.fromEntries(
|
||||
store.styles[
|
||||
store.layout.style
|
||||
].fieldgroups.map((x) => [x.identifier, x]),
|
||||
)[props.config.fieldgroup];
|
||||
});
|
||||
|
||||
function focusGroup() {
|
||||
// TODO: highlight group? or change ui concept completely
|
||||
const elem = document.getElementById('fieldgroup-' + props.config.fieldgroup)
|
||||
elem && elem.scrollIntoView()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div(@click="focusGroup")
|
||||
PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def")
|
||||
PredefinedFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def")
|
||||
</template>
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from "vue";
|
||||
import { StoreKey } from "../../walletStore.js";
|
||||
import RowPreview from "./row-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{ layout: Array<PreviewLayout> }>();
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.pass-container
|
||||
div.pass-content
|
||||
RowPreview(v-for="row of layout" :config="row")
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.pass-container {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
vertical-align: top;
|
||||
display: inline-block;
|
||||
|
||||
border: solid 1px #949494;
|
||||
border-radius: 1em;
|
||||
padding: 1em;
|
||||
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
.pass-content {
|
||||
overflow: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="css">
|
||||
.pass-container {
|
||||
.fieldgroup-container {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
min-height: 2lh;
|
||||
}
|
||||
.fieldgroup-label {
|
||||
font-weight: bold;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.fieldgroup-item {
|
||||
flex: 0 1 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.fieldgroup-item-image {
|
||||
max-height: 3lh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.fieldgroup-item-qrcode {
|
||||
font-weight: bold;
|
||||
background-color: lightgray;
|
||||
width: 50%;
|
||||
aspect-ratio: 1;
|
||||
margin: auto;
|
||||
/* center content */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 1em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.nowrap {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.large {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.tight {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
config: PreviewFieldgroup;
|
||||
style_def: PlaceholderFieldGroupDefinition;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
|
||||
FieldgroupItemPreview(v-for="{ label, content } of store.renderedFieldGroups[config.fieldgroup]" :label="label" :content="content" :content_type="style_def.content_type" :display_class="config.display" :display="style_def.display")
|
||||
</template>
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
style_def: PredefinedFieldGroupDefinition;
|
||||
config: PredefinedFieldgroupPreview;
|
||||
}>();
|
||||
|
||||
const isActive = computed(
|
||||
() =>
|
||||
store.layout.fieldgroups[props.style_def.identifier]?.active,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-container(v-if="isActive" :style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
|
||||
FieldgroupItemPreview(v-for="{ label, content } of config.sample" :label="label" :content="content" content_type="text" display="with_label")
|
||||
</template>
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import FieldgroupItemPreview from './fieldgroup-item-preview.vue';
|
||||
import FieldgroupPreview from './fieldgroup-preview.vue';
|
||||
import SettingPreview from './setting-preview.vue';
|
||||
const props = defineProps<{
|
||||
config: PreviewRow;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.preview-row(v-if="'children' in config" :style="{ flexDirection: config.direction || 'row' }" :class="config.display")
|
||||
RowPreview(v-for="child of config.children" :config="child" )
|
||||
FieldgroupPreview(v-else-if="'fieldgroup' in config" :config="config")
|
||||
FieldgroupItemPreview(v-else-if="'value' in config" :label="config.label" :content="config.value" content_type="text" :display="!!config.label ? 'with_label' : 'plain'" :display_class="config.display")
|
||||
SettingPreview(v-else-if="'setting' in config" :config="config")
|
||||
|
||||
</template>
|
||||
<style lang="css" scoped>
|
||||
.preview-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
display: flex
|
||||
}
|
||||
.tight {
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
|
||||
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const { config } = defineProps<{
|
||||
config: SettingPreview;
|
||||
}>();
|
||||
|
||||
const settingDef = computed(() => {
|
||||
return Object.fromEntries(
|
||||
store.styles[store.layout.style].settings.map(
|
||||
(x) => [x.identifier, x],
|
||||
),
|
||||
)[config.setting];
|
||||
});
|
||||
const settingValue = computed(() => {
|
||||
const val = store.settings[config.setting];
|
||||
if (settingDef.value.type == "image" && val instanceof File) {
|
||||
return URL.createObjectURL(val);
|
||||
} else {
|
||||
return val;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
//- pre
|
||||
//- code {{ settingDef }}
|
||||
//- pre
|
||||
//- code {{ config }}
|
||||
//- pre
|
||||
//- code {{ settingValue }}
|
||||
//- PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def")
|
||||
//- PredefinedFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def")
|
||||
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
|
||||
FieldgroupItemPreview(:content="settingValue" :content_type="settingDef.type" :display_class="config.display" display="plain")
|
||||
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Input from "./input/input.vue";
|
||||
import FileInput from "./input/file-input.vue";
|
||||
import { inject } from "vue";
|
||||
import { StoreKey } from "../walletStore";
|
||||
|
||||
const props = defineProps<{
|
||||
field?: Setting;
|
||||
}>();
|
||||
const store = inject(StoreKey)!;
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.form-group
|
||||
Input(v-if='field.type == "text"' :label="field.label" :type="field.type" :required="field.required" @update:modelValue="(v) => store.setSetting(field.identifier, v)" :modelValue="store.settings[field.identifier]" :help_text="field.help_text")
|
||||
FileInput(v-if='field.type == "image"' :label="field.label" :required="field.required" :help_text="field.help_text" @change="(v) => store.setSetting(field.identifier, v)" :filename="store.settings[field.identifier]?.name" :current_url="store.settings[field.identifier]?.name")
|
||||
</template>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from "vue";
|
||||
import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
|
||||
import PredefinedFieldSettings from "./predefined-field-settings.vue";
|
||||
import SettingsField from "./settings-field.vue";
|
||||
import { StoreKey } from "../walletStore.js";
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
h2.h3 {{ gettext("Settings") }}
|
||||
SettingsField(v-for="field of store.style.settings" :field="field" :key="field.identifier")
|
||||
h2.h3 {{ gettext("Field Groups") }}
|
||||
div(v-for="(fieldgroup, fieldgroupId) in store.style.fieldgroups" :id="'fieldgroup-' + fieldgroup.identifier")
|
||||
PlaceholderFieldSettings(
|
||||
v-if="fieldgroup.type == 'placeholder'"
|
||||
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
|
||||
:fieldgroup="fieldgroup"
|
||||
:overflows="store.style.fieldgroups.slice(fieldgroupId + 1) \
|
||||
.filter(x => x.type == 'placeholder' && x.content_type === fieldgroup.content_type)"
|
||||
)
|
||||
PredefinedFieldSettings(v-else-if="fieldgroup.type == 'predefined'"
|
||||
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
|
||||
:fieldgroup="fieldgroup")
|
||||
</template>
|
||||
@@ -1,57 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive } from 'vue'
|
||||
import Select from './input/select.vue'
|
||||
import I18nInput from './input/i18ninput.vue'
|
||||
import { StoreKey } from "../walletStore";
|
||||
|
||||
const store = inject(StoreKey)!
|
||||
const gettext = (window as any).gettext
|
||||
|
||||
const props = defineProps<{
|
||||
placeholderChoices: [string|null, string][];
|
||||
}>();
|
||||
|
||||
const entry = defineModel<FieldEntry>({ required: true })
|
||||
|
||||
const selection = computed({
|
||||
get() {
|
||||
if (entry.value.type === 'placeholder') {
|
||||
return entry.value.content
|
||||
} else if (entry.value.type === 'custom') {
|
||||
return "other"
|
||||
}
|
||||
},
|
||||
set(newValue) {
|
||||
if (newValue == "other") {
|
||||
entry.value.type = "custom"
|
||||
entry.value.content = {};
|
||||
} else {
|
||||
entry.value.type = "placeholder"
|
||||
entry.value.content = newValue
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const textContent = computed({
|
||||
get() {
|
||||
if (entry.value.type === 'placeholder') {
|
||||
return ""
|
||||
} else if (entry.value.type === 'custom') {
|
||||
return entry.value.content
|
||||
}
|
||||
},
|
||||
set(newValue) {
|
||||
entry.value.content = newValue
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.i18n-form-group
|
||||
Select(
|
||||
v-model="selection"
|
||||
:choices="placeholderChoices"
|
||||
)
|
||||
I18nInput(v-model="textContent" v-if="selection === 'other'" :locales="store.locales")
|
||||
</template>
|
||||
@@ -1,32 +0,0 @@
|
||||
export function i18nstringLocalize(s: I18nString): string {
|
||||
if (typeof s === 'string') {
|
||||
return s
|
||||
}
|
||||
if (s === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
var locale = document.body.attributes['data-pretixlocale'].value
|
||||
var short_locale = locale.split('-')[0]
|
||||
if (locale in s)
|
||||
return s[locale]
|
||||
|
||||
if (short_locale in s)
|
||||
return s[short_locale]
|
||||
|
||||
for (const k of Object.keys(s)) {
|
||||
if (k.split('-')[0] === short_locale && s[k]) {
|
||||
return s[k]
|
||||
}
|
||||
}
|
||||
|
||||
if (s['en'])
|
||||
return s['en']
|
||||
|
||||
for (const k of Object.keys(s)) {
|
||||
if (s[k]) {
|
||||
return s[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
type Platform = {
|
||||
identifier: string;
|
||||
name: string;
|
||||
styles: Styles;
|
||||
};
|
||||
|
||||
type Style = {
|
||||
identifier: string;
|
||||
name: string;
|
||||
fieldgroups: FieldGroupDefinition[];
|
||||
settings: Setting[];
|
||||
};
|
||||
|
||||
type Variable = {
|
||||
label: string;
|
||||
sample: string;
|
||||
required_context: string[];
|
||||
};
|
||||
|
||||
type Styles = Record<string, Style>;
|
||||
type Variables = Record<string, Variable>;
|
||||
type VariableConfig = Record<string, Variables>;
|
||||
type Platforms = Platform[];
|
||||
|
||||
//
|
||||
|
||||
type BaseFieldGroupDefinition = {
|
||||
type: string;
|
||||
identifier: string;
|
||||
name: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type FieldGroupDefinition =
|
||||
| PlaceholderFieldGroupDefinition
|
||||
| PredefinedFieldGroupDefinition;
|
||||
|
||||
type FieldGroupDisplay = "plain" | "with_label" | "code";
|
||||
|
||||
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
|
||||
type: "placeholder";
|
||||
content_type: FieldContentType;
|
||||
default_entries: FieldEntry[];
|
||||
display: FieldGroupDisplay;
|
||||
min_entries: number | null;
|
||||
max_entries: number | null;
|
||||
context_args: string[];
|
||||
};
|
||||
|
||||
type PredefinedFieldGroupDefinition = BaseFieldGroupDefinition & {
|
||||
type: "predefined";
|
||||
};
|
||||
|
||||
type I18nString = null | string | Record<string, string>;
|
||||
|
||||
type FieldContentType = "text" | "image";
|
||||
|
||||
type PlaceholderFieldEntry = {
|
||||
type: "placeholder";
|
||||
label?: I18nString;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type CustomFieldEntry = {
|
||||
type: "custom";
|
||||
label?: I18nString;
|
||||
content?: I18nString;
|
||||
};
|
||||
|
||||
type FieldEntry = PlaceholderFieldEntry | CustomFieldEntry;
|
||||
|
||||
type Setting = {
|
||||
identifier: string;
|
||||
label: string;
|
||||
type: "text" | "image";
|
||||
required: boolean;
|
||||
help_text: string;
|
||||
};
|
||||
|
||||
type PlaceholderFieldGroupConfig = {
|
||||
entries: Array<FieldEntry>;
|
||||
overflow: string | null;
|
||||
};
|
||||
|
||||
type PredefinedFieldGroupConfig = {
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type FieldGroupConfig =
|
||||
| PlaceholderFieldGroupConfig
|
||||
| PredefinedFieldGroupConfig;
|
||||
|
||||
type LayoutData = {
|
||||
fieldgroups?: Record<string, FieldGroupConfig>;
|
||||
settings?: Record<string, any>;
|
||||
};
|
||||
|
||||
type PlatformLayout = {
|
||||
platform: string;
|
||||
style: string | null;
|
||||
layout: LayoutData;
|
||||
};
|
||||
|
||||
type WalletLayout = {
|
||||
name?: string;
|
||||
platform_layouts: PlatformLayout[];
|
||||
};
|
||||
type WalletStore = {
|
||||
platforms: Platforms;
|
||||
variables: VariableConfig;
|
||||
locales: Record<string, string>;
|
||||
csrfToken: String;
|
||||
walletLayout: WalletLayout | null;
|
||||
};
|
||||
|
||||
type PreviewLayout = Array<PreviewRow>;
|
||||
type PreviewRow =
|
||||
| {
|
||||
children: Array<PreviewRow>;
|
||||
direction?: "row" | "column";
|
||||
display?: Array<string>;
|
||||
}
|
||||
| PreviewFieldgroup
|
||||
| FixedPreview
|
||||
| SettingPreview;
|
||||
|
||||
type PreviewProps = {
|
||||
relSize?: number;
|
||||
direction?: "row" | "column";
|
||||
display?: Array<string>;
|
||||
};
|
||||
type SettingPreview = {
|
||||
setting: string;
|
||||
} & PreviewProps;
|
||||
|
||||
type PreviewFieldgroup =
|
||||
| PredefinedFieldgroupPreview
|
||||
| PlaceholderFieldGroupPreview;
|
||||
type FixedPreview = { value: I18nString; label?: I18nString } & PreviewProps;
|
||||
|
||||
type PlaceholderFieldGroupPreview = {
|
||||
fieldgroup: string;
|
||||
} & PreviewProps;
|
||||
|
||||
type PredefinedFieldgroupPreview = {
|
||||
fieldgroup: string;
|
||||
sample: PreviewSample[];
|
||||
} & PreviewProps;
|
||||
|
||||
type PreviewSample = {
|
||||
content: I18nString;
|
||||
label: I18nString;
|
||||
};
|
||||
|
||||
type NewPlatformLayout = {
|
||||
style: string;
|
||||
fieldgroups: {};
|
||||
settings: {};
|
||||
file_settings: Record<string, WalletFile>;
|
||||
};
|
||||
type ServerSideFile = { url: string; name: string };
|
||||
type ClientSideFile = { file: File | null; identifier?: string };
|
||||
type WalletFile = ServerSideFile | ClientSideFile;
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../../../pretix/static/pretixpresale/widget/src/lib/store.ts
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./components/app.vue";
|
||||
import { createWalletStore, StoreKey } from "./walletStore";
|
||||
const mountEl = document.querySelector<HTMLElement>("#editor")!;
|
||||
const store = createWalletStore({
|
||||
platforms: JSON.parse(
|
||||
document.querySelector("#platforms")?.textContent ?? "{}",
|
||||
),
|
||||
variables: JSON.parse(
|
||||
document.querySelector("#variables")?.textContent ?? "{}",
|
||||
),
|
||||
locales: JSON.parse(document.querySelector("#locales")?.textContent ?? "{}"),
|
||||
csrfToken: document.querySelector<HTMLInputElement>(
|
||||
"input[name=csrfmiddlewaretoken]",
|
||||
)?.value!!,
|
||||
layoutId: mountEl.dataset.layoutId!
|
||||
});
|
||||
store.load();
|
||||
const app = createApp(App);
|
||||
app.provide(StoreKey, store);
|
||||
app.mount(mountEl);
|
||||
|
||||
app.config.errorHandler = (error, _vm, info) => {
|
||||
// vue fatals on errors by default, which is a weird choice
|
||||
// https://github.com/vuejs/core/issues/3525
|
||||
// https://github.com/vuejs/router/discussions/2435
|
||||
console.error("[VUE]", info, error);
|
||||
};
|
||||
@@ -1,319 +0,0 @@
|
||||
import { serialize } from "node:v8";
|
||||
import { i18nstringLocalize } from "./helpers.js";
|
||||
import { createStore } from "./lib/store.ts";
|
||||
import { toRaw, type InjectionKey } from "vue";
|
||||
|
||||
export type WidgetStore = ReturnType<typeof createWalletStore>;
|
||||
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
|
||||
|
||||
function getDefaultFieldgroupState(
|
||||
fieldgroup: FieldGroupDefinition,
|
||||
): FieldGroupConfig {
|
||||
if (fieldgroup.type == "predefined") {
|
||||
return { active: fieldgroup.required };
|
||||
} else if (fieldgroup.type == "placeholder") {
|
||||
return {
|
||||
overflow: null,
|
||||
entries: JSON.parse(JSON.stringify(fieldgroup.default_entries)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseExistingFieldgroupState(
|
||||
fieldgroup: FieldGroupDefinition,
|
||||
existing?: FieldGroupConfig,
|
||||
): FieldGroupConfig {
|
||||
if (!existing) {
|
||||
return getDefaultFieldgroupState(fieldgroup);
|
||||
}
|
||||
if (fieldgroup.type == "predefined") {
|
||||
return {
|
||||
active: "active" in existing ? existing.active : fieldgroup.required,
|
||||
};
|
||||
} else if (fieldgroup.type == "placeholder") {
|
||||
return {
|
||||
overflow: "overflow" in existing ? existing.overflow : null,
|
||||
// TODO: check that placeholders are possible
|
||||
entries: structuredClone(
|
||||
toRaw(
|
||||
"entries" in existing ? existing.entries : fieldgroup.default_entries,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createWalletStore(config: {
|
||||
platforms: Platforms;
|
||||
variables: VariableConfig;
|
||||
locales: Record<string, string>;
|
||||
csrfToken: string;
|
||||
layoutId: string;
|
||||
}) {
|
||||
return createStore({
|
||||
state: () => ({
|
||||
...config,
|
||||
loaded: false,
|
||||
activePlatform: config.platforms[0].identifier,
|
||||
name: null as string | null,
|
||||
platformLayouts: {} as Record<string, NewPlatformLayout>,
|
||||
}),
|
||||
getters: {
|
||||
platform() {
|
||||
return this.getPlatform(this.activePlatform);
|
||||
},
|
||||
layout() {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
return this.platformLayouts[this.activePlatform] || null;
|
||||
},
|
||||
style(): Style {
|
||||
if (this.layout) {
|
||||
return this.platform.styles[this.layout.style];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
styles() {
|
||||
return this.platform.styles;
|
||||
},
|
||||
settings() {
|
||||
const settings = {};
|
||||
for (const setting of this.style.settings) {
|
||||
if (setting.type == "text") {
|
||||
settings[setting.identifier] =
|
||||
this.layout.settings[setting.identifier];
|
||||
} else if (setting.type == "image") {
|
||||
settings[setting.identifier] =
|
||||
this.layout.file_settings[setting.identifier];
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
},
|
||||
renderedFieldGroups() {
|
||||
const content = {};
|
||||
const group_defs = this.style.fieldgroups;
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
content[fieldgroup.identifier] = [];
|
||||
const layout_group = this.layout.fieldgroups[
|
||||
fieldgroup.identifier
|
||||
] as any as PlaceholderFieldGroupConfig;
|
||||
for (const entry of layout_group.entries) {
|
||||
const placeholder =
|
||||
entry.type === "placeholder"
|
||||
? this.variables[fieldgroup.content_type][entry.content]
|
||||
: null;
|
||||
|
||||
let label = i18nstringLocalize(entry.label);
|
||||
if (placeholder && !label) {
|
||||
label = i18nstringLocalize(placeholder.label);
|
||||
}
|
||||
|
||||
let value = null;
|
||||
if (entry.type == "custom") {
|
||||
value = i18nstringLocalize(entry.content);
|
||||
} else if (entry.type == "placeholder") {
|
||||
value =
|
||||
placeholder?.sample ||
|
||||
`(unknown placeholder: ${entry.content})`;
|
||||
}
|
||||
content[fieldgroup.identifier].push({
|
||||
entry,
|
||||
label,
|
||||
content: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
const layout_group: PlaceholderFieldGroupConfig =
|
||||
this.layout.fieldgroups[
|
||||
fieldgroup.identifier
|
||||
];
|
||||
if (
|
||||
fieldgroup.max_entries &&
|
||||
content[fieldgroup.identifier].length > fieldgroup.max_entries
|
||||
) {
|
||||
const overflow = content[fieldgroup.identifier].slice(
|
||||
fieldgroup.max_entries,
|
||||
);
|
||||
content[fieldgroup.identifier] = content[
|
||||
fieldgroup.identifier
|
||||
].slice(0, fieldgroup.max_entries);
|
||||
if (layout_group.overflow) {
|
||||
content[layout_group.overflow].splice(0, 0, ...overflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
getPlatform(identifier: string): Platform {
|
||||
for (const platform of this.platforms) {
|
||||
if (platform.identifier === identifier) {
|
||||
return platform;
|
||||
}
|
||||
}
|
||||
},
|
||||
load() {
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
this.parseServerLayout(x);
|
||||
this.loaded = true;
|
||||
})
|
||||
.catch(alert);
|
||||
},
|
||||
parseServerLayout(serverLayout) {
|
||||
this.name = serverLayout.name;
|
||||
for (const layout of serverLayout.platform_layouts) {
|
||||
const clientLayout = {
|
||||
style: layout.style,
|
||||
fieldgroups: {},
|
||||
settings: layout.layout.settings,
|
||||
file_settings: layout.file_settings,
|
||||
};
|
||||
const styleDefinition = this.getPlatform(layout.platform).styles[
|
||||
layout.style
|
||||
];
|
||||
console.log(styleDefinition);
|
||||
for (const fieldgroup of styleDefinition.fieldgroups) {
|
||||
clientLayout.fieldgroups[fieldgroup.identifier] =
|
||||
parseExistingFieldgroupState(
|
||||
fieldgroup,
|
||||
layout.layout.fieldgroups[fieldgroup.identifier],
|
||||
);
|
||||
}
|
||||
this.platformLayouts[layout.platform] = clientLayout;
|
||||
}
|
||||
},
|
||||
setPlatform(platform: string) {
|
||||
this.activePlatform = platform;
|
||||
},
|
||||
setStyle(style: string | null) {
|
||||
if (style === null) {
|
||||
delete this.platformLayouts[this.activePlatform];
|
||||
} else if (Object.keys(this.platform.styles).includes(style)) {
|
||||
const newLayout = {
|
||||
style,
|
||||
fieldgroups: {},
|
||||
settings: {},
|
||||
file_settings: {},
|
||||
};
|
||||
for (const fieldgroup of this.platform.styles[style].fieldgroups) {
|
||||
newLayout.fieldgroups[fieldgroup.identifier] =
|
||||
getDefaultFieldgroupState(fieldgroup);
|
||||
}
|
||||
this.platformLayouts[this.activePlatform] = newLayout;
|
||||
// TODO: keep old fieldgroups & settings if matching
|
||||
}
|
||||
},
|
||||
getSetting(identifier: string): Setting {
|
||||
for (const setting of this.style.settings) {
|
||||
if (setting.identifier === identifier) {
|
||||
return setting;
|
||||
}
|
||||
}
|
||||
},
|
||||
setSetting(identifier: string, value: string | File) {
|
||||
const setting = this.getSetting(identifier);
|
||||
if (!setting) return;
|
||||
|
||||
if (
|
||||
setting.type === "image" &&
|
||||
(value instanceof File || value === null)
|
||||
) {
|
||||
this.layout.file_settings[setting.identifier] = {
|
||||
file: value as File | null,
|
||||
};
|
||||
} else if (setting.type == "text" && typeof value == "string") {
|
||||
this.layout.settings[setting.identifier] = value;
|
||||
}
|
||||
},
|
||||
async uploadFile(file: ClientSideFile) {
|
||||
return await fetch("/api/v1/upload", {
|
||||
method: "POST",
|
||||
body: file.file,
|
||||
headers: {
|
||||
"content-disposition": `attachment; filename="${encodeURI(file.file.name)}"`,
|
||||
"content-type": file.file.type || "application/octet-stream",
|
||||
"X-CSRFToken": this.csrfToken,
|
||||
},
|
||||
})
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
file.identifier = x.id;
|
||||
return x.id;
|
||||
})
|
||||
.catch(alert);
|
||||
},
|
||||
async serializePlatformLayout(platform, layout: NewPlatformLayout) {
|
||||
const uploadedFiles = Object.fromEntries(
|
||||
(
|
||||
await Promise.all(
|
||||
Object.entries(layout.file_settings).map(async ([k, v]) => {
|
||||
if ("url" in v) {
|
||||
return;
|
||||
} else if ("identifier" in v) {
|
||||
return [k, v.identifier];
|
||||
} else if ("file" in v && v.file instanceof File) {
|
||||
return [k, await this.uploadFile(v)];
|
||||
} else if ("file" in v && v.file == null) {
|
||||
return [k, null];
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((x) => !!x),
|
||||
);
|
||||
return {
|
||||
platform,
|
||||
style: layout.style,
|
||||
file_settings: uploadedFiles,
|
||||
layout: {
|
||||
fieldgroups: layout.fieldgroups,
|
||||
settings: layout.settings,
|
||||
},
|
||||
};
|
||||
},
|
||||
async serializeLayout() {
|
||||
const layoutPromises = Object.entries(this.platformLayouts).map(
|
||||
([platform, layout]) =>
|
||||
this.serializePlatformLayout(platform, layout),
|
||||
);
|
||||
return {
|
||||
name: this.name,
|
||||
platform_layouts: await Promise.all(layoutPromises),
|
||||
};
|
||||
},
|
||||
async save() {
|
||||
const serializedLayout = await this.serializeLayout();
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-CSRFToken": this.csrfToken,
|
||||
},
|
||||
body: JSON.stringify(serializedLayout),
|
||||
},
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.catch((x) => alert(x))
|
||||
.then((x) => {
|
||||
this.parseServerLayout(x);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import { i18nstringLocalize } from "./helpers.js";
|
||||
import { createStore } from "./lib/store.ts";
|
||||
import { toRaw, type InjectionKey } from "vue";
|
||||
|
||||
export type WidgetStore = ReturnType<typeof createWalletStore>;
|
||||
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
|
||||
|
||||
export function createWalletStore(config: {
|
||||
platforms: Platforms;
|
||||
variables: VariableConfig;
|
||||
locales: Record<string, string>;
|
||||
csrfToken: string;
|
||||
layoutId: string;
|
||||
}) {
|
||||
return createStore({
|
||||
state: () => ({
|
||||
...config,
|
||||
walletLayout: null as WalletLayout | null,
|
||||
currentPlatform: config.platforms[0].identifier,
|
||||
loaded: false,
|
||||
files: {} as Record<string, File>
|
||||
}),
|
||||
getters: {
|
||||
currentPlatformStyles() {
|
||||
for (const platform of this.platforms) {
|
||||
if (platform.identifier === this.currentPlatform) {
|
||||
return platform.styles;
|
||||
}
|
||||
}
|
||||
throw "Unknown platform";
|
||||
},
|
||||
currentPlatformLayout(): PlatformLayout {
|
||||
if (!this.walletLayout) {
|
||||
throw "currentPlatformLayout access before store was loaded";
|
||||
}
|
||||
for (const layout of this.walletLayout.platform_layouts) {
|
||||
if (layout.platform === this.currentPlatform) {
|
||||
if (!("fieldgroups" in layout.layout)) {
|
||||
layout.layout.fieldgroups = {};
|
||||
}
|
||||
if (!("settings" in layout.layout)) {
|
||||
layout.layout.settings = {};
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
const newLayout = {
|
||||
platform: this.currentPlatform,
|
||||
style: null,
|
||||
layout: { fieldgroups: {}, settings: {} },
|
||||
};
|
||||
this.walletLayout.platform_layouts.push(newLayout);
|
||||
return newLayout;
|
||||
},
|
||||
|
||||
currentLayoutFieldContent() {
|
||||
const content = {};
|
||||
const group_defs =
|
||||
this.currentPlatformStyles[this.currentPlatformLayout.style]
|
||||
.fieldgroups;
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
content[fieldgroup.identifier] = [];
|
||||
const layout_group = this.currentPlatformLayout.layout.fieldgroups[
|
||||
fieldgroup.identifier
|
||||
] as any as PlaceholderFieldGroupConfig;
|
||||
for (const entry of layout_group.entries) {
|
||||
const placeholder =
|
||||
entry.type === "placeholder"
|
||||
? this.variables[fieldgroup.content_type][entry.content]
|
||||
: null;
|
||||
|
||||
let label = i18nstringLocalize(entry.label);
|
||||
if (placeholder && !label) {
|
||||
label = i18nstringLocalize(placeholder.label);
|
||||
}
|
||||
|
||||
let value = null;
|
||||
if (entry.type == "custom") {
|
||||
value = i18nstringLocalize(entry.content);
|
||||
} else if (entry.type == "placeholder") {
|
||||
value =
|
||||
placeholder?.sample ||
|
||||
`(unknown placeholder: ${entry.content})`;
|
||||
}
|
||||
content[fieldgroup.identifier].push({
|
||||
entry,
|
||||
label,
|
||||
content: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
const layout_group: PlaceholderFieldGroupConfig =
|
||||
this.currentPlatformLayout.layout.fieldgroups[
|
||||
fieldgroup.identifier
|
||||
];
|
||||
if (
|
||||
fieldgroup.max_entries &&
|
||||
content[fieldgroup.identifier].length > fieldgroup.max_entries
|
||||
) {
|
||||
const overflow = content[fieldgroup.identifier].slice(
|
||||
fieldgroup.max_entries,
|
||||
);
|
||||
content[fieldgroup.identifier] = content[
|
||||
fieldgroup.identifier
|
||||
].slice(0, fieldgroup.max_entries);
|
||||
if (layout_group.overflow) {
|
||||
content[layout_group.overflow].splice(0, 0, ...overflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
currentLayoutSettings() {
|
||||
return {...this.currentPlatformLayout.file_settings, ...this.currentPlatformLayout.layout.settings}
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
load() {
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
this.walletLayout = x;
|
||||
this.loaded = true;
|
||||
});
|
||||
},
|
||||
async uploadFile(file: File) {
|
||||
return await fetch("/api/v1/upload", {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: {
|
||||
"content-disposition": `attachment; filename="${encodeURI(file.name)}"`,
|
||||
"content-type": file.type || "application/octet-stream",
|
||||
"X-CSRFToken": this.csrfToken,
|
||||
},
|
||||
})
|
||||
.then((x) => x.json())
|
||||
.then((x) => x.id);
|
||||
},
|
||||
async saveLayout() {
|
||||
const layoutToSave = structuredClone(toRaw(this.walletLayout))
|
||||
// TODO: error handling, parallelization
|
||||
for (const platformLayout of layoutToSave.platform_layouts) {
|
||||
const platformStyle = this.platforms.filter(
|
||||
(x) => x.identifier == platformLayout.platform,
|
||||
)[0].styles[platformLayout.style];
|
||||
for (const setting of platformStyle.settings) {
|
||||
console.log(setting, platformLayout.layout?.settings[setting.identifier])
|
||||
if (
|
||||
setting.type === "image" &&
|
||||
platformLayout.layout?.settings[setting.identifier] instanceof
|
||||
File
|
||||
) {
|
||||
platformLayout.layout.settings[setting.identifier] = await
|
||||
this.uploadFile(
|
||||
platformLayout.layout.settings[setting.identifier],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-CSRFToken": this.csrfToken,
|
||||
},
|
||||
body: JSON.stringify(layoutToSave),
|
||||
},
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.catch((x) => alert(x))
|
||||
.then((x) => {
|
||||
this.walletLayout = x;
|
||||
});
|
||||
},
|
||||
setCurrentPlatformStyle(style: string | null) {
|
||||
if (style === null) {
|
||||
this.currentPlatformLayout.style = null;
|
||||
this.currentPlatformLayout.layout.fieldgroups = {};
|
||||
} else if (Object.keys(this.currentPlatformStyles).includes(style)) {
|
||||
const oldStyle =
|
||||
this.currentPlatformLayout.style !== null
|
||||
? this.currentPlatformStyles[this.currentPlatformLayout.style]
|
||||
: { fieldgroups: [] };
|
||||
const newStyle = this.currentPlatformStyles[style];
|
||||
|
||||
const oldFieldGroups = Object.fromEntries(
|
||||
oldStyle.fieldgroups.map((x) => [x.identifier, x]),
|
||||
);
|
||||
const newFieldGroups = Object.fromEntries(
|
||||
newStyle.fieldgroups.map((x) => [x.identifier, x]),
|
||||
);
|
||||
const keysToKeep = new Set(
|
||||
Object.keys(this.currentPlatformLayout.layout.fieldgroups).filter(
|
||||
(x) =>
|
||||
oldFieldGroups[x]?.type === "placeholder" &&
|
||||
newFieldGroups[x]?.type === "placeholder" &&
|
||||
oldFieldGroups[x]?.content_type ===
|
||||
newFieldGroups[x]?.content_type,
|
||||
),
|
||||
);
|
||||
const keysToDefault = new Set(Object.keys(newFieldGroups)).difference(
|
||||
keysToKeep,
|
||||
);
|
||||
const keysToRemove = new Set(
|
||||
Object.keys(this.currentPlatformLayout.layout.fieldgroups),
|
||||
)
|
||||
.difference(keysToKeep)
|
||||
.difference(keysToDefault);
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
delete this.currentPlatformLayout.layout.fieldgroups[key];
|
||||
}
|
||||
|
||||
for (const key of keysToDefault) {
|
||||
if (newFieldGroups[key].type == "placeholder") {
|
||||
this.currentPlatformLayout.layout.fieldgroups[key] = {
|
||||
overflow: null,
|
||||
entries: JSON.parse(
|
||||
JSON.stringify(newFieldGroups[key].default_entries),
|
||||
),
|
||||
active:
|
||||
newFieldGroups[key].required ||
|
||||
newFieldGroups[key].default_entries.length > 0,
|
||||
};
|
||||
} else {
|
||||
this.currentPlatformLayout.layout.fieldgroups[key] = {
|
||||
active: newFieldGroups[key].required,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPlatformLayout.style = style;
|
||||
}
|
||||
},
|
||||
setSetting(identifier: string, value: string | File | null) {
|
||||
this.currentPlatformLayout.layout.settings[identifier] = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
from .apple import ApplePlatform, AppleWalletEventTicket
|
||||
from .google import GooglePlatform, GoogleWalletEventTicket
|
||||
from .base import PassStyle
|
||||
|
||||
AVAILABLE_PLATFORMS = [ApplePlatform, GooglePlatform]
|
||||
|
||||
AVAILABLE_STYLES: dict[str, list[type[PassStyle]]] = {
|
||||
"apple": [AppleWalletEventTicket],
|
||||
"google": [GoogleWalletEventTicket],
|
||||
}
|
||||
|
||||
AVAILABLE_STYLES_DICT = {
|
||||
plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items()
|
||||
}
|
||||
|
||||
|
||||
def get_style(platform: str, identifier: str) -> type[PassStyle] | None:
|
||||
return AVAILABLE_STYLES_DICT.get(platform, {}).get(identifier)
|
||||
|
||||
|
||||
__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassStyle"]
|
||||
@@ -1,369 +0,0 @@
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
from .base import (
|
||||
FieldEntryType,
|
||||
FieldGroupDisplay,
|
||||
ImageFieldGroup,
|
||||
PlaceholderFieldGroup,
|
||||
PredefinedFieldGroup,
|
||||
TextFieldGroup,
|
||||
WalletPlatform,
|
||||
PassStyle,
|
||||
PlaceholderFieldEntry,
|
||||
SettingsField,
|
||||
)
|
||||
from django.utils.translation import gettext as _, gettext_lazy, override
|
||||
from i18nfield.strings import LazyI18nString
|
||||
import io
|
||||
import hashlib
|
||||
import zipfile
|
||||
import cryptography
|
||||
import cryptography.x509
|
||||
import cryptography.hazmat.primitives.serialization.pkcs7
|
||||
import json
|
||||
from django.contrib.staticfiles import finders
|
||||
from pretix.base.models import OrderPosition
|
||||
from django.utils.encoding import force_bytes
|
||||
from django import forms
|
||||
|
||||
|
||||
class ApplePlatform(WalletPlatform):
|
||||
identifier = "apple"
|
||||
name = _("Apple")
|
||||
|
||||
|
||||
class FormattedLazyI18nString:
|
||||
def __init__(self, base_str: LazyI18nString, **format_args: str):
|
||||
self.base_str = base_str
|
||||
self.format_args = format_args
|
||||
|
||||
def localize(self, language):
|
||||
return self.base_str.localize(language).format(**self.format_args)
|
||||
|
||||
def lazyi18nstring_from_gettext(text: str, locales: set[str]) -> LazyI18nString:
|
||||
data = {}
|
||||
for locale in locales:
|
||||
with override(locale):
|
||||
data[locale] = _(text)
|
||||
return LazyI18nString(data)
|
||||
|
||||
|
||||
class StringResource:
|
||||
entries: dict[str, LazyI18nString | FormattedLazyI18nString]
|
||||
locales: set[str]
|
||||
|
||||
def __init__(self, locales):
|
||||
self.entries = {}
|
||||
self.locales = set(locales)
|
||||
|
||||
def add_entry(self, key: str, value: LazyI18nString | FormattedLazyI18nString):
|
||||
if key in self.entries:
|
||||
raise ValueError(f"{key} already exists in this StringResource")
|
||||
self.entries[key] = value
|
||||
|
||||
def escape(self, string):
|
||||
return string.translate(
|
||||
str.maketrans({'"': '\\"', "\r": "\\r", "\n": "\\n", "\\": "\\\\"})
|
||||
)
|
||||
|
||||
def generate_resource(self, language):
|
||||
output = ""
|
||||
for key, entry in self.entries.items():
|
||||
output += (
|
||||
f'"{self.escape(key)}" = "{self.escape(entry.localize(language))}";\n'
|
||||
)
|
||||
return output.strip()
|
||||
|
||||
def generate(self):
|
||||
return {language: self.generate_resource(language) for language in self.locales}
|
||||
|
||||
|
||||
class SignedZipFile:
|
||||
"""Generates a zip-file with manifest and signature as apple expects a pkpass file to be"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ca_certificate: str | bytes,
|
||||
certificate: str | bytes,
|
||||
key: str | bytes,
|
||||
password,
|
||||
):
|
||||
self.ca_certificate = cryptography.x509.load_pem_x509_certificate(
|
||||
force_bytes(ca_certificate)
|
||||
)
|
||||
self.certificate = cryptography.x509.load_pem_x509_certificate(
|
||||
force_bytes(certificate)
|
||||
)
|
||||
self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
|
||||
force_bytes(key), force_bytes(password) if password else None
|
||||
)
|
||||
self.password = password
|
||||
|
||||
self.file = io.BytesIO()
|
||||
self.zip_file = zipfile.ZipFile(self.file, "w")
|
||||
self.manifest = {}
|
||||
|
||||
def sign(self, data: bytes):
|
||||
return (
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7SignatureBuilder()
|
||||
.set_data(data)
|
||||
.add_signer(
|
||||
self.certificate,
|
||||
self.key,
|
||||
cryptography.hazmat.primitives.hashes.SHA256(),
|
||||
)
|
||||
.add_certificate(self.ca_certificate)
|
||||
.sign(
|
||||
cryptography.hazmat.primitives.serialization.Encoding.DER,
|
||||
[
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.Binary,
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.DetachedSignature,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def finish(self):
|
||||
manifest = json.dumps(self.manifest).encode()
|
||||
signature = self.sign(manifest)
|
||||
self.add_file("manifest.json", manifest)
|
||||
self.add_file("signature", signature)
|
||||
self.zip_file.close()
|
||||
return self.file.getvalue()
|
||||
|
||||
def add_file(self, filename: str, content: str | bytes):
|
||||
if isinstance(content, str):
|
||||
content = content.encode()
|
||||
|
||||
with self.zip_file.open(filename, "w") as f:
|
||||
f.write(content)
|
||||
self.manifest[filename] = hashlib.sha1(content).hexdigest()
|
||||
|
||||
|
||||
class AppleWalletStyle(PassStyle):
|
||||
@property
|
||||
def settings(self):
|
||||
return [
|
||||
SettingsField(
|
||||
identifier="logo",
|
||||
label=_("Logo"),
|
||||
type="image",
|
||||
required=False,
|
||||
help_text="Will be displayed on the top left corner of the pass"
|
||||
),
|
||||
SettingsField(
|
||||
identifier="icon",
|
||||
label=_("Icon"),
|
||||
type="image",
|
||||
required=False,
|
||||
help_text="Will be displayed as the file icon"
|
||||
),
|
||||
]
|
||||
|
||||
def pass_content(self, fields, strings):
|
||||
raise NotImplementedError()
|
||||
|
||||
def generate_pass_json(self, fields, op, strings):
|
||||
ticket = str(op.item.name)
|
||||
if op.variation:
|
||||
ticket += " - " + str(op.variation)
|
||||
|
||||
description = FormattedLazyI18nString(
|
||||
LazyI18nString.from_gettext("Ticket for {event} ({product})"),
|
||||
event=self.event.name,
|
||||
product=ticket,
|
||||
)
|
||||
strings.add_entry("description", description)
|
||||
|
||||
serialNumber = "%s-%s-%s-%d" % (
|
||||
self.event.organizer.slug,
|
||||
self.event.slug,
|
||||
op.order.code,
|
||||
op.pk,
|
||||
)
|
||||
|
||||
pass_json = {
|
||||
"formatVersion": 1,
|
||||
"description": "description",
|
||||
"organizationName": self.event.organizer.name,
|
||||
"passTypeIdentifier": self.event.settings.wallet_apple_pass_type_id,
|
||||
"teamIdentifier": self.event.settings.wallet_apple_team_id,
|
||||
"serialNumber": serialNumber,
|
||||
**self.pass_content(fields, strings),
|
||||
}
|
||||
return pass_json
|
||||
|
||||
def generate(self, op: OrderPosition):
|
||||
order = op.order
|
||||
filename = "{}-{}.pkpass".format(order.event.slug, order.code)
|
||||
|
||||
fields = self.get_pass_fields(op)
|
||||
|
||||
pkpass = SignedZipFile(
|
||||
self.event.settings.wallet_apple_ca_certificate.read(),
|
||||
self.event.settings.wallet_apple_certificate.read(),
|
||||
self.event.settings.wallet_apple_key.read(),
|
||||
self.event.settings.wallet_apple_key_password,
|
||||
)
|
||||
strings = StringResource(locales=self.event.settings.locales)
|
||||
|
||||
pass_json = self.generate_pass_json(fields, op, strings)
|
||||
print(pass_json)
|
||||
breakpoint()
|
||||
if fields["logo"]:
|
||||
logo = fields["logo"][0]["value"]
|
||||
else:
|
||||
logo = open(finders.find("pretix_passbook/logo.png"), "rb")
|
||||
|
||||
if fields["icon"]:
|
||||
icon = fields["icon"][0]["value"]
|
||||
else:
|
||||
icon = open(finders.find("pretix_passbook/icon.png"), "rb")
|
||||
|
||||
pkpass.add_file("icon.png", icon.read())
|
||||
pkpass.add_file("logo.png", logo.read())
|
||||
|
||||
for lang, content in strings.generate().items():
|
||||
pkpass.add_file(f"{lang}.lproj/pass.strings", content)
|
||||
pkpass.add_file("pass.json", json.dumps(pass_json))
|
||||
result = pkpass.finish()
|
||||
return filename, "application/vnd.apple.pkpass", result
|
||||
|
||||
|
||||
class AppleWalletEventTicket(AppleWalletStyle):
|
||||
identifier = "event_1"
|
||||
name = _("Event Ticket Layout 1")
|
||||
fieldgroups = [
|
||||
TextFieldGroup(
|
||||
identifier="logo_text",
|
||||
name=_("Logo text"),
|
||||
max_entries=1,
|
||||
display=FieldGroupDisplay.PLAIN,
|
||||
default_entries=[],
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="primary",
|
||||
name=_("Primary"),
|
||||
min_entries=1,
|
||||
max_entries=1,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
|
||||
content="item",
|
||||
)
|
||||
], # TODO: support Lazyi18nproxy here by using lazyi18nstring_from_gettext
|
||||
description=_("These fields appear prominently featured on the pass."),
|
||||
required=True,
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="secondary",
|
||||
name=_("Secondary"),
|
||||
max_entries=4,
|
||||
context_args={"event", "order", "order_position"},
|
||||
), # TODO: validation of max field count if combined "Coupons, store cards, and generic passes with a square barcode can have a total of up to four secondary and auxiliary fields, combined."
|
||||
TextFieldGroup(
|
||||
identifier="header",
|
||||
name=_("Header"),
|
||||
max_entries=3,
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="auxiliary",
|
||||
name=_("Auxiliary"),
|
||||
max_entries=4,
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="code",
|
||||
name=_("QR-Code"),
|
||||
max_entries=1,
|
||||
display=FieldGroupDisplay.CODE,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
content="secret",
|
||||
)
|
||||
],
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="back",
|
||||
name=_("Back"),
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
]
|
||||
preview_layout = [
|
||||
[
|
||||
{
|
||||
"children": [
|
||||
{"setting": "logo"},
|
||||
{
|
||||
"fieldgroup": "logo_text",
|
||||
"relSize": 3,
|
||||
"display": ["bold", "large", "centered"],
|
||||
},
|
||||
{
|
||||
"fieldgroup": "header",
|
||||
"relSize": 2,
|
||||
"display": ["large", "tight"],
|
||||
},
|
||||
]
|
||||
},
|
||||
{"fieldgroup": "primary", "display": "large"},
|
||||
{"fieldgroup": "secondary"},
|
||||
{"fieldgroup": "auxiliary"},
|
||||
{"fieldgroup": "code"},
|
||||
],
|
||||
[{"fieldgroup": "back", "direction": "column"}],
|
||||
]
|
||||
|
||||
def convert_fields(self, strings, fields, prefix):
|
||||
converted = []
|
||||
for i, f in enumerate(fields):
|
||||
converted_field = {**f, "key": f"{prefix}-{i}"}
|
||||
if "label" in converted_field and isinstance(
|
||||
converted_field["label"], LazyI18nString
|
||||
):
|
||||
strings.add_entry(f"{prefix}-{i}-label", converted_field["label"])
|
||||
converted_field["label"] = f"{prefix}-{i}-label"
|
||||
|
||||
if isinstance(converted_field["value"], LazyI18nString):
|
||||
strings.add_entry(f"{prefix}-{i}-value", converted_field["value"])
|
||||
converted_field["value"] = f"{prefix}-{i}-value"
|
||||
converted.append(converted_field)
|
||||
return converted
|
||||
|
||||
def pass_content(self, fields, strings):
|
||||
content: dict[str, Any] = {
|
||||
"eventTicket": {
|
||||
"primaryFields": self.convert_fields(
|
||||
strings, fields["primary"], "primary"
|
||||
),
|
||||
"secondaryFields": self.convert_fields(
|
||||
strings, fields["secondary"], "secondary"
|
||||
),
|
||||
"auxiliaryFields": self.convert_fields(
|
||||
strings, fields["auxiliary"], "auxiliary"
|
||||
),
|
||||
"backFields": self.convert_fields(strings, fields["back"], "back"),
|
||||
"headerFields": self.convert_fields(
|
||||
strings, fields["header"], "header"
|
||||
),
|
||||
},
|
||||
}
|
||||
if fields["logo_text"]:
|
||||
content["logoText"] = self.convert_fields(
|
||||
strings, fields["logo_text"], "logo_text"
|
||||
)[0]["value"]
|
||||
|
||||
if fields["code"]:
|
||||
content["barcodes"] = [
|
||||
{
|
||||
"format": "PKBarcodeFormatQR",
|
||||
"message": str(fields["code"][0]["value"]),
|
||||
"messageEncoding": "utf-8",
|
||||
"altText": str(fields["code"][0]["value"]),
|
||||
}
|
||||
]
|
||||
return content
|
||||
@@ -1,453 +0,0 @@
|
||||
import enum
|
||||
from typing import Literal, OrderedDict, TypedDict
|
||||
from i18nfield.strings import LazyI18nString
|
||||
import jsonschema
|
||||
from django.core.exceptions import ValidationError
|
||||
from pretix.base.models import OrderPosition
|
||||
from ..placeholders import WalletPlaceholderContext, get_wallet_placeholders
|
||||
from django import forms
|
||||
from pretix.api.helpers import handle_file_upload
|
||||
from django.core.files import File
|
||||
|
||||
class WalletPlatform:
|
||||
identifier: str
|
||||
name: str
|
||||
|
||||
|
||||
class LayoutContext(TypedDict):
|
||||
placeholders: dict[str, dict]
|
||||
|
||||
|
||||
class FieldGroupType(enum.Enum):
|
||||
PLACEHOLDER = "placeholder"
|
||||
PREDEFINED = "predefined"
|
||||
|
||||
|
||||
class FieldGroupDisplay(enum.Enum):
|
||||
PLAIN = "plain"
|
||||
WITH_LABEL = "with_label"
|
||||
CODE = "code"
|
||||
|
||||
|
||||
class FieldGroup:
|
||||
type: FieldGroupType
|
||||
identifier: str
|
||||
name: str
|
||||
description: str
|
||||
required: bool = False
|
||||
|
||||
def __init__(self, identifier: str, name: str, description=None, required=False):
|
||||
self.identifier = identifier
|
||||
self.name = name
|
||||
self.required = required
|
||||
self.description = description or ""
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: LayoutContext,
|
||||
) -> dict:
|
||||
raise NotImplementedError()
|
||||
|
||||
def asdict(self):
|
||||
return {
|
||||
"type": self.type.value,
|
||||
"identifier": self.identifier,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"required": self.required,
|
||||
}
|
||||
|
||||
|
||||
class FieldContentType(enum.Enum):
|
||||
IMAGE = "image"
|
||||
TEXT = "text"
|
||||
|
||||
|
||||
class FieldEntryType(enum.Enum):
|
||||
CUSTOM = "custom"
|
||||
PLACEHOLDER = "placeholder"
|
||||
|
||||
|
||||
class FieldEntry[T]:
|
||||
type: FieldEntryType
|
||||
label: LazyI18nString | None
|
||||
content: T
|
||||
|
||||
def __init__(
|
||||
self, type: FieldEntryType, content: T, label: LazyI18nString | None = None
|
||||
):
|
||||
self.type = type
|
||||
self.label = label
|
||||
self.content = content
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {
|
||||
"type": self.type.value,
|
||||
"content": self.content,
|
||||
"label": self.label.data if self.label else None,
|
||||
}
|
||||
|
||||
|
||||
class PlaceholderFieldEntry(FieldEntry[str]):
|
||||
type = FieldEntryType.PLACEHOLDER
|
||||
label: LazyI18nString | None
|
||||
content: str
|
||||
|
||||
def __init__(self, content: str, label: LazyI18nString | None = None):
|
||||
self.label = label
|
||||
self.content = content
|
||||
|
||||
|
||||
class CustomFieldEntry(FieldEntry[LazyI18nString]):
|
||||
type: FieldEntryType
|
||||
label: LazyI18nString | None
|
||||
content: LazyI18nString
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {
|
||||
"type": self.type.value,
|
||||
"content": self.content.data,
|
||||
"label": self.label.data if self.label else None,
|
||||
}
|
||||
|
||||
|
||||
class PredefinedFieldGroup(FieldGroup):
|
||||
type = FieldGroupType.PREDEFINED
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: LayoutContext,
|
||||
):
|
||||
return {"type": "object", "properties": {"active": {"type": "boolean"}}}
|
||||
|
||||
|
||||
class PlaceholderFieldGroup(FieldGroup):
|
||||
type = FieldGroupType.PLACEHOLDER
|
||||
content_type: FieldContentType
|
||||
default_entries: list[FieldEntry]
|
||||
display: FieldGroupDisplay
|
||||
min_entries: int | None
|
||||
max_entries: int | None
|
||||
context_args: set[
|
||||
str
|
||||
] # what context arguments are available when rendering this fieldgroup
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
name: str,
|
||||
content_type: FieldContentType,
|
||||
description: str = "",
|
||||
required=False,
|
||||
default_entries=None,
|
||||
min_entries=None,
|
||||
max_entries=None,
|
||||
display=FieldGroupDisplay.WITH_LABEL,
|
||||
context_args: set[str] | None = None,
|
||||
):
|
||||
super().__init__(identifier, name, description, required)
|
||||
self.content_type = content_type
|
||||
self.default_entries = default_entries or []
|
||||
self.min_entries = min_entries
|
||||
self.max_entries = max_entries
|
||||
self.display = display
|
||||
self.context_args = context_args or set()
|
||||
|
||||
if self.required and (self.min_entries is None or self.min_entries < 1):
|
||||
self.min_entries = 1
|
||||
|
||||
def asdict(self):
|
||||
return {
|
||||
**super().asdict(),
|
||||
"content_type": self.content_type.value,
|
||||
"default_entries": [x.asdict() for x in self.default_entries],
|
||||
"display": self.display.value,
|
||||
"min_entries": self.min_entries,
|
||||
"max_entries": self.max_entries,
|
||||
"context_args": list(sorted(self.context_args)),
|
||||
}
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: LayoutContext,
|
||||
):
|
||||
content_type_placeholders = (
|
||||
context["placeholders"].get(self.content_type.value, {}).values()
|
||||
)
|
||||
available_placeholders = [
|
||||
x.identifier
|
||||
for x in content_type_placeholders
|
||||
if WalletPlaceholderContext.is_available(x, self.context_args)
|
||||
]
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": self.entries_schema(placeholders=available_placeholders),
|
||||
"overflow": {
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
f.identifier
|
||||
for f in remaining_fields
|
||||
if isinstance(f, PlaceholderFieldGroup)
|
||||
and f.content_type == self.content_type
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
"required": ["entries"],
|
||||
}
|
||||
|
||||
def entries_schema(self, placeholders: list[str]):
|
||||
baseprops = {}
|
||||
if self.display == FieldGroupDisplay.WITH_LABEL:
|
||||
baseprops["label"] = {"$ref": "#/$defs/I18nString"}
|
||||
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"anyOf": [
|
||||
{
|
||||
"properties": {
|
||||
**baseprops,
|
||||
"type": {"const": "placeholder"},
|
||||
"content": {"enum": placeholders},
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
**baseprops,
|
||||
"type": {"const": "custom"},
|
||||
"content": {"$ref": "#/$defs/I18nString"},
|
||||
}
|
||||
},
|
||||
],
|
||||
"required": ["type", "content"],
|
||||
},
|
||||
}
|
||||
if self.display == FieldGroupDisplay.WITH_LABEL:
|
||||
schema["items"]["required"].append("label")
|
||||
if self.min_entries is not None:
|
||||
schema["minItems"] = self.min_entries
|
||||
# max_entries is not enforced here, as the layout can have more fields than that (null-fields are removed, rest is overspilled)
|
||||
return schema
|
||||
|
||||
|
||||
class TextFieldGroup(PlaceholderFieldGroup):
|
||||
content_type = FieldContentType.TEXT
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(content_type=self.content_type, **kwargs)
|
||||
|
||||
|
||||
class ImageFieldGroup(PlaceholderFieldGroup):
|
||||
content_type = FieldContentType.IMAGE
|
||||
display = FieldGroupDisplay.PLAIN
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(content_type=self.content_type, display=self.display, **kwargs)
|
||||
|
||||
|
||||
class SettingsField:
|
||||
identifier: str
|
||||
label: str
|
||||
type: Literal["image", "text"]
|
||||
help_text: str|None
|
||||
required: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
label: str,
|
||||
type: Literal["image", "text"] = "text",
|
||||
help_text = None,
|
||||
required: bool = False,
|
||||
):
|
||||
self.identifier = identifier
|
||||
self.label = label
|
||||
self.type = type
|
||||
self.help_text = help_text
|
||||
self.required = required
|
||||
|
||||
def asdict(self):
|
||||
return {
|
||||
"identifier": self.identifier,
|
||||
"label": self.label,
|
||||
"type": self.type,
|
||||
"help_text": self.help_text,
|
||||
"required": self.required,
|
||||
}
|
||||
|
||||
|
||||
class PassStyle:
|
||||
identifier: str # unique within platform
|
||||
name: str
|
||||
# order here limits in what order users can configure field "overspilling" (if too many fields are defined, where should the rest go)
|
||||
# -> can only go down in the list
|
||||
# we evaluate the fields in this order, so they overspill in this order as well
|
||||
fieldgroups: list[FieldGroup]
|
||||
|
||||
@property
|
||||
def settings(self) -> list[SettingsField]:
|
||||
return []
|
||||
|
||||
@property
|
||||
def preview_layout(self) -> list | None:
|
||||
return None
|
||||
|
||||
def asdict(self):
|
||||
return {
|
||||
"identifier": self.identifier,
|
||||
"name": self.name,
|
||||
"fieldgroups": [x.asdict() for x in self.fieldgroups],
|
||||
"preview_layout": self.preview_layout,
|
||||
"settings": [x.asdict() for x in self.settings],
|
||||
}
|
||||
|
||||
def layout_schema(self):
|
||||
context = LayoutContext(placeholders=self.placeholders)
|
||||
print(f"schema {self.settings=}")
|
||||
schema = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
# TODO: $id
|
||||
"title": self.name,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fieldgroups": {
|
||||
"description": "Layout Field Groups",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
group.identifier: group.layout_schema(
|
||||
context=context, remaining_fields=self.fieldgroups[i:]
|
||||
)
|
||||
for (i, group) in enumerate(self.fieldgroups)
|
||||
},
|
||||
"required": [
|
||||
group.identifier for group in self.fieldgroups if group.required
|
||||
],
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
setting.identifier: {"type": ["string", "null"]}
|
||||
for setting in self.settings
|
||||
},
|
||||
"required": [
|
||||
setting.identifier for setting in self.settings if setting.required
|
||||
],
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"I18nString": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "object", "additionalProperties": {"type": "string"}},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
if any(group.required for group in self.fieldgroups):
|
||||
schema.setdefault("required", [])
|
||||
schema["required"].append("fieldgroups")
|
||||
# if any(setting.required for setting in self.settings):
|
||||
# schema.setdefault("required", [])
|
||||
# schema["required"].append("settings")
|
||||
|
||||
return schema
|
||||
|
||||
def render_placeholder(self, context, content_type, content):
|
||||
placeholder = self.placeholders.get(content_type, {}).get(content)
|
||||
if placeholder:
|
||||
placeholder_value = context.render_placeholder(placeholder)
|
||||
if placeholder_value:
|
||||
return placeholder.label, placeholder_value
|
||||
|
||||
return None, None
|
||||
|
||||
def __init__(self, event, layout = None, file_settings: dict[str, File] | None = None):
|
||||
self.event = event
|
||||
self.layout = layout
|
||||
self.placeholders = get_wallet_placeholders(self.event)
|
||||
self.file_settings = file_settings
|
||||
|
||||
def validate(self):
|
||||
schema = self.layout_schema()
|
||||
try:
|
||||
jsonschema.validate(self.layout, schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
raise ValidationError("Invalid layout: {}".format(str(e)))
|
||||
|
||||
def extract_file_settings(self, request, file_settings):
|
||||
res = {}
|
||||
for setting in self.settings:
|
||||
if setting.type == "image":
|
||||
if file_settings.get(setting.identifier) == "file:keep":
|
||||
res[setting.identifier] = "keep"
|
||||
elif data := file_settings.get(setting.identifier):
|
||||
res[setting.identifier] = handle_file_upload(data, request.user, request.auth, {"image/png", "image/jpeg"})
|
||||
elif setting.identifier in file_settings:
|
||||
res[setting.identifier] = None
|
||||
|
||||
return res
|
||||
|
||||
def get_pass_fields(self, op: OrderPosition):
|
||||
context = WalletPlaceholderContext(
|
||||
event=self.event, order=op.order, order_position=op
|
||||
)
|
||||
|
||||
fields = {}
|
||||
for group in self.fieldgroups:
|
||||
if isinstance(group, PredefinedFieldGroup):
|
||||
pass
|
||||
|
||||
elif isinstance(group, PlaceholderFieldGroup):
|
||||
group_fields = fields.get(group.identifier, [])
|
||||
if group.identifier in self.layout["fieldgroups"]:
|
||||
for field in self.layout["fieldgroups"][group.identifier][
|
||||
"entries"
|
||||
]:
|
||||
field_entry = {}
|
||||
if group.display == FieldGroupDisplay.WITH_LABEL:
|
||||
field_entry["label"] = LazyI18nString(field["label"])
|
||||
if field["type"] == FieldEntryType.PLACEHOLDER.value:
|
||||
label, field_entry["value"] = self.render_placeholder(
|
||||
context, group.content_type.value, field["content"]
|
||||
)
|
||||
if (
|
||||
group.display == FieldGroupDisplay.WITH_LABEL
|
||||
and not str(field_entry["label"])
|
||||
and label
|
||||
):
|
||||
field_entry["label"] = LazyI18nString(label)
|
||||
|
||||
elif field["type"] == FieldEntryType.CUSTOM.value:
|
||||
field_entry["value"] = LazyI18nString(field["content"])
|
||||
if "value" in field_entry and field_entry["value"]:
|
||||
group_fields.append(field_entry)
|
||||
if group.min_entries and len(group_fields) < group.min_entries:
|
||||
raise ValueError(
|
||||
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(group_fields)} were provided"
|
||||
)
|
||||
fields[group.identifier] = group_fields[: group.max_entries]
|
||||
if overflow_group := self.layout["fieldgroups"][group.identifier][
|
||||
"overflow"
|
||||
]:
|
||||
fields.setdefault(overflow_group, [])
|
||||
fields[overflow_group] += group_fields[group.max_entries :]
|
||||
|
||||
else:
|
||||
raise ValueError("Unknown field group")
|
||||
return fields
|
||||
|
||||
def group_is_active(self, identifier: str):
|
||||
return self.layout["fieldgroups"].get(identifier, {}).get("active", False)
|
||||
|
||||
def generate(self, op: OrderPosition):
|
||||
raise NotImplementedError()
|
||||
@@ -1,361 +0,0 @@
|
||||
from pretix.base.models import Event, OrderPosition
|
||||
|
||||
from .base import (
|
||||
FieldGroupDisplay,
|
||||
ImageFieldGroup,
|
||||
PassStyle,
|
||||
PlaceholderFieldEntry,
|
||||
PredefinedFieldGroup,
|
||||
TextFieldGroup,
|
||||
WalletPlatform,
|
||||
)
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from walletobjects import ButtonJWT, EventTicketClass, EventTicketObject
|
||||
from walletobjects.comms import Comms
|
||||
from walletobjects.constants import (
|
||||
Barcode,
|
||||
ClassType,
|
||||
ConfirmationCode,
|
||||
DoorsOpen,
|
||||
MultipleDevicesAndHoldersAllowedStatus,
|
||||
ObjectState,
|
||||
ObjectType,
|
||||
ReviewStatus,
|
||||
Seat,
|
||||
)
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
import uuid
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from django.utils import translation
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def _get_instance_uuid():
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.wallet_google_instance_uuid:
|
||||
gs.settings.wallet_google_instance_uuid = str(uuid.uuid4())
|
||||
|
||||
return gs.settings.wallet_google_instance_uuid
|
||||
|
||||
|
||||
def get_class_id(event: Event):
|
||||
# TODO: add layout id somewhere
|
||||
instance_uuid = _get_instance_uuid()
|
||||
issuer_id = event.settings.get("wallet_google_issuer_id")
|
||||
return "%s.pretix-%s-%s-%s" % (
|
||||
issuer_id,
|
||||
instance_uuid,
|
||||
event.organizer.slug,
|
||||
event.slug,
|
||||
)
|
||||
|
||||
|
||||
def get_object_id(op: OrderPosition):
|
||||
instance_uuid = _get_instance_uuid()
|
||||
issuer_id = op.order.event.settings.get("wallet_google_issuer_id")
|
||||
|
||||
return "%s.pretix-%s-%s-%s-%s-%s-1" % (
|
||||
issuer_id,
|
||||
instance_uuid,
|
||||
op.order.event.organizer.slug,
|
||||
op.order.event.slug,
|
||||
op.order.code,
|
||||
op.positionid,
|
||||
)
|
||||
|
||||
|
||||
def get_translated_dict(string, locales):
|
||||
translated = {}
|
||||
|
||||
for locale in locales:
|
||||
translation.activate(locale)
|
||||
translated[locale] = _(string)
|
||||
translation.deactivate()
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
def get_translated_string(string, locale):
|
||||
translation.activate(locale)
|
||||
translated = _(string)
|
||||
translation.deactivate()
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
class GooglePlatform(WalletPlatform):
|
||||
identifier = "google"
|
||||
name = _("Google")
|
||||
|
||||
|
||||
class GoogleWalletStyle(PassStyle):
|
||||
platform = GooglePlatform
|
||||
|
||||
def _generate_class(self):
|
||||
output_class = EventTicketClass(
|
||||
self.event.organizer.name,
|
||||
get_class_id(self.event),
|
||||
MultipleDevicesAndHoldersAllowedStatus.multipleHolders, # TODO: Make configurable
|
||||
self.event.name,
|
||||
ReviewStatus.underReview,
|
||||
self.event.settings.locale,
|
||||
)
|
||||
|
||||
output_class.homepage_uri(
|
||||
eventreverse_absolute(self.event, "presale:event.index"),
|
||||
get_translated_string("Website", self.event.settings.locale),
|
||||
get_translated_dict("Website", self.event.settings.locales),
|
||||
)
|
||||
|
||||
# TODO: callback url
|
||||
# output_class.callback_url(eventreverse_absolute(event.organizer,"plugins:wallet:google_webhook",))
|
||||
|
||||
# TODO: move to pass settings or set defaults
|
||||
# if (event.settings.get('ticketoutput_googlepaypasses_latitude')
|
||||
# and event.settings.get('ticketoutput_googlepaypasses_longitude')):
|
||||
# output_class.locations(
|
||||
# event.settings.get('ticketoutput_googlepaypasses_latitude'),
|
||||
# event.settings.get('ticketoutput_googlepaypasses_longitude')
|
||||
# )
|
||||
# elif event.geo_lat and event.geo_lon:
|
||||
# output_class.locations(
|
||||
# event.geo_lat,
|
||||
# event.geo_lon
|
||||
# )
|
||||
|
||||
# output_class.country_code(event.settings.locale)
|
||||
|
||||
# if event.settings.get('ticketoutput_googlepaypasses_hero'):
|
||||
# output_class.hero_image(
|
||||
# urljoin(django_settings.SITE_URL, event.settings.get('ticketoutput_googlepaypasses_hero').url),
|
||||
# str(event.name),
|
||||
# event.name,
|
||||
# )
|
||||
|
||||
# output_class.hex_background_color(event.settings.get('primary_color'))
|
||||
# output_class.event_id('pretix-%s-%s-%s' % (gs.settings.get('update_check_id'), event.organizer.id, event.id))
|
||||
|
||||
# if event.settings.get('ticketoutput_googlepaypasses_logo'):
|
||||
# output_class.logo(
|
||||
# urljoin(django_settings.SITE_URL, event.settings.get('ticketoutput_googlepaypasses_logo').url),
|
||||
# str(event.name),
|
||||
# event.name,
|
||||
# )
|
||||
|
||||
# if event.location:
|
||||
# name = {}
|
||||
# address = {}
|
||||
|
||||
# for key, value in event.location.data.items():
|
||||
# lines = value.splitlines()
|
||||
# name[key] = lines[0]
|
||||
# # We must provide at least one address line each for the name and address - no way around it.
|
||||
# if len(lines) > 1:
|
||||
# address[key] = '\n'.join(value.splitlines()[1:])
|
||||
# else:
|
||||
# address[key] = lines[0]
|
||||
|
||||
# output_class.venue(name, address)
|
||||
|
||||
# if event.date_from and event.date_to and event.date_admission:
|
||||
# output_class.date_time(
|
||||
# DoorsOpen.doorsOpen,
|
||||
# event.date_admission.isoformat(),
|
||||
# event.date_from.isoformat(),
|
||||
# event.date_to.isoformat(),
|
||||
# )
|
||||
|
||||
# output_class.confirmation_code_label(ConfirmationCode.orderNumber)
|
||||
|
||||
# if event.seating_plan_id is not None:
|
||||
# output_class.seat_label(Seat.seat)
|
||||
|
||||
# return self._comms().put_item(ClassType.eventTicketClass, class_name, output_class)
|
||||
return output_class
|
||||
|
||||
def _generate_object(self, op: OrderPosition, class_id: str):
|
||||
output_object = EventTicketObject(
|
||||
get_object_id(op), class_id, ObjectState.active, self.event.settings.locale
|
||||
)
|
||||
return output_object
|
||||
|
||||
def generate(self, op):
|
||||
self.op = op
|
||||
comms = Comms(self.event.settings.get("wallet_google_credentials").read())
|
||||
|
||||
class_object = self._generate_class()
|
||||
ticket_object = self._generate_object(op, class_id=class_object["id"])
|
||||
|
||||
# TODO: privacy screen
|
||||
class_object = comms.put_item(
|
||||
ClassType.eventTicketClass, class_object["id"], class_object
|
||||
)
|
||||
ticket_object = comms.put_item(
|
||||
ObjectType.eventTicketObject, ticket_object["id"], ticket_object
|
||||
)
|
||||
|
||||
generated_jwt = comms.sign_jwt(
|
||||
ButtonJWT(
|
||||
origins=[settings.SITE_URL],
|
||||
issuer=comms.client_email,
|
||||
event_ticket_objects=[ticket_object],
|
||||
skinny=True,
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
"googlepaypass",
|
||||
"text/uri-list",
|
||||
"https://pay.google.com/gp/v/save/%s" % generated_jwt,
|
||||
)
|
||||
|
||||
|
||||
class GoogleWalletEventTicket(GoogleWalletStyle):
|
||||
identifier = "event"
|
||||
name = "Event Ticket"
|
||||
fieldgroups = [
|
||||
ImageFieldGroup(
|
||||
identifier="logo",
|
||||
name=_("Logo"),
|
||||
min_entries=0,
|
||||
max_entries=1,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
content="poweredby",
|
||||
)
|
||||
],
|
||||
),
|
||||
PredefinedFieldGroup(identifier="venue", name=_("Venue")),
|
||||
PredefinedFieldGroup(identifier="date", name=_("Date")),
|
||||
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
|
||||
TextFieldGroup(
|
||||
identifier="code",
|
||||
name=_("QR-Code"),
|
||||
max_entries=1,
|
||||
display=FieldGroupDisplay.CODE,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
content="secret",
|
||||
)
|
||||
],
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
def preview_layout(self):
|
||||
return [
|
||||
[
|
||||
{
|
||||
"children": [
|
||||
{"fieldgroup": "logo", "relSize": 1},
|
||||
{
|
||||
"value": str(self.event.organizer.name),
|
||||
"relSize": 3,
|
||||
"display": ["large", "centered"],
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"fieldgroup": "venue",
|
||||
"sample": [
|
||||
{"content": self.venue()[0], "label": ""},
|
||||
],
|
||||
},
|
||||
{"value": str(self.event.name), "display": "large"},
|
||||
],
|
||||
"direction": "column",
|
||||
"display": ["tight"],
|
||||
},
|
||||
{
|
||||
"fieldgroup": "date",
|
||||
"sample": [
|
||||
{"content": "01/01/1970", "label": "Date"},
|
||||
{"content": "12:34", "label": "Time"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"fieldgroup": "seating",
|
||||
"sample": [
|
||||
{"content": "5", "label": "Row"},
|
||||
{"content": "2", "label": "Seat"},
|
||||
],
|
||||
},
|
||||
{"fieldgroup": "code"},
|
||||
],
|
||||
[
|
||||
{
|
||||
"fieldgroup": "venue",
|
||||
"sample": [
|
||||
{"content": self.venue()[1], "label": self.venue()[0]},
|
||||
],
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
def venue(self):
|
||||
if self.event.location:
|
||||
name = {}
|
||||
address = {}
|
||||
|
||||
for key, value in self.event.location.data.items():
|
||||
lines = value.splitlines()
|
||||
name[key] = lines[0]
|
||||
# We must provide at least one address line each for the name and address - no way around it.
|
||||
if len(lines) > 1:
|
||||
address[key] = "\n".join(value.splitlines()[1:])
|
||||
else:
|
||||
address[key] = lines[0]
|
||||
|
||||
return name, address
|
||||
return "", ""
|
||||
|
||||
def _generate_class(self):
|
||||
output_class = super()._generate_class()
|
||||
if self.group_is_active("venue") and all(self.venue()):
|
||||
output_class.venue(*self.venue())
|
||||
return output_class
|
||||
|
||||
def _generate_object(self, op: OrderPosition, class_id: str):
|
||||
output_object = super()._generate_object(op, class_id)
|
||||
fields = self.get_pass_fields(op)
|
||||
if fields["code"]:
|
||||
output_object.barcode(
|
||||
Barcode.qrCode, fields["code"][0]["value"], fields["code"][0]["value"]
|
||||
)
|
||||
|
||||
# output_object.reservation_info("%s-%s" % (op.order.event.slug, op.order.code))
|
||||
# output_object.ticket_holder_name(op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''))
|
||||
output_object.ticket_holder_name("Some name")
|
||||
output_object.ticket_number(fields["code"][0]["value"])
|
||||
# output_object.ticket_type(
|
||||
# get_translated_dict(
|
||||
# str(op.item) + (" – " + str(op.variation.value) if op.variation else ""),
|
||||
# op.order.event.settings.get('locales')
|
||||
# )
|
||||
# )
|
||||
|
||||
# places = django_settings.CURRENCY_PLACES.get(op.order.event.currency, 2)
|
||||
# output_object.face_value(int(op.price * 1000 ** places), op.order.event.currency)
|
||||
|
||||
# if op.order.event.seating_plan_id is not None:
|
||||
# if op.seat:
|
||||
# output_object.seat(
|
||||
# get_translated_dict(
|
||||
# _(str(op.seat)),
|
||||
# op.order.event.settings.get('locales')
|
||||
# )
|
||||
# )
|
||||
# else:
|
||||
# output_object.seat(
|
||||
# get_translated_dict(
|
||||
# _('General admission'),
|
||||
# op.order.event.settings.get('locales')
|
||||
# )
|
||||
# )
|
||||
|
||||
# return self._comms().put_item(ObjectType.eventTicketObject, object_name, output_object)
|
||||
return output_object
|
||||
@@ -1,35 +0,0 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load bootstrap3 %}
|
||||
{% load vite %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
|
||||
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "New layout" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form layout="control" %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{% trans "Ticket design" %}
|
||||
</label>
|
||||
<div class="col-md-9 form-control-static">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can modify the design after you saved this page.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,19 +0,0 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Wallet layout" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Wallet layout" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans with name=object.name %}Are you sure you want to delete <strong>"{{ name }}"</strong>?{% endblocktrans %}</p>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "plugins:wallet:index" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,22 +0,0 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load bootstrap3 %}
|
||||
{% load vite %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
|
||||
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "Edit layout" %}</h1>
|
||||
{{ platforms|json_script:"platforms" }}
|
||||
{{ variables|json_script:"variables" }}
|
||||
{{ locales|json_script:"locales" }}
|
||||
<div id="editor" data-layout-id="{{ object.pk }}"></div>
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %}
|
||||
|
||||
{% csrf_token %}
|
||||
{% endblock %}
|
||||
@@ -1,74 +0,0 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load wallet %}
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Wallet layouts" %}</h1>
|
||||
{% if object_list|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You haven't created any layouts yet.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
{% if "event.settings.general:write" in request.eventpermset %}
|
||||
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new layout" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Default" %}</th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in object_list %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if "can_change_event_settings" in request.eventpermset %}
|
||||
<strong><a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
|
||||
{{ l.name }}
|
||||
</a></strong>
|
||||
{% else %}
|
||||
<strong>{{ l.name }}</strong>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if l.default %}
|
||||
<span class="text-success">
|
||||
<span class="fa fa-check"></span>
|
||||
{% trans "Default" %}
|
||||
</span>
|
||||
{% elif "can_change_event_settings" in request.eventpermset %}
|
||||
<form class="form-inline" method="post"
|
||||
action="{% url "plugins:wallet:default" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-sm">
|
||||
{% trans "Make default" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
{% if "can_change_event_settings" in request.eventpermset %}
|
||||
<a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ l.id }}"
|
||||
class="btn btn-default btn-sm" title="{% trans "Clone" %}" data-toggle="tooltip"><i class="fa fa-copy"></i></a>
|
||||
<a href="{% url "plugins:wallet:delete" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,8 +0,0 @@
|
||||
{% load i18n %}
|
||||
<p>
|
||||
<a class="btn btn-primary btn-lg" target="_blank"
|
||||
href="{% url "plugins:wallet:index" organizer=request.organizer.slug event=request.event.slug %}">
|
||||
<span class="fa fa-paint-brush"></span>
|
||||
{% trans "Edit layouts" %}
|
||||
</a>
|
||||
</p>
|
||||
@@ -1,10 +0,0 @@
|
||||
from django import template
|
||||
|
||||
from ..models import WalletLayout
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def platform_layouts(platform, event):
|
||||
return WalletLayout.objects.filter(event=event, platform=platform.identifier)
|
||||
@@ -1,192 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix 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/>.
|
||||
#
|
||||
import logging
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from pretix.base.ticketoutput import BaseTicketOutput
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
from django.template.loader import render_to_string
|
||||
from django.shortcuts import get_object_or_404
|
||||
from .styles.base import WalletPlatform
|
||||
from .styles.apple import ApplePlatform
|
||||
from .styles.google import GooglePlatform
|
||||
from collections import OrderedDict
|
||||
from django import forms
|
||||
from .forms import CertificateFileField, validate_rsa_privkey
|
||||
from pretix.control.forms import ClearableBasenameFileInput
|
||||
|
||||
logger = logging.getLogger("pretix.plugins.wallet")
|
||||
|
||||
|
||||
class WalletSettingsHolder(BaseTicketOutput):
|
||||
identifier = "wallet"
|
||||
verbose_name = _("Wallet Output")
|
||||
|
||||
is_meta = True
|
||||
is_enabled = False
|
||||
preview_allowed = (
|
||||
False # TODO: implement own preview view or hide button for meta-outputs
|
||||
)
|
||||
|
||||
def settings_content_render(self, request) -> str:
|
||||
return render_to_string(
|
||||
"pretixplugins/wallet/settings_content.html", {"request": request}
|
||||
)
|
||||
|
||||
|
||||
class WalletOutput(BaseTicketOutput):
|
||||
settings_form_fields = []
|
||||
platform: WalletPlatform
|
||||
|
||||
def __init__(self, event: Event):
|
||||
super().__init__(event)
|
||||
self.settings = SettingsSandbox(
|
||||
"ticketoutput", WalletSettingsHolder.identifier, event
|
||||
)
|
||||
|
||||
def generate(self, op):
|
||||
if hasattr(op.item, "walletlayout"):
|
||||
wallet_layout = op.item.walletlayout
|
||||
else:
|
||||
wallet_layout = op.event.wallet_layouts.get(default=True)
|
||||
platform_layout = get_object_or_404(
|
||||
wallet_layout.platform_layouts, platform=self.platform.identifier
|
||||
)
|
||||
return self.platform.generate(platform_layout.pass_layout, op)
|
||||
|
||||
|
||||
class GoogleWalletTicketOutput(WalletOutput):
|
||||
identifier = "wallet_google"
|
||||
verbose_name = _("Google")
|
||||
download_button_text = "Add to Google Wallet"
|
||||
platform = GooglePlatform
|
||||
|
||||
def get_global_settings(sender, **kwargs):
|
||||
return OrderedDict(
|
||||
[
|
||||
(
|
||||
"wallet_google_issuer_id",
|
||||
forms.CharField(
|
||||
label=_("Google Wallet Issuer/Merchant ID"),
|
||||
help_text=_(
|
||||
# TODO: update text
|
||||
"After getting accepted by Google into the Google Pay API for Passes program, "
|
||||
"your Issuer ID can be found in the Merchant center at "
|
||||
"https://wallet.google.com/merchant/walletobjects/"
|
||||
),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_google_credentials",
|
||||
forms.FileField(
|
||||
label=_("Google Wallet Service Account Credentials"),
|
||||
help_text=_(
|
||||
"Please paste the contents of the JSON credentials file "
|
||||
"of the service account you tied to your Google Pay API "
|
||||
"for Passes Issuer ID"
|
||||
),
|
||||
required=False,
|
||||
# TODO: add validator
|
||||
# validators=[validate_json_credentials]
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_google_instance_uuid",
|
||||
forms.CharField(
|
||||
label=_("Google Wallet Pass Instance UUID"),
|
||||
help_text=_(
|
||||
"Instance-specific part to be added to the wallet ids"
|
||||
),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class AppleWalletTicketOutput(WalletOutput):
|
||||
identifier = "wallet_apple"
|
||||
verbose_name = _("Apple")
|
||||
download_button_text = "Add to Apple Wallet"
|
||||
platform = ApplePlatform
|
||||
|
||||
def get_global_settings(sender, **kwargs):
|
||||
return OrderedDict(
|
||||
[
|
||||
(
|
||||
"wallet_apple_team_id",
|
||||
forms.CharField(
|
||||
label=_("Apple Wallet Pass team ID"),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_apple_pass_type_id",
|
||||
forms.CharField(
|
||||
label=_("Apple Wallet Pass type"),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_apple_certificate",
|
||||
CertificateFileField(
|
||||
label=_("Apple Wallet Pass certificate file"),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_apple_ca_certificate",
|
||||
CertificateFileField(
|
||||
label=_("Apple Wallet Pass CA Certificate"),
|
||||
help_text=_(
|
||||
"You can download the current CA certificate from apple at "
|
||||
"https://www.apple.com/certificateauthority/AppleWWDRCAG4.cer"
|
||||
),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_apple_key",
|
||||
forms.FileField(
|
||||
label=_("Apple Wallet Pass secret key"),
|
||||
required=False,
|
||||
validators=[validate_rsa_privkey],
|
||||
widget=ClearableBasenameFileInput,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_apple_key_password",
|
||||
forms.CharField(
|
||||
label=_("Apple Wallet Pass key password"),
|
||||
widget=forms.PasswordInput(render_value=True),
|
||||
required=False,
|
||||
help_text=_(
|
||||
"Optional, only necessary if the key entered above requires a password to use."
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput]
|
||||
@@ -1,50 +0,0 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix 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/>.
|
||||
#
|
||||
from django.urls import re_path
|
||||
from pretix.api.urls import event_router
|
||||
|
||||
from .views import (
|
||||
LayoutEditorView,
|
||||
LayoutCreateView,
|
||||
LayoutListView,
|
||||
LayoutPreviewView,
|
||||
LayoutSetDefault,
|
||||
LayoutDelete
|
||||
)
|
||||
from .api import WalletLayoutViewSet
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/$',
|
||||
LayoutListView.as_view(), name='index'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/add/$',
|
||||
LayoutCreateView.as_view(), name='add'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<layout>[^/]+)/$',
|
||||
LayoutEditorView.as_view(), name='edit'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/preview/$',
|
||||
LayoutPreviewView.as_view(), name='preview'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/default/(?P<layout>[^/]+)/$', # TODO
|
||||
LayoutSetDefault.as_view(), name='default'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/delete/(?P<layout>[^/]+)/$', # TODO
|
||||
LayoutDelete.as_view(), name='delete'),
|
||||
]
|
||||
|
||||
event_router.register('walletlayouts', WalletLayoutViewSet)
|
||||
@@ -1,242 +0,0 @@
|
||||
import copy
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from django import forms
|
||||
from django.core.exceptions import BadRequest
|
||||
from django.db.models.query import QuerySet
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, DetailView, ListView, DeleteView, View
|
||||
from pretix_vrpayment_wero.payment import HttpRequest
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.pdf import get_images, get_variables
|
||||
from pretix.base.services.tickets import get_preview_position
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
from pretix.helpers.models import modelclone
|
||||
from .models import WalletLayout
|
||||
from .styles import (
|
||||
AVAILABLE_STYLES,
|
||||
AVAILABLE_PLATFORMS,
|
||||
AVAILABLE_STYLES_DICT,
|
||||
)
|
||||
from django.contrib import messages
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.utils.functional import cached_property
|
||||
from django.templatetags.static import static
|
||||
from .placeholders import get_wallet_placeholders, WalletPlaceholderContext
|
||||
from pretix.base.middleware import add_to_response_csp
|
||||
|
||||
def get_editor_placeholders(event):
|
||||
with (
|
||||
rolledback_transaction(),
|
||||
language(event.settings.locale, event.settings.region),
|
||||
):
|
||||
p = get_preview_position(event)
|
||||
context = WalletPlaceholderContext(event=event, order=p.order, order_position=p)
|
||||
placeholders = {
|
||||
t: {
|
||||
pid: {"label": str(p.label), "sample": str(context.render_sample(p)), "required_context": list(sorted(p.required_context))}
|
||||
for pid, p in ps.items()
|
||||
}
|
||||
for t, ps in get_wallet_placeholders(event).items()
|
||||
}
|
||||
return placeholders
|
||||
|
||||
|
||||
class WalletLayoutMixin:
|
||||
model = WalletLayout
|
||||
permission = "event.settings.general:write"
|
||||
pk_url_kwarg = "layout"
|
||||
context_object_name = "layouts"
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.wallet_layouts.all()
|
||||
|
||||
|
||||
class LayoutListView(WalletLayoutMixin, EventPermissionRequiredMixin, ListView):
|
||||
template_name = "pretixplugins/wallet/layout_list.html"
|
||||
|
||||
|
||||
class LayoutDetailView(WalletLayoutMixin, EventPermissionRequiredMixin, DetailView):
|
||||
pass
|
||||
|
||||
|
||||
class LayoutEditorView(LayoutDetailView):
|
||||
template_name = "pretixplugins/wallet/edit.html"
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["platforms"] = [
|
||||
{
|
||||
"identifier": platform.identifier,
|
||||
"name": platform.name,
|
||||
"styles": {
|
||||
style.identifier: style(self.request.event).asdict()
|
||||
for style in AVAILABLE_STYLES.get(platform.identifier)
|
||||
},
|
||||
}
|
||||
for platform in AVAILABLE_PLATFORMS
|
||||
]
|
||||
context["variables"] = get_editor_placeholders(self.request.event)
|
||||
context["locales"] = {
|
||||
l: dict(settings.LANGUAGES).get(l, l)
|
||||
for l in self.request.event.settings.get("locales")
|
||||
}
|
||||
|
||||
return context
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
add_to_response_csp(response, {
|
||||
'img-src': ['blob:'],
|
||||
})
|
||||
return response
|
||||
|
||||
class WalletLayoutCreateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = WalletLayout
|
||||
fields = ("name",)
|
||||
|
||||
def __init__(self, *args, event, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.event = event
|
||||
|
||||
def save(self, *args, **kwargs) -> Any:
|
||||
self.instance.event = self.event
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class LayoutCreateView(WalletLayoutMixin, EventPermissionRequiredMixin, CreateView):
|
||||
template_name = "pretixplugins/wallet/create.html"
|
||||
form_class = WalletLayoutCreateForm
|
||||
permission = "event.settings.general:write"
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save()
|
||||
if self.copy_from:
|
||||
for pl in self.copy_from.platform_layouts.all():
|
||||
modelclone(pl, parent=self.object).save()
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
def get_form_kwargs(self) -> dict[str, Any]:
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["event"] = self.request.event
|
||||
|
||||
if self.copy_from:
|
||||
kwargs["instance"] = modelclone(self.copy_from, default=False)
|
||||
kwargs.setdefault("initial", {})
|
||||
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(
|
||||
"plugins:wallet:edit",
|
||||
kwargs={
|
||||
"organizer": self.request.event.organizer.slug,
|
||||
"event": self.request.event.slug,
|
||||
"layout": self.object.pk,
|
||||
},
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def copy_from(self) -> WalletLayout | None:
|
||||
if self.request.GET.get("copy_from"):
|
||||
try:
|
||||
return self.get_queryset().get(pk=self.request.GET.get("copy_from"))
|
||||
except WalletLayout.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class LayoutPreviewView(EventPermissionRequiredMixin, View):
|
||||
permission = "event.settings.general:write"
|
||||
|
||||
def post(self, request, **kwargs):
|
||||
event = request.event
|
||||
platform_id = request.POST.get("platform")
|
||||
style_id = request.POST.get("style")
|
||||
layout = request.POST.get("layout")
|
||||
|
||||
platform = None
|
||||
for p in AVAILABLE_PLATFORMS:
|
||||
if p.identifier == platform_id:
|
||||
platform = p
|
||||
if not platform:
|
||||
raise BadRequest("Unknown platform")
|
||||
if style_id not in AVAILABLE_STYLES_DICT[platform_id]:
|
||||
raise BadRequest("Unknown style")
|
||||
style = AVAILABLE_STYLES_DICT[platform_id][style_id]
|
||||
|
||||
layout = json.loads(layout)
|
||||
with (
|
||||
rolledback_transaction(),
|
||||
language(request.event.settings.locale, request.event.settings.region),
|
||||
):
|
||||
p = get_preview_position(request.event)
|
||||
l = style(event=event, layout=layout) # TODO
|
||||
file_settings = l.extract_file_settings(request)
|
||||
layout = style(event, layout, file_settings)
|
||||
layout.validate()
|
||||
|
||||
fname, mimet, data = layout.generate(p)
|
||||
resp = HttpResponse(data, content_type=mimet)
|
||||
ftype = fname.split(".")[-1]
|
||||
if not mimet.startswith("text/"):
|
||||
resp["Content-Disposition"] = (
|
||||
'attachment; filename="ticket-preview.{}"'.format(ftype)
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
class LayoutSetDefault(LayoutDetailView):
|
||||
@transaction.atomic
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
request.event.wallet_layouts.exclude(pk=obj.pk).update(default=False)
|
||||
obj.default = True
|
||||
obj.save(update_fields=["default"])
|
||||
messages.success(self.request, _("Your changes have been saved."))
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(
|
||||
"plugins:wallet:index",
|
||||
kwargs={
|
||||
"organizer": self.request.event.organizer.slug,
|
||||
"event": self.request.event.slug,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class LayoutDelete(WalletLayoutMixin, DeleteView):
|
||||
template_name = "pretixplugins/wallet/delete.html"
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(
|
||||
"plugins:wallet:index",
|
||||
kwargs={
|
||||
"organizer": self.request.event.organizer.slug,
|
||||
"event": self.request.event.slug,
|
||||
},
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
self.object = self.get_object()
|
||||
self.object.log_action(
|
||||
action="pretix.plugins.wallet.layout.deleted", user=self.request.user
|
||||
)
|
||||
self.object.delete()
|
||||
|
||||
if not self.request.event.wallet_layouts.filter(default=True).exists():
|
||||
f = self.request.event.wallet_layouts.first()
|
||||
if f:
|
||||
f.default = True
|
||||
f.save(update_fields=["default"])
|
||||
|
||||
messages.success(self.request, _("The selected layout been deleted."))
|
||||
return redirect(self.get_success_url())
|
||||
@@ -100,7 +100,7 @@ from pretix.presale.views.cart import (
|
||||
_items_from_post_data, cart_session, create_empty_cart_id,
|
||||
get_or_create_cart_id,
|
||||
)
|
||||
from pretix.presale.views.questions import QuestionsViewMixin
|
||||
from pretix.presale.views.questions import CartQuestionsViewMixin
|
||||
|
||||
|
||||
class BaseCheckoutFlowStep:
|
||||
@@ -772,7 +772,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None))
|
||||
|
||||
|
||||
class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
priority = 50
|
||||
identifier = "questions"
|
||||
template_name = "pretixpresale/event/checkout_questions.html"
|
||||
@@ -1125,6 +1125,7 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['order_questions_form'] = self.order_questions_form
|
||||
ctx['formgroups'] = self.formdict.items()
|
||||
ctx['contact_form'] = self.contact_form
|
||||
ctx['invoice_form'] = self.invoice_form
|
||||
@@ -1563,6 +1564,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
ctx['addr'] = self.invoice_address
|
||||
ctx['confirm_messages'] = self.confirm_messages
|
||||
ctx['cart_session'] = self.cart_session
|
||||
ctx['checkout_session'] = self.checkout_session
|
||||
ctx['invoice_address_asked'] = self.address_asked
|
||||
ctx['customer'] = self.cart_customer
|
||||
|
||||
@@ -1660,6 +1662,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
customer=self.cart_session.get('customer'),
|
||||
override_now_dt=time_machine_now(default=None),
|
||||
api_meta=api_meta,
|
||||
cart_id=get_or_create_cart_id(request),
|
||||
)
|
||||
|
||||
def get_success_message(self, value):
|
||||
|
||||
@@ -41,8 +41,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
|
||||
from pretix.base.forms.questions import (
|
||||
BaseInvoiceAddressForm, BaseQuestionsForm, WrappedPhoneNumberPrefixWidget,
|
||||
guess_phone_prefix_from_request,
|
||||
BaseInvoiceAddressForm, TicketLevelQuestionsForm,
|
||||
WrappedPhoneNumberPrefixWidget, guess_phone_prefix_from_request,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.base.validators import EmailBanlistValidator
|
||||
@@ -139,11 +139,14 @@ class InvoiceNameForm(InvoiceAddressForm):
|
||||
del self.fields[f]
|
||||
|
||||
|
||||
class QuestionsForm(BaseQuestionsForm):
|
||||
class CustomerAwareQuestionsForm(TicketLevelQuestionsForm):
|
||||
"""
|
||||
This form class is responsible for asking order-related questions. This includes
|
||||
The base class is responsible for asking order-related questions. This includes
|
||||
the attendee name for admission tickets, if the corresponding setting is enabled,
|
||||
as well as additional questions defined by the organizer.
|
||||
|
||||
This class adds support for pre-filling data like name and address from a
|
||||
customer profile, in case the user is logged-in with a customer account.
|
||||
"""
|
||||
required_css_class = 'required'
|
||||
address_validation = True
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user