Compare commits

..
Author SHA1 Message Date
Lukas Bockstaller 6b12e68859 add test cases for required meta_properties and going live 2026-10-26 11:00:34 +01:00
Lukas Bockstaller 3105b7e8fb raise correct validation error 2026-10-23 13:51:16 +02:00
Lukas Bockstaller 26da8b3aab API: run clean_live() after updates to the event 2026-10-23 13:22:20 +02:00
Raphael Michel e188796a81 Bump Pillow to 13.0.* (#6694) 2026-10-22 15:44:57 +02:00
Lukas Bockstaller 1ca41269cd test: unset EMAIL_HOST_USER and EMAIL_HOST_PASSWORD for smtp tests (#6688)
Harden tests so that they don't fail locally when the config values are set in pretix.cfg
2026-10-22 12:03:57 +02:00
Lukas Bockstaller 9581c08949 put last_seen attribute into the defaults parameter of the get_or_create (#6689) 2026-10-22 12:00:35 +02:00
luelista 5393aff5f0 Attempt to make customer account SSO configuration a bit less confusing (#6586)
* More explanation text, better titles
* Direct links to docs
* Add all information to copy/paste in one place
2026-10-22 10:57:37 +02:00
Raphael Michel 03e488ef20 Add comment on known permission issue 2026-10-21 13:55:13 +02:00
Douwe Somers 619c68e51a Fix interpolate function (#6672) 2026-10-21 10:59:46 +02:00
Richard Schreiber 10c1d5106d API: make bundle_variation optional when creating items with bundles (#6681)
* API: make bundle_variation optional when creating items with bundles

* Add test
2026-10-21 10:32:15 +02:00
Martin Gross 876566759d Stripe: Reenable Continue button for Affirm/Klarna even if CC-element is not present 2026-10-21 10:05:34 +02:00
Christiaan de Die le Clercq c6a0e06e87 Translations: Update Dutch
Currently translated at 96.9% (6247 of 6442 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl/

powered by weblate
2026-10-20 12:09:18 +02:00
Martin Gross 39e4a6c9f7 Translations: Update Luxembourgish
Currently translated at 25.1% (1619 of 6442 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/lb/

powered by weblate
2026-10-20 12:09:18 +02:00
Martin Gross 431bd038fc Translations: Update German
Currently translated at 100.0% (260 of 260 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/de/

powered by weblate
2026-10-20 12:09:18 +02:00
Martin Gross 2374a33424 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2026-10-20 12:09:18 +02:00
Martin Gross 463de6ae12 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2026-10-20 12:09:18 +02:00
Jakob 117b300c86 Translations: Update Luxembourgish
Currently translated at 25.1% (1619 of 6442 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/lb/

powered by weblate
2026-10-20 12:09:18 +02:00
Jakob 36d7f684c3 Translations: Update German
Currently translated at 100.0% (260 of 260 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/de/

powered by weblate
2026-10-20 12:09:18 +02:00
Jakob 9ceab479a4 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2026-10-20 12:09:18 +02:00
Jakob 6229b4a848 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2026-10-20 12:09:18 +02:00
dependabot[bot] 16d0f17587 Bump pycparser from 3.0 to 3.11 (#6669)
Bumps [pycparser](https://github.com/eliben/pycparser) from 3.0 to 3.11.
- [Release notes](https://github.com/eliben/pycparser/releases)
- [Commits](https://github.com/eliben/pycparser/compare/release_v3.00...release_v3.11)

---
updated-dependencies:
- dependency-name: pycparser
  dependency-version: '3.11'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 12:03:58 +02:00
dependabot[bot] a1f012805a Update pypdf requirement from ==6.19.* to ==6.20.* (#6670)
Updates the requirements on [pypdf](https://github.com/py-pdf/pypdf) to permit the latest version.
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.19.0...6.20.0)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.20.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 12:03:47 +02:00
Martin Gross b5ae2ccb25 PayPal2: CCID3 messaging (#6679) 2026-10-20 11:01:37 +02:00
106 changed files with 381 additions and 7756 deletions

No files matched your search

-2
View File
@@ -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
View File
@@ -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": {
+3 -3
View File
@@ -77,14 +77,14 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.15.*",
"phonenumberslite==9.0.*",
"Pillow==12.3.*",
"Pillow==13.0.*",
"pretix-plugin-build",
"protobuf==7.36.*",
"psycopg2-binary",
"pycountry",
"pycparser==3.0",
"pycparser==3.11",
"pycryptodome==3.24.*",
"pypdf==6.19.*",
"pypdf==6.20.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
-1
View File
@@ -31,7 +31,6 @@ ignore =
src/tests/plugins/stripe/*
src/tests/plugins/sendmail/*
src/tests/plugins/ticketoutputpdf/*
src/tests/plugins/wallet/*
.*
CODE_OF_CONDUCT.md
CONTRIBUTING.md
-1
View File
@@ -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 -1
View File
@@ -76,7 +76,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
except DeviceLastSeen.DoesNotExist:
# First request from device, create model, ignore result. Use get_or_create to be safe
# against concurrent create requests
DeviceLastSeen.objects.get_or_create(device=device, last_seen=now())
DeviceLastSeen.objects.get_or_create(device=device, defaults={"last_seen": now()})
else:
if now() - last_seen_obj.last_seen < timedelta(seconds=10):
# We don't need to know the last seen info of a device to more precision than this,
-33
View File
@@ -1,33 +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
from pretix.api.auth.utils import get_session_key_for_api_auth
def handle_file_upload(data, user, auth, allowed_types):
try:
cf = CachedFile.objects.get(
file__isnull=False,
pk=data[len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
# also be harmless because all files with this key are expired one day after deployment of this fix
# and no new files with this key are created
pass
elif cf.session_key != get_session_key_for_api_auth(user, auth):
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
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
+12 -3
View File
@@ -35,10 +35,13 @@
import logging
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.exceptions import (
PermissionDenied, ValidationError as DjangoValidationError,
)
from django.db import transaction
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.html import strip_tags
from django.utils.translation import gettext as _
from django_countries.serializers import CountryFieldMixin
from pytz import common_timezones
@@ -218,8 +221,6 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
if self.instance is None:
raise ValidationError(_('Events cannot be created as \'live\'. Quotas and payment must be added to the '
'event before sales can go live.'))
else:
self.instance.clean_live()
return value
@cached_property
@@ -429,6 +430,14 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
event.set_active_plugins(plugins)
event.save()
live = validated_data.get('live') or event.live
if live:
event.refresh_from_db()
try:
event.clean_live()
except DjangoValidationError as e:
raise ValidationError({'live': [strip_tags(m) for m in e.messages]})
return event
+1 -1
View File
@@ -378,7 +378,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
def validate_bundles(self, value):
if not self.instance:
for b_data in value:
ItemBundle.clean_itemvar(self.context['event'], b_data['bundled_item'], b_data['bundled_variation'])
ItemBundle.clean_itemvar(self.context['event'], b_data.get('bundled_item'), b_data.get('bundled_variation'))
return value
def validate_addons(self, value):
+31 -8
View File
@@ -64,7 +64,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
@@ -76,7 +76,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):
@@ -330,6 +329,35 @@ with scopes_disabled():
)
def _handle_file_upload(data, user, auth):
try:
cf = CachedFile.objects.get(
file__isnull=False,
pk=data[len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
# also be harmless because all files with this key are expired one day after deployment of this fix
# and no new files with this key are created
pass
elif cf.session_key != get_session_key_for_api_auth(user, auth):
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
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):
list_by_event = {cl.event_id: cl for cl in checkinlists}
if not checkinlists:
@@ -774,12 +802,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:
+1 -1
View File
@@ -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)
+41 -85
View File
@@ -21,21 +21,17 @@
#
import logging
import os
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedFile, CachedTicket, Event, InvoiceAddress,
Order, OrderPosition,
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
OrderPosition,
)
from pretix.base.services.tasks import EventTask, ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -46,7 +42,7 @@ from pretix.helpers.database import rolledback_transaction
logger = logging.getLogger(__name__)
def generate_orderposition(order_position: int, provider: str, cf: CachedFile | None = None):
def generate_orderposition(order_position: int, provider: str):
order_position = OrderPosition.objects.select_related('order', 'order__event').get(id=order_position)
with language(order_position.order.locale, order_position.order.event.settings.region):
@@ -56,23 +52,15 @@ def generate_orderposition(order_position: int, provider: str, cf: CachedFile |
if prov.identifier == provider:
filename, ttype, data = prov.generate(order_position)
path, ext = os.path.splitext(filename)
if cf:
cf.type = ttype
cf.filename = filename
cf.file.save(name=filename, content=ContentFile(data))
return cf.pk
if prov.is_cacheable:
for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider):
ct.delete()
ct = CachedTicket.objects.create(order_position=order_position, provider=provider,
extension=ext, type=ttype, file=None)
ct.file.save(filename, ContentFile(data))
return ct.pk
for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider):
ct.delete()
ct = CachedTicket.objects.create(order_position=order_position, provider=provider,
extension=ext, type=ttype, file=None)
ct.file.save(filename, ContentFile(data))
return ct.pk
def generate_order(order: int, provider: str, cf: CachedFile | None = None):
def generate_order(order: int, provider: str):
order = Order.objects.select_related('event').get(id=order)
with language(order.locale, order.event.settings.region):
@@ -82,88 +70,56 @@ def generate_order(order: int, provider: str, cf: CachedFile | None = None):
if prov.identifier == provider:
filename, ttype, data = prov.generate_order(order)
path, ext = os.path.splitext(filename)
if cf:
cf.type = ttype
cf.filename = filename
cf.file.save(name=filename, content=ContentFile(data))
return cf.pk
if prov.is_cacheable:
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
ct.delete()
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext,
type=ttype, file=None)
ct.file.save(filename, ContentFile(data))
return ct.pk
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
ct.delete()
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext,
type=ttype, file=None)
ct.file.save(filename, ContentFile(data))
return ct.pk
@app.task(base=ProfiledTask)
def generate(model: str, pk: int, provider: str, cachedfile_pk: int | None = None):
def generate(model: str, pk: int, provider: str):
with scopes_disabled():
if cachedfile_pk:
cf = CachedFile.objects.get(pk=cachedfile_pk)
else:
cf = None
if model == 'order':
return generate_order(pk, provider, cf)
return generate_order(pk, provider)
elif model == 'orderposition':
return generate_orderposition(pk, provider, cf)
return generate_orderposition(pk, provider)
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=LazyI18nString.from_gettext("Sample product"), default_price=Decimal('42.23'),
description=LazyI18nString.from_gettext("Sample product description"))
item2 = event.items.create(name=LazyI18nString.from_gettext("Sample workshop"), default_price=Decimal('23.40'))
item.program_times.create(item=item, start=datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc),
end=datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc))
from pretix.base.models import Order, SeatingPlan
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()}
SeatingPlan.objects.create(
name="Plan", organizer=event.organizer, layout="{}"
)
event.seat_category_mappings.create(
layout_category='Stalls', product=item
)
seat = event.seats.create(seat_number="1", row_label="C", product=item, seat_guid=uuid.uuid4())
position = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price, seat=seat)
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):
@@ -187,7 +143,7 @@ def get_tickets_for_order(order, base_position=None):
]
for p in providers:
if not p.is_enabled or not p.is_cacheable:
if not p.is_enabled:
continue
if p.multi_download_enabled and not base_position:
+2 -2
View File
@@ -36,10 +36,10 @@ register = template.Library()
@register.filter("money")
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False) -> str:
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False):
if isinstance(value, (float, int, str)):
if value == '':
return value # pyright: ignore[reportReturnType]
return value
value = Decimal(value)
if value is None:
value = Decimal('0.00')
+2 -26
View File
@@ -99,7 +99,7 @@ class BaseTicketOutput:
def get_tickets_to_print(self, order):
return order.positions_with_tickets
def generate_order(self, order: Order) -> Tuple[str, str, str | bytes]:
def generate_order(self, order: Order) -> Tuple[str, str, str]:
"""
This method is the same as order() but should not generate one file per order position
but instead one file for the full order.
@@ -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:
"""
@@ -231,19 +220,6 @@ class BaseTicketOutput:
"""
return 'fa-download'
@property
def confirmation_text(self) -> str | None:
"""
A text the user has to confirm before a pass is generated.
Returning None means no confirmation screen is shown.
"""
return None
@property
def is_cacheable(self) -> bool:
return True
@property
def preview_allowed(self) -> bool:
"""
+1 -1
View File
@@ -1005,7 +1005,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):
+16 -5
View File
@@ -1198,7 +1198,7 @@ OrganizerFooterLinkFormset = inlineformset_factory(
class SSOProviderForm(I18nModelForm):
config_oidc_base_url = forms.URLField(
label=pgettext_lazy('sso_oidc', 'Base URL'),
label=pgettext_lazy('sso_oidc', 'Base URL / issuer'),
required=False,
)
config_oidc_client_id = forms.CharField(
@@ -1295,6 +1295,14 @@ class SSOProviderForm(I18nModelForm):
class SSOClientForm(I18nModelForm):
disp_base_url = forms.CharField(
label=_('Base URL / issuer'),
disabled=True,
)
disp_client_id = forms.CharField(
label=_('Client ID'),
disabled=True,
)
regenerate_client_secret = forms.BooleanField(
label=_('Invalidate old client secret and generate a new one'),
required=False,
@@ -1302,7 +1310,8 @@ class SSOClientForm(I18nModelForm):
class Meta:
model = CustomerSSOClient
fields = ['is_active', 'name', 'client_id', 'client_type', 'authorization_grant_type', 'redirect_uris',
fields = ['is_active', 'name', 'disp_base_url', 'disp_client_id', 'regenerate_client_secret',
'client_type', 'authorization_grant_type', 'redirect_uris',
'allowed_scopes', 'require_pkce']
widgets = {
'authorization_grant_type': forms.RadioSelect,
@@ -1317,13 +1326,15 @@ class SSOClientForm(I18nModelForm):
help_text=self.fields['allowed_scopes'].help_text,
required=self.fields['allowed_scopes'].required,
initial=self.fields['allowed_scopes'].initial,
choices=CustomerSSOClient.SCOPE_CHOICES,
choices=[(val, format_html('<code>{}</code> &ndash; {}', val, label)) for val, label in CustomerSSOClient.SCOPE_CHOICES],
widget=forms.CheckboxSelectMultiple
)
organizer = kwargs['event']
self.initial['disp_base_url'] = eventreverse_absolute(organizer, 'presale:organizer.index', {}).strip('/')
if self.instance and self.instance.pk:
self.fields['client_id'].disabled = True
self.initial['disp_client_id'] = self.instance.client_id
else:
del self.fields['client_id']
self.initial['disp_client_id'] = _('(will be generated)')
del self.fields['regenerate_client_secret']
@@ -481,15 +481,5 @@
<div class="steps">
</div>
{% enddialog %}
{% trans "Confirmation required" as confirmation_title %}
{% dialog "confirmationmodal" confirmation_title "" icon="shield" %}
<div class="modal-card-confirm modal-card-confirm-spread">
<button type="button" class="btn btn-default btn-lg" commandfor="confirmationmodal" command="close">{% trans "Cancel" %}</button>
<button type="button" id="confirmationmodal-confirm"
class="btn btn-primary btn-lg">
{% trans "Confirm" %}
</button>
</div>
{% enddialog %}
</body>
</html>
@@ -10,7 +10,6 @@
{% load l10n %}
{% load phone_format %}
{% load getitem %}
{% load dialog %}
{% block title %}
{% blocktrans trimmed with code=order.code %}
Order details: {{ code }}
@@ -569,8 +568,7 @@
class="form-inline helper-display-inline{% if b.javascript_required %} requirejs{% endif %}">
{% csrf_token %}
<button type="submit"
class="btn btn-xs btn-default"
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
class="btn btn-xs btn-default">
<span class="fa {{ b.icon }}"></span> {{ b.text }}
</button>
</form>
@@ -1,12 +1,29 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% if client %}
{% trans "SSO client:" %} {{ client.name }}
{% else %}
{% trans "Create credentials for a new SSO client" %}
{% endif %}
{% endblock %}
{% block inner %}
{% if client %}
<h1>{% trans "SSO client:" %} {{ client.name }}</h1>
{% else %}
<h1>{% trans "Create a new SSO client" %}</h1>
<h1>{% trans "Create credentials for a new SSO client" %}</h1>
<p>
{% blocktrans trimmed %}
After submitting this form, client credentials for your SSO client will be generated.
{% endblocktrans %}
</p>
{% endif %}
<p>
{% blocktrans trimmed with url="https://docs.pretix.eu/guides/customer-accounts/#using-pretix-as-an-sso-provider" %}
For more information, refer to our documentation on <a href="{{ url }}">Using pretix as an SSO provider with external SSO clients</a>.
{% endblocktrans %}
</p>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
@@ -3,16 +3,23 @@
{% load bootstrap3 %}
{% block title %}{% trans "SSO clients" %}{% endblock %}
{% block inner %}
<h1>{% trans "SSO clients" %}</h1>
<h1>{% trans "SSO clients" %} <small> &mdash; {% blocktrans %}
"Login with pretix" in external systems
{% endblocktrans %}</small></h1>
<p>
{% blocktrans trimmed %}
You can allow your customers to log into other systems using their customer account credentials by setting up
your other systems as a Single-Sign-On (SSO) client based on OpenID Connect.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed with url="https://docs.pretix.eu/guides/customer-accounts/#using-pretix-as-an-sso-provider" %}
For more information, refer to our documentation on <a href="{{ url }}">Using pretix as an SSO provider with external SSO clients</a>.
{% endblocktrans %}
</p>
<a href="{% url "control:organizer.ssoclient.add" organizer=request.organizer.slug %}" class="btn btn-default">
<span class="fa fa-plus"></span>
{% trans "Create a new SSO client" %}
{% trans "Create credentials for a new SSO client" %}
</a>
<table class="table table-condensed table-hover">
<thead>
@@ -1,12 +1,24 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% if provider %}
{% trans "External SSO provider:" %} {{ provider.name }}
{% else %}
{% trans "Connect to an external SSO provider" %}
{% endif %}
{% endblock %}
{% block inner %}
{% if provider %}
<h1>{% trans "SSO provider:" %} {{ provider.name }}</h1>
<h1>{% trans "External SSO provider:" %} {{ provider.name }}</h1>
{% else %}
<h1>{% trans "Create a new SSO provider" %}</h1>
<h1>{% trans "Connect to an external SSO provider" %}</h1>
{% endif %}
<p>
{% blocktrans trimmed with url="https://docs.pretix.eu/guides/customer-accounts/#using-pretix-as-an-sso-client" %}
For more information, refer to our documentation on <a href="{{ url }}" target="_blank">Using pretix as an SSO client</a>.
{% endblocktrans %}
</p>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
@@ -3,16 +3,23 @@
{% load bootstrap3 %}
{% block title %}{% trans "SSO providers" %}{% endblock %}
{% block inner %}
<h1>{% trans "SSO providers" %}</h1>
<h1>{% trans "SSO providers" %} <small> &mdash; {% blocktrans %}
Connect to an external login provider
{% endblocktrans %}</small></h1>
<p>
{% blocktrans trimmed %}
You can connect existing Single-Sign-On (SSO) providers to allow your customers to log in using your own
account system.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed with url="https://docs.pretix.eu/guides/customer-accounts/#using-pretix-as-an-sso-client" %}
For more information, refer to our documentation on <a href="{{ url }}" target="_blank">Using pretix as an SSO client</a>.
{% endblocktrans %}
</p>
<a href="{% url "control:organizer.ssoprovider.add" organizer=request.organizer.slug %}" class="btn btn-default">
<span class="fa fa-plus"></span>
{% trans "Create a new SSO provider" %}
{% trans "Connect to an external SSO provider" %}
</a>
<table class="table table-condensed table-hover">
<thead>
+4 -9
View File
@@ -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
+9 -34
View File
@@ -54,7 +54,6 @@ from django.db.models import (
Count, Exists, F, IntegerField, Max, OuterRef, Prefetch, ProtectedError, Q,
QuerySet, Subquery, Sum,
)
from uuid import UUID
from django.forms import formset_factory
from django.http import (
FileResponse, Http404, HttpResponseNotAllowed, HttpResponseRedirect,
@@ -583,15 +582,12 @@ 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',
'identifier': provider.identifier,
'multi': provider.multi_download_enabled,
'javascript_required': provider.javascript_required,
'confirmation_text': provider.confirmation_text
'javascript_required': provider.javascript_required
})
return buttons
@@ -671,18 +667,7 @@ class OrderDownload(AsyncAction, OrderView):
permission = 'event.orders:read'
def get_success_url(self, value):
if isinstance(value, UUID):
cf = CachedFile.objects.filter(pk=value).first()
else:
cf = None
if not cf or not cf.allowed_for_session(self.request):
return self.get_self_url()
if cf.type == 'text/uri-list':
return cf.file.file.read().decode()
else:
return reverse('cachedfile.download', kwargs={"id":str(value)})
return self.get_self_url()
def get_error_url(self):
return self.get_order_url()
@@ -721,19 +706,12 @@ class OrderDownload(AsyncAction, OrderView):
if 'position' in kwargs and not self.order_position.generate_ticket:
return self.error(_('Ticket download is not enabled for this product.'))
if self.output.is_cacheable and (ct := self.get_last_ct()):
ct = self.get_last_ct()
if ct:
return self.success(ct)
if not self.output.is_cacheable:
cf = CachedFile(web_download=True, date=now(), expires=now() + timedelta(hours=2))
cf.bind_to_session(request)
cf.save()
else:
cf = None
return self.do('orderposition' if 'position' in kwargs else 'order',
self.order_position.pk if 'position' in kwargs else self.order.pk,
self.output.identifier, cf.pk if cf else None)
self.output.identifier)
def get_success_message(self, value):
return ""
@@ -760,15 +738,12 @@ class OrderDownload(AsyncAction, OrderView):
content_type=value.type
)
else:
return redirect(self.get_success_url(value))
return redirect(self.get_self_url())
def get_last_ct(self):
if self.output.is_cacheable:
ct = CachedTicket.objects.filter(
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
).last()
else:
ct = None
ct = CachedTicket.objects.filter(
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
).last()
if not ct or not ct.file:
return None
return ct
+15 -6
View File
@@ -2817,10 +2817,15 @@ class SSOProviderCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequire
return get_object_or_404(CustomerSSOProvider, organizer=self.request.organizer, pk=self.kwargs.get('provider'))
def get_success_url(self):
return reverse('control:organizer.ssoproviders', kwargs={
'organizer': self.request.organizer.slug,
return reverse('control:organizer.ssoprovider.edit', kwargs={
'organizer': self.request.organizer.slug, 'provider': self.object.pk,
})
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['redirect_uri'] = _('(will be generated)')
return ctx
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.organizer
@@ -2950,8 +2955,11 @@ class SSOClientCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredM
secret = form.instance.set_client_secret()
messages.success(
self.request,
_('The SSO client has been created. Please note down the following client secret, it will never be shown '
'again: {secret}').format(secret=secret)
_('The SSO client has been created.') + ' ' +
_('Please note down the following client secret, it will never be shown again.') + '\n\n' +
_('Base URL / issuer') + f': {form.initial["disp_base_url"]}\n\n' +
_('Client ID') + f': {form.instance.client_id}\n\n' +
_('Client secret') + f': {secret}'
)
form.instance.organizer = self.request.organizer
ret = super().form_valid(form)
@@ -3000,8 +3008,9 @@ class SSOClientUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredM
secret = form.instance.set_client_secret()
messages.success(
self.request,
_('Your changes have been saved. Please note down the following client secret, it will never be shown '
'again: {secret}').format(secret=secret)
_('Your changes have been saved.') + ' ' +
_('Please note down the following client secret, it will never be shown again.') + '\n\n' +
_('Client secret') + f': {secret}'
)
else:
messages.success(
-9
View File
@@ -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):
+4 -4
View File
@@ -5,8 +5,8 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-09-30 08:06+0000\n"
"Last-Translator: Phin Wolkwitz <wolkwitz@pretix.eu>\n"
"PO-Revision-Date: 2026-10-13 14:40+0000\n"
"Last-Translator: Martin Gross <gross@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/"
"de/>\n"
"Language: de\n"
@@ -14,7 +14,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.9.1\n"
"X-Generator: Weblate 2026.10\n"
"X-Poedit-Bookmarks: -1,-1,904,-1,-1,-1,-1,-1,-1,-1\n"
#: pretix/_base_settings.py
@@ -34449,7 +34449,7 @@ msgstr ""
#: pretix/presale/forms/customer.py
msgid "Forgot your password?"
msgstr "Passwort zurücksetzen"
msgstr "Passwort vergessen?"
#: pretix/presale/forms/customer.py
msgid ""
+3 -3
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-07-06 02:00+0000\n"
"Last-Translator: Benedikt Bormann <mail@bbormann.de>\n"
"PO-Revision-Date: 2026-10-13 14:40+0000\n"
"Last-Translator: Martin Gross <gross@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"
"de/>\n"
"Language: de\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.6.1\n"
"X-Generator: Weblate 2026.10\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
msgid "Marked as paid"
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-09-30 08:09+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"PO-Revision-Date: 2026-10-13 14:40+0000\n"
"Last-Translator: Martin Gross <gross@rami.io>\n"
"Language-Team: German (informal) <https://translate.pretix.eu/projects/"
"pretix/pretix/de_Informal/>\n"
"Language: de_Informal\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.9.1\n"
"X-Generator: Weblate 2026.10\n"
#: pretix/_base_settings.py
msgid "English"
+3 -3
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2025-10-28 17:00+0000\n"
"Last-Translator: Sven Muhlen <sven.muhlen@bnl.etat.lu>\n"
"PO-Revision-Date: 2026-10-13 14:40+0000\n"
"Last-Translator: Martin Gross <gross@rami.io>\n"
"Language-Team: Luxembourgish <https://translate.pretix.eu/projects/pretix/"
"pretix/lb/>\n"
"Language: lb\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.14\n"
"X-Generator: Weblate 2026.10\n"
#: pretix/_base_settings.py
msgid "English"
+22 -38
View File
@@ -7,16 +7,16 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-05-05 13:26+0000\n"
"Last-Translator: Ruud Hendrickx <ruud@leckxicon.eu>\n"
"Language-Team: Dutch <https://translate.pretix.eu/projects/pretix/pretix/nl/"
">\n"
"PO-Revision-Date: 2026-10-19 02:00+0000\n"
"Last-Translator: Christiaan de Die le Clercq <contact@techwolf12.nl>\n"
"Language-Team: Dutch <https://translate.pretix.eu/projects/pretix/pretix/nl/>"
"\n"
"Language: nl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.17.1\n"
"X-Generator: Weblate 2026.10.1\n"
#: pretix/_base_settings.py
msgid "English"
@@ -92,7 +92,7 @@ msgstr "Hebreeuws"
#: pretix/_base_settings.py
msgid "Hungarian"
msgstr ""
msgstr "Hongaars"
#: pretix/_base_settings.py
msgid "Indonesian"
@@ -152,7 +152,7 @@ msgstr "Spaans (Latijns-Amerika)"
#: pretix/_base_settings.py
msgid "Thai"
msgstr ""
msgstr "Thais"
#: pretix/_base_settings.py
msgid "Turkish"
@@ -412,7 +412,7 @@ msgstr ""
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
#, python-format
msgid "You've been invited to join %(organizer)s"
msgstr ""
msgstr "Je bent uitgenodigd voor %(organizer)s"
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
msgid "This user already has been invited for this team."
@@ -432,16 +432,12 @@ msgid "Medium connected to other event"
msgstr "Het medium is verbonden met een ander evenement"
#: pretix/api/views/checkin.py
#, fuzzy
#| msgid "You cannot change this order."
msgid "You cannot exchange a medium for a medium."
msgstr "U kunt deze bestelling niet wijzigen."
msgstr "Je kunt geen medium inwisselen voor een medium."
#: pretix/api/views/checkin.py
#, fuzzy
#| msgid "You cannot change this order."
msgid "You cannot simulate a medium exchange."
msgstr "U kunt deze bestelling niet wijzigen."
msgstr "Je kunt een wisseling van medium niet simuleren."
#: pretix/api/views/oauth.py pretix/control/logdisplay.py
#, python-brace-format
@@ -599,16 +595,17 @@ msgstr ""
"objecten zoals varianten of bundels."
#: pretix/api/webhooks.py
#, fuzzy
#| msgid "Quota handling"
msgid "Quota changed"
msgstr "Quotabeheer"
msgstr "Quotum gewijzigd"
#: pretix/api/webhooks.py
msgid ""
"This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability."
msgstr ""
"Dit omvat gerelateerde gebeurtenissen zoals het aanmaken, verwijderen, "
"openen of sluiten van quota. Er wordt geen webhook verzonden voor "
"wijzigingen in de resulterende beschikbaarheid."
#: pretix/api/webhooks.py
msgid "Shop taken live"
@@ -1395,10 +1392,8 @@ msgid "Membership type"
msgstr "Lidmaatschapstype"
#: pretix/base/exporters/customers.py
#, fuzzy
#| msgid "Purchase time"
msgid "Purchase ticket"
msgstr "Aankooptijd"
msgstr "Ticket kopen"
#: pretix/base/exporters/customers.py pretix/base/exporters/orderlist.py
#: pretix/base/exporters/waitinglist.py pretix/base/forms/questions.py
@@ -1415,10 +1410,8 @@ msgid "Start date"
msgstr "Startdatum"
#: pretix/base/exporters/customers.py
#, fuzzy
#| msgid "Start time from"
msgid "Start time"
msgstr "Starttijd vanaf"
msgstr "Starttijd"
#: pretix/base/exporters/customers.py pretix/base/exporters/orderlist.py
#: pretix/base/exporters/waitinglist.py pretix/base/models/memberships.py
@@ -1431,10 +1424,8 @@ msgid "End date"
msgstr "Einddatum"
#: pretix/base/exporters/customers.py
#, fuzzy
#| msgid "End: %(time)s"
msgid "End time"
msgstr "Einde: %(time)s"
msgstr "Eindtijd"
#: pretix/base/exporters/dekodi.py pretix/base/exporters/invoices.py
msgctxt "export_category"
@@ -1794,16 +1785,12 @@ msgid "Transmission type"
msgstr "Verzendingstype"
#: pretix/base/exporters/invoices.py
#, fuzzy
#| msgid "Transmission type"
msgid "Transmission status"
msgstr "Verzendingstype"
msgstr "Verzendstatus"
#: pretix/base/exporters/invoices.py
#, fuzzy
#| msgid "Transmission type"
msgid "Transmission date"
msgstr "Verzendingstype"
msgstr "Verzendingsdatum"
#: pretix/base/exporters/invoices.py
msgid "Cancellation"
@@ -3041,11 +3028,12 @@ msgid ""
"The field \"%(label)s\" may not contain special characters such as "
"\"%(chars)s\"."
msgstr ""
"Het veld \"%(label)s\" mag geen speciale tekens zoals \"%(chars)s\" bevatten."
#: pretix/base/forms/questions.py
#, python-format
msgid "The field \"%(label)s\" may not contain an URL (%(url)s)."
msgstr ""
msgstr "Het veld \"%(label)s\" mag geen URL (%(url)s) bevatten."
#: pretix/base/forms/questions.py
msgctxt "phonenumber"
@@ -4107,10 +4095,8 @@ msgid "Users"
msgstr "Gebruikers"
#: pretix/base/models/auth.py
#, fuzzy
#| msgid "Changes to your account at {organizer}"
msgid "Changes to your account"
msgstr "Wijzigingen in uw account bij {organizer}"
msgstr "Wijzigingen in uw account"
#: pretix/base/models/auth.py
#, fuzzy, python-brace-format
@@ -4136,8 +4122,6 @@ msgstr ""
"naar {new_email}, gebruikt u de volgende code:"
#: pretix/base/models/auth.py
#, fuzzy
#| msgid "Enter confirmation code"
msgid "Your confirmation code"
msgstr "Voer bevestigingscode in"
+3
View File
@@ -633,6 +633,9 @@ class ImportView(ListView):
class OrganizerBanktransferView:
def dispatch(self, request, *args, **kwargs):
# Users can use the bank transfer views if they have sufficient permission for at least one event.
# Yes, this allows them to cauase changes in events they don't have access to. This is an accepted
# risk because every other way, the bank transfer feature becomes basically unusable.
has_any_event_perm = request.user.get_events_with_permission(
"event.orders:write", request=request
).filter(organizer=request.organizer).exists()
@@ -97,7 +97,7 @@ var pretixpaypal = {
let sdk_url = 'https://www.paypal.com/sdk/js'
+ '?client-id=' + pretixpaypal.client_id
+ '&components=buttons,funding-eligibility'
+ '&components=buttons,funding-eligibility,messages'
+ '&currency=' + pretixpaypal.currency
if (pretixpaypal.locale) {
@@ -195,6 +195,12 @@ var pretixpaypal = {
label: 'pay',
tagline: false
},
message: {
amount: undefined,
align: 'center',
color: 'black',
position: 'bottom'
},
createOrder: function (data, actions) {
if (pretixpaypal.order_id) {
return pretixpaypal.order_id
@@ -285,6 +291,18 @@ var pretixpaypal = {
if (button.isEligible()) {
eligibles.push(gettext(pretixpaypal.apm_map[fundingSource] || fundingSource))
if (fundingSource === 'paylater') {
pretixpaypal.paypal.Messages({
pageType: 'payment',
style: {
layout: 'text',
logo: {
type: 'none',
}
}
}).render('.pp-message');
}
}
})
@@ -14,6 +14,7 @@
After you clicked continue, we will be able to select how you want to pay and to fill in your payment
details. You will then be redirected back here to review and confirm your order.
{% endblocktrans %}
<div class="pp-message"></div>
{% endif %}
</p>
@@ -172,6 +172,9 @@ var pretixstripe = {
})
pretixstripe.affirm.mount('#stripe-affirm')
pretixstripe.affirm.on('ready', function () {
$('.stripe-container').closest('form').find('.checkout-button-row .btn-primary').prop('disabled', false)
})
}
if ($('#stripe-klarna').length) {
try {
@@ -183,6 +186,9 @@ var pretixstripe = {
})
pretixstripe.klarna.mount('#stripe-klarna')
pretixstripe.klarna.on('ready', function () {
$('.stripe-container').closest('form').find('.checkout-button-row .btn-primary').prop('disabled', false)
})
} catch (e) {
console.error(e)
$('#stripe-klarna').html('<div class=\'alert alert-danger\'>Technical error, please contact support: ' + e + '</div>')
-21
View File
@@ -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/>.
#
-129
View File
@@ -1,129 +0,0 @@
import os
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers, viewsets
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from .models import WalletLayout, WalletPlatformLayout
from .styles import AVAILABLE_PLATFORMS, AVAILABLE_STYLES_DICT
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"]]
obj = style(event=self.context["event"], layout=data["layout"])
obj.validate()
data["file_settings"] = obj.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":
file_setting, _ = obj.file_settings.get_or_create(
key=key
)
file_setting.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,
)
-41
View File
@@ -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
-137
View File
@@ -1,137 +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 import File
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.utils.translation import gettext_lazy as _
from pretix.control.forms import ClearableBasenameFileInput
from .models import WalletLayout, WalletLayoutItem
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
class WalletLayoutItemForm(forms.ModelForm):
is_layouts = True
layout = forms.ModelChoiceField(
queryset=WalletLayout.objects.none(),
label=_("Wallet Layout"),
empty_label=_("(Event default)"),
required=False
)
class Meta:
model = WalletLayoutItem
fields = ("layout",)
def __init__(self, *args, **kwargs):
event = kwargs.pop("event")
super().__init__(*args, **kwargs)
self.fields["layout"].queryset = event.wallet_layouts.all()
def save(self, commit=True):
if self.cleaned_data["layout"] is None:
if self.instance.pk:
self.instance.delete()
else:
self.instance.layout = self.cleaned_data["layout"]
return super().save(commit=commit)
-75
View File
@@ -1,75 +0,0 @@
from typing import Callable, Iterable, Protocol, runtime_checkable
from django.utils import translation
from django.utils.formats import date_format
from django.utils.functional import Promise
type MaybeTranslatedString = str | Localizable
@runtime_checkable
class Localizable(Protocol):
def localize(self, lng: str) -> str:
...
# marked Promise so DjangoJSONEncoder automatically transforms it into a string
class LocalizableBase(Promise):
def localize(self, lng):
with translation.override(lng):
return str(self)
class FormattingLocalizableString(LocalizableBase):
def __init__(
self,
base_str: MaybeTranslatedString,
*format_args: MaybeTranslatedString,
**format_kwargs: MaybeTranslatedString,
):
self.base_str = base_str
self.format_args = format_args
self.format_kwargs = format_kwargs
def __str__(self):
return str(self.base_str).format(*self.format_args, **self.format_kwargs)
class GettextLocalizableString(LocalizableBase):
def __init__(self, base):
self.base = base
@classmethod
def gettext(cls, base):
"""Fake function so makemessages picks up these strings."""
return cls(base)
def __str__(self):
return translation.gettext(self.base)
class LocalizableDatetime(LocalizableBase):
def __init__(self, datetime, format):
self.datetime = datetime
self.format = format
def __str__(self):
return date_format(self.datetime, self.format)
class LazyLocalizable(LocalizableBase):
def __init__(self, func: Callable[..., str]):
self.func = func
def __str__(self):
return self.func()
def localizable_to_dict(string: MaybeTranslatedString, locales: Iterable[str]):
translated = {}
for locale in locales:
with translation.override(locale):
translated[locale] = str(string)
return translated
@@ -1,99 +0,0 @@
# Generated by Django 5.2.13 on 2026-05-19 15:39
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
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),
),
]
@@ -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,52 +0,0 @@
# Generated by Django 5.2.17 on 2026-10-06 14:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0312_alter_customer_locale_alter_devicelastseen_device_and_more",
),
("wallet", "0003_walletlayoutfilesetting"),
]
operations = [
migrations.AlterField(
model_name="walletlayoutitem",
name="layout",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="item_assignments",
to="wallet.walletlayout",
),
),
migrations.CreateModel(
name="GoogleWalletInstance",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("type", models.CharField()),
("identifier", models.CharField()),
("data", models.JSONField()),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.event",
),
),
],
options={
"unique_together": {("type", "identifier")},
},
),
]
@@ -1,22 +0,0 @@
# Generated by Django 5.2.17 on 2026-10-19 20:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("wallet", "0004_alter_walletlayoutitem_layout_googlewalletinstance"),
]
operations = [
migrations.RenameField(
model_name="googlewalletinstance",
old_name="type",
new_name="resource",
),
migrations.AlterUniqueTogether(
name="googlewalletinstance",
unique_together={("resource", "identifier")},
),
]
Whitespace-only changes.
-123
View File
@@ -1,123 +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.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q, constraints
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.models import LoggedModel
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"
)
]
ordering = ("-default", "name",)
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 = {fs.key: fs.file for fs in self.file_settings.all()}
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,
null=True,
on_delete=models.CASCADE,
related_name="item_assignments",
)
def clean(self):
if self.layout and 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)
# prob smth like this
class GoogleWalletInstance(models.Model):
event = models.ForeignKey("pretixbase.Event", on_delete=models.CASCADE)
resource = models.CharField()
identifier = models.CharField()
data = models.JSONField()
class Meta:
unique_together = (("resource", "identifier"),)
@@ -1,14 +0,0 @@
from . import base, placeholders
from .base import WalletPlaceholderRenderer, get_available_context
from .placeholders import (
get_wallet_placeholder_renderer, get_wallet_placeholders,
)
__all__ = [
"base",
"placeholders",
"get_available_context",
"get_wallet_placeholder_renderer",
"get_wallet_placeholders",
"WalletPlaceholderRenderer",
]
@@ -1,302 +0,0 @@
from collections.abc import Callable
from functools import cached_property
from typing import Any
from django.core.files import File
from ..i18n import MaybeTranslatedString
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 content_type(self) -> str:
"""The content type this placeholder generates (text / image)"""
raise NotImplementedError()
@property
def label(self) -> MaybeTranslatedString:
"""The human readable name of this placeholder"""
raise NotImplementedError()
@property
def control_label(self) -> MaybeTranslatedString:
"""
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):
@property
def content_type(self) -> str:
return "text"
def render(self, **context) -> MaybeTranslatedString | 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) -> MaybeTranslatedString:
"""
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 not sample:
raise RuntimeError("`render` returned falsy value when rendering a sample")
return sample
class BaseWalletImagePlaceholder(BaseWalletPlaceholder):
@property
def content_type(self) -> str:
return "image"
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: MaybeTranslatedString,
args: set[str],
func: Callable[..., MaybeTranslatedString | None],
sample: (
None | MaybeTranslatedString | Callable[..., MaybeTranslatedString]
) = None,
):
self._identifier = identifier
self._label = label
self._args = args
self.render = func
self._sample = sample
def __repr__(self):
return f"FunctionalWalletTextPlaceholder(identifier={self.identifier!r}, label={self.label!r})"
@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) -> MaybeTranslatedString:
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: MaybeTranslatedString,
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 PlaceholderContextTransformation:
@property
def required_context(self) -> set[str]:
"""
A a set of all attribute names that need to be contained in the base context for this transformation to function.
"""
return set()
@property
def generated_context_name(self) -> str:
"""The name of the context arg this transformation produces"""
raise NotImplementedError()
def transform(self, context):
raise NotImplementedError("`transform` not implemented")
class FunctionalContextTransformation(PlaceholderContextTransformation):
def __init__(self, context_name: str, context_args: set[str], func: Callable):
self.context_args = context_args
self.context_name = context_name
self.transform = func
@property
def required_context(self) -> set[str]:
return self.context_args
@property
def generated_context_name(self) -> str:
return self.context_name
def get_available_context(
initial_context: set[str], transformations: list[PlaceholderContextTransformation]
):
used_trans = set()
available_context = set(initial_context)
changed = True
while changed:
changed = False
for trans in transformations:
if trans in used_trans:
continue
if trans.required_context <= available_context:
used_trans.add(trans)
if trans.generated_context_name not in available_context:
available_context.add(trans.generated_context_name)
changed = True
return available_context
def get_transformed_context(
context_args: dict[str, Any],
transformations: list[PlaceholderContextTransformation],
):
used_trans = set()
transformed_context_args = dict(context_args)
changed = True
while changed:
changed = False
for trans in transformations:
if trans in used_trans:
continue
if trans.required_context <= transformed_context_args.keys():
used_trans.add(trans)
if trans.generated_context_name not in transformed_context_args.keys():
transformation_context = {
k: v
for k, v in transformed_context_args.items()
if k in trans.required_context
}
transformed_context_args[trans.generated_context_name] = (
trans.transform(**transformation_context)
)
changed = True
return transformed_context_args
class WalletPlaceholderRenderer:
def __init__(
self,
*,
transformations: list[PlaceholderContextTransformation] | None = None,
**kwargs,
):
self.context = kwargs
self.cache = {}
self.transformations = list(transformations) if transformations else []
@cached_property
def _get_transformed_context(self):
return get_transformed_context(self.context, self.transformations)
def _get_placeholder_context(self, placeholder: BaseWalletPlaceholder):
context = self._get_transformed_context
missing_context = placeholder.required_context - context.keys()
if missing_context:
raise MissingContextException(
f"Missing context args for '{placeholder.identifier}': {', '.join(missing_context)}"
)
return {k: v for k, v in context.items() if k in placeholder.required_context}
def is_available(self, placeholder: BaseWalletPlaceholder, context_args: set[str]):
available_context = get_available_context(context_args, self.transformations)
missing_context = placeholder.required_context - available_context
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))
@@ -1,314 +0,0 @@
from django.contrib.staticfiles import finders
from django.core.files import File
from django.dispatch import receiver
from django.templatetags.static import static
from pretix.base.models.event import Event
from pretix.base.templatetags.money import money_filter
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.plugins.wallet.i18n import (
FormattingLocalizableString, GettextLocalizableString, LocalizableDatetime,
)
from ..signals import register_wallet_placeholders
from .base import (
BaseWalletPlaceholder, FunctionalContextTransformation,
FunctionalWalletImagePlaceholder, FunctionalWalletTextPlaceholder,
WalletPlaceholderRenderer,
)
def get_wallet_placeholder_renderer(**kwargs):
return WalletPlaceholderRenderer(
**kwargs,
transformations=[
# TODO: make this extendable via signal?
FunctionalContextTransformation(
"item", {"order_position"}, lambda order_position: order_position.item
),
FunctionalContextTransformation(
"order", {"order_position"}, lambda order_position: order_position.order
),
FunctionalContextTransformation(
"event", {"order"}, lambda order: order.event
),
FunctionalContextTransformation(
"organizer", {"event"}, lambda event: event.organizer
),
FunctionalContextTransformation(
"subevent_or_event",
{"order_position"},
lambda order_position: order_position.subevent
or order_position.order.event,
),
FunctionalContextTransformation(
"seat",
{"order_position"},
lambda order_position: (
order_position.seat if order_position.seat_id else None
),
),
],
)
def get_wallet_placeholders(event) -> dict[str, dict[str, BaseWalletPlaceholder]]:
placeholders = {"text": {}, "image": {}}
for r, ps in register_wallet_placeholders.send(sender=event):
for placeholder in ps:
placeholders[placeholder.content_type][placeholder.identifier] = placeholder
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"))
def admission(subevent_or_event):
return (
LocalizableDatetime(
subevent_or_event.date_admission.astimezone(subevent_or_event.timezone),
"SHORT_DATETIME_FORMAT",
)
if subevent_or_event.date_admission
else None
)
def date_from(subevent_or_event):
return LocalizableDatetime(
subevent_or_event.date_from.astimezone(subevent_or_event.timezone),
"SHORT_DATETIME_FORMAT",
)
def admission_or_date_from(subevent_or_event):
return admission(subevent_or_event) or date_from(subevent_or_event)
def website(order_position, order):
if order_position.subevent:
return eventreverse_absolute(
order.event,
"presale:event.index",
{"subevent": order_position.subevent.pk},
)
else:
return eventreverse_absolute(order.event, "presale:event.index")
@receiver(
register_wallet_placeholders,
dispatch_uid="plugin_wallet_register_wallet_placeholders",
)
def base_text_placeholders(sender: Event, **kwargs):
return [
FunctionalWalletTextPlaceholder(
"event",
GettextLocalizableString.gettext("Event"),
{"event"},
lambda event: event.name,
),
FunctionalWalletTextPlaceholder(
"event_slug",
GettextLocalizableString.gettext("Event Slug"),
{"event"},
lambda event: event.slug,
),
FunctionalWalletTextPlaceholder(
"admission",
GettextLocalizableString.gettext("Admission"),
{"subevent_or_event"},
admission,
date_from,
),
FunctionalWalletTextPlaceholder(
"date_from",
GettextLocalizableString.gettext("Begin"),
{"subevent_or_event"},
date_from,
),
FunctionalWalletTextPlaceholder(
"admission_or_date_from",
GettextLocalizableString.gettext("Admission or Date From"),
{"subevent_or_event"},
admission_or_date_from,
),
FunctionalWalletTextPlaceholder(
"order",
GettextLocalizableString.gettext("Order Code"),
{"order"},
lambda order: order.code,
),
FunctionalWalletTextPlaceholder(
"total",
GettextLocalizableString.gettext("Order Total"),
{"event", "order"},
lambda event, order: money_filter(order.total, event.currency),
),
FunctionalWalletTextPlaceholder(
"order_email",
GettextLocalizableString.gettext("Order Email"),
{"order"},
lambda order: order.email,
),
FunctionalWalletTextPlaceholder(
"price",
GettextLocalizableString.gettext("Item Price"),
{"event", "order_position"},
lambda event, order_position: money_filter(
order_position.price, event.currency
),
),
FunctionalWalletTextPlaceholder(
"secret",
GettextLocalizableString.gettext("Order Secret (QR-Code-Content)"),
{"order_position"},
lambda order_position: order_position.secret,
),
FunctionalWalletTextPlaceholder(
"item",
GettextLocalizableString.gettext("Product"),
{"item"},
lambda item: item.name,
),
FunctionalWalletTextPlaceholder(
"item_description",
GettextLocalizableString.gettext("Product description"),
{"item"},
lambda item: item.description,
),
FunctionalWalletTextPlaceholder(
"item_with_variation",
GettextLocalizableString.gettext("Product and variation"),
{"order_position"},
lambda order_position: (
FormattingLocalizableString(
"{name} - {variation}",
name=order_position.item.name,
variation=order_position.variation.value,
)
if order_position.variation
else order_position.item.name
),
),
FunctionalWalletTextPlaceholder(
"seat",
GettextLocalizableString.gettext("Seat: Full name"),
{"seat"},
lambda seat: (
FormattingLocalizableString("{seat}", seat=seat) if seat else None
),
GettextLocalizableString.gettext("Ground floor, Row 3, Seat 4")
),
FunctionalWalletTextPlaceholder(
"seat_zone",
GettextLocalizableString.gettext("Seat: Zone"),
{"seat"},
lambda seat: seat.zone_name,
GettextLocalizableString.gettext("Ground floor"),
),
FunctionalWalletTextPlaceholder(
"seat_row",
GettextLocalizableString.gettext("Seat: Row"),
{"seat"},
lambda seat: seat.row_label or seat.row_name,
"C",
),
FunctionalWalletTextPlaceholder(
"seat_number",
GettextLocalizableString.gettext("Seat: Seat Number"),
{"seat"},
lambda seat: seat.seat_label or seat.seat_number,
"5",
),
FunctionalWalletTextPlaceholder(
"attendee_name",
GettextLocalizableString.gettext("Attendee name"),
{"order_position"},
lambda order_position: order_position.attendee_name,
),
FunctionalWalletTextPlaceholder(
"program_start",
GettextLocalizableString.gettext("Programm times: Start"),
{"item", "subevent_or_event"},
lambda item, subevent_or_event: (
LocalizableDatetime(
min(pt.start for pt in item.program_times.all()).astimezone(
subevent_or_event.timezone
),
"SHORT_DATETIME_FORMAT",
)
if item.program_times.all()
else None
),
),
FunctionalWalletTextPlaceholder(
"program_end",
GettextLocalizableString.gettext("Programm times: End"),
{"item", "subevent_or_event"},
lambda item, subevent_or_event: (
LocalizableDatetime(
max(pt.end for pt in item.program_times.all()).astimezone(
subevent_or_event.timezone
),
"SHORT_DATETIME_FORMAT",
)
if item.program_times.all()
else None
),
),
FunctionalWalletTextPlaceholder(
"organizer",
GettextLocalizableString.gettext("Organizer"),
{"organizer"},
lambda organizer: organizer.name,
),
FunctionalWalletTextPlaceholder(
"organizer_contact",
GettextLocalizableString.gettext("Organizer Contact"),
{"event"},
lambda event: event.settings.contact_mail,
"contact@example.com",
),
FunctionalWalletTextPlaceholder(
"order_code",
GettextLocalizableString.gettext("Order Code"),
{"order"},
lambda order: order.code,
),
FunctionalWalletTextPlaceholder(
"purchase_date",
GettextLocalizableString.gettext("Purchase Date"),
{"order", "subevent_or_event"},
lambda order, subevent_or_event: LocalizableDatetime(
order.datetime.astimezone(subevent_or_event.timezone),
"SHORT_DATETIME_FORMAT",
),
),
FunctionalWalletTextPlaceholder(
"website",
GettextLocalizableString.gettext("Website"),
{"order_position", "order"},
website,
),
# IMAGES
FunctionalWalletImagePlaceholder(
"poweredby_logo",
GettextLocalizableString.gettext("Logo"),
set(),
lambda: get_static_file("pretixplugins/wallet/logo.png"),
static("pretixplugins/wallet/logo.png"),
),
FunctionalWalletImagePlaceholder(
"poweredby_icon",
GettextLocalizableString.gettext("Icon"),
set(),
lambda: get_static_file("pretixplugins/wallet/icon.png"),
static("pretixplugins/wallet/icon.png"),
),
# TODO: Image upload
]
-72
View File
@@ -1,72 +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.dispatch import receiver
from pretix.base.signals import (
register_global_settings, register_ticket_outputs,
)
from pretix.control.signals import item_forms
from .forms import WalletLayoutItemForm
from .models import WalletLayoutItem
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()
@receiver(item_forms, dispatch_uid="pretix_wallet_item_forms")
def control_item_forms(sender, request, item, **kwargs):
forms = []
try:
inst = WalletLayoutItem.objects.get(item=item)
except WalletLayoutItem.DoesNotExist:
inst = WalletLayoutItem(item=item)
forms.append(
WalletLayoutItemForm(
instance=inst,
event=sender,
data=(request.POST if request.method == "POST" else None),
prefix="wallet",
)
)
return forms
Whitespace-only changes.
-31
View File
@@ -1,31 +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_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.BaseWalletPlaceholder.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -1,183 +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 { StoreKey } from "../walletStore";
import GoogleEventDefault from "./platform-preview/google-event-default.vue";
import GoogleEventCustom from "./platform-preview/google-event-custom.vue";
import Apple from "./platform-preview/apple.vue";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
function openForm(url: string, data: Record<string, string | object>) {
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;
let value = data[key];
if (value instanceof Object) {
value = JSON.stringify(data[key]);
}
input.value = value;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
async function openPreview(e: Event) {
e.preventDefault();
openForm(store.previewUrl, {
csrfmiddlewaretoken: store.csrfToken,
id: store.layoutId,
...(await store.serializeCurrentPlatformLayout()),
});
}
const platformChoices = computed((): [string, string][] => {
return [
[null, "Do not generate pass"],
...Object.values(store.platform.styles).map((x): [string, string] => [x.identifier, x.name]),
];
});
const previewRenderers = {
google_eventticket_default: GoogleEventDefault,
google_eventticket_custom: GoogleEventCustom,
apple_eventticket: Apple,
};
</script>
<template lang="pug">
// TODO: add :key for all `v-for`s
// 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" required="true")
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")
button(type="button" @click="store.setStyle(store.style.identifier)") Reset style
StyleSettings(v-if="!!store.style")
.col-md-6.col-lg-4(style="position: sticky; top: 10px;")
.panel.panel-default
.panel-heading Preview
.panel-body
div(v-if="!!store.style?.preview_renderer && store.style?.preview_renderer in previewRenderers")
p.text-muted {{ gettext("The preview below is only a rough representation of what the pass might look like. Please check the generated pass.") }}
component(:is="previewRenderers[store.style?.preview_renderer]" :data="store.style?.preview_data")
div(v-else) Preview not supported
.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;
}
}
}
.walletsettings-panel .panel-body {
> :is(h1, h2, h3, h4, h5, h6):first-child {
margin-top: 0;
}
}
.pass-container {
min-width: 0;
width: 100%;
min-height: 0;
border: solid 1px #949494;
padding: 1em;
overflow: hidden;
position: relative;
background-image: var(--background-image);
background-color: var(--background-color);
color: var(--bg-color);
background-size: cover;
background-position: center;
}
.blur-bg::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(10px);
pointer-events: none;
}
.f-row {
display: flex;
flex-direction: row;
gap: inherit;
> div {
min-width: 0;
}
}
.f-column {
display: flex;
flex-direction: column;
gap: inherit;
}
.pass-inner {
position: relative;
gap: 1em;
display: flex;
flex-direction: column;
}
.qrcode-inner {
font-weight: bold;
background-color: lightgray;
color: black;
width: 50%;
aspect-ratio: 1;
align-self: center;
/* center content */
display: flex;
align-items: center;
text-align: center;
justify-content: center;
padding: 1em;
overflow-wrap: anywhere;
}
</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,62 +0,0 @@
<script setup lang="ts">
import { computed, ref, useId, watchEffect } from "vue";
const gettext = (window as any).gettext;
const selectedFile = defineModel<{"name": string; "url": string;}>()
const props = defineProps<{
label?: LocalizableString;
errors?: string[];
help_text?: string;
default?: string;
}>();
const id = useId();
function onChange(e) {
selectedFile.value = (e.target as HTMLInputElement).files[0] || null
}
const inputRef = ref<HTMLInputElement>();
function clearFile() {
selectedFile.value = null;
}
function onDrop(e) {
selectedFile.value = e.dataTransfer.files[0] || null
}
</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(@drop.stop.prevent="onDrop" @dragover.stop.prevent @dragenter.stop.prevent)
div(v-if="!!selectedFile")
| {{ gettext("Currently") + ': ' }}
a(:href="selectedFile.url" data-lightbox="input") {{ selectedFile.name }}
.btn-group
button.btn.btn-default(type="button" @click="inputRef.click()") Select file
button.btn.btn-danger(v-if="!!selectedFile || !!current_url" type="button" @click="clearFile")
i.fa.fa-trash
span.sr-only {{ gettext("Clear") }}
input.hidden(:id="id" @change="onChange" v-bind="$attrs" :required="$attrs.required && !current_url" type="file" ref="inputRef")
.help-block(v-if="!!help_text || !!props.default")
template(v-if="!!help_text") {{ help_text }}
br(v-if="!!help_text && !!props.default")
template(v-if="!!props.default")
a(:href="props.default" data-lightbox="input") {{ gettext("Show default") }}
.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,31 +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[],
placeholders: Record<string, 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]))
}
if (modelValue.value === null) {
modelValue.value = {};
}
})
</script>
<template lang="pug">
input.form-control(v-if="!!modelValue" v-for="(human_readable, locale) in store.locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="placeholders?.[locale] || human_readable")
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
@@ -1,86 +0,0 @@
<script setup lang="ts">
import { useId, computed, onMounted, useTemplateRef, onUnmounted, onUpdated, onBeforeUpdate } from "vue";
const gettext = (window as any).gettext;
defineOptions({
inheritAttrs: false,
});
const {
label,
errors,
type = "text",
min=null,
max=null,
defaultVal=null
} = defineProps<{
label?: string;
errors?: string[];
type?: string;
help_text?: string;
inline?: boolean;
min?: number;
max?: number;
defaultVal?: string;
}>();
const modelValue = defineModel<string | null>();
const id = useId();
const inputRef = useTemplateRef("input");
const attrs = computed(() => {
if (type === "float") {
return { class: "appear-text", type: "number", step: "any", min, max };
} else {
return { type: "text" };
}
});
onMounted(() => {
if (type == "color") {
const jq_elem = $(inputRef.value)
jq_elem.colorpicker({
format: "hex",
align: "left",
customClass: "colorpicker-2x",
sliders: {
saturation: {
maxLeft: 200,
maxTop: 200,
},
hue: {
maxTop: 200,
},
alpha: {
maxTop: 200,
},
},
})
jq_elem.on('changeColor', (e) => e.value && (modelValue.value = e.value))
jq_elem.on('showPicker', () => {jq_elem.colorpicker('setValue', modelValue.value || defaultVal)});
}
});
onUnmounted(() => {
if (type == "color") {
$(inputRef.value).colorpicker("destroy");
}
});
</script>
<template lang="pug">
.form-group(:class="{'row':inline}")
label.control-label(:for="id" v-if="label" :class="{'col-md-3': inline}") {{ label }}
br(v-if="!$attrs.required")
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
div(:class="{'col-md-9': inline}")
input.form-control(:id="id" ref="input" v-model="modelValue" v-bind="{...$attrs, ...attrs}" :placeholder="defaultVal")
.help-block(v-if="!!help_text") {{ help_text }}
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
<style lang="css" scoped>
.appear-text {
appearance: textfield;
}
</style>
@@ -1,34 +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,
help_text?: 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.help_text") {{ help_text }}
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>
@@ -1,134 +0,0 @@
<script setup lang="ts">
import { computed, inject, watchEffect } from "vue";
import Select from "./input/select.vue";
import I18nInput from "./input/i18ninput.vue";
import TextContent from "./text-content.vue";
import { StoreKey } from "../walletStore";
import { i18nstringLocalize } from "../helpers.js";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
const ngettext = (window as any).ngettext;
const interpolate = (window as any).interpolate;
const props = defineProps<{
fieldgroup: PlaceholderFieldGroupDefinition;
}>();
const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({
required: true,
});
const overflowOptions = computed((): Array<[string | null, string]> => {
if (props.fieldgroup.overflows.length > 0) {
return [
...props.fieldgroup.overflows.map((x): [string, string] => [x, store.fieldgroups[x]?.name]),
[null, "Do not overflow"],
];
} else {
return [];
}
});
function addEntry() {
fieldConfig.value.entries.push({ type: "placeholder", label: "", content: null });
}
watchEffect(() => {
if (!fieldConfig.value) {
fieldConfig.value = {
overflow: props.fieldgroup.default_overflow,
entries: JSON.parse(JSON.stringify(props.fieldgroup.default_entries)),
};
}
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.placeholders.text)
.filter(([_, { required_context }]) =>
new Set(required_context).isSubsetOf(availableContext),
)
.map(([k, v]): [string, string] => [k, i18nstringLocalize(v.label)]);
choices.push(["other", gettext("Other…")]);
return choices;
});
const overflowText = computed(() => {
if (!props.fieldgroup.max_entries) {
return ""
}
const formats = ngettext(
'Maximum one field in this fieldgroup can be shown on the pass. The remaining fields will be shown in the selected fieldgroup.',
'Maximum %s fields in this fieldgroup can be shown on the pass. The remaining fields will be shown in the selected fieldgroup.',
props.fieldgroup.max_entries
);
return interpolate(formats, [props.fieldgroup.max_entries])
})
const placeholders = computed(() => {
return fieldConfig.value.entries.map((entry) => {
const placeholder =
entry.type === "placeholder"
? store.placeholders[props.fieldgroup.content_type][entry.content]
: null;
const placeholders = {};
let base;
if (i18nstringLocalize(entry.label)) {
base = entry.label
} else if (placeholder) {
base = placeholder.label
} else {
return null
}
for (const locale of Object.keys(store.locales)) {
placeholders[locale] = i18nstringLocalize(base, locale)
}
return placeholders
})
});
</script>
<template lang="pug">
.panel.panel-default.walletsettings-panel
.panel-heading
h3.panel-title {{ fieldgroup.name }}
.panel-body(v-if="fieldConfig")
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
h4 {{ gettext("Content") }}
.form-group
table.table.table-hover
thead
tr
th.col-5(v-if="fieldgroup.display == 'with_label'") {{ gettext('Label') }}
th(:class="'col-' + (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" :placeholders="placeholders[n - 1]")
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.placeholders.image).map(([k, v]) => [k, i18nstringLocalize(v.label)])"
)
td.text-right
button.btn.btn-danger.form-control-static(type="button" @click="fieldConfig.entries.splice(n - 1, 1)" v-if="fieldConfig.entries.length > (fieldgroup.min_entries||0)")
i.fa.fa-trash
span.sr-only {{ gettext('Delete') }}
.text-left.text-muted(v-else) {{ gettext("At least one field in this group is required") }}
button.btn.btn-default(type="button" @click="addEntry")
i.fa.fa-plus
| {{ gettext("Add field") }}
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow" :help_text="overflowText")
</template>
@@ -1,25 +0,0 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
const store = inject(StoreKey)!;
const props = defineProps<{
fieldgroup: string;
}>();
function focusGroup() {
// TODO: highlight group? or change ui concept completely
const elem = document.getElementById('fieldgroup-' + props.fieldgroup)
elem && elem.scrollIntoView()
}
</script>
<template lang="pug">
div(@click="focusGroup")
div(v-for="entry in store.renderedFieldGroups[fieldgroup]")
div.pass-label {{ entry.label }}
div.pass-content {{ entry.content }}
</template>
@@ -1,122 +0,0 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { StoreKey } from "../../walletStore";
import FieldGroupEntries from "./FieldGroupEntries.vue";
const store = inject(StoreKey)!;
const props = defineProps<{ data: any }>();
const theme = computed(() => {
const theme = {
bg_color: store.getSettingValue("bg_color"),
fg_color: store.getSettingValue("fg_color"),
label_color: store.getSettingValue("label_color"),
bg_image: null,
};
const bg_image = store.getSettingValue("background");
if (bg_image?.url) {
theme.bg_image = `url(${encodeURI(store.getSettingValue("background").url)})`;
theme.bg_color = "#000";
theme.fg_color = '#fff'
}
return theme;
});
</script>
<template lang="pug">
div.apple-pass-preview
.pass-container.blur-bg.apple-pass-front
.pass-inner.f-column
.f-row.head-row
img.logo(:src="store.getSettingValue('logo')?.url")
div.logo-text {{ store.renderedFieldGroups['logo_text']?.[0]?.content }}
FieldGroupEntries.header.f-row(fieldgroup="header")
.f-column
FieldGroupEntries.primary(fieldgroup="primary")
FieldGroupEntries.f-row(fieldgroup="secondary")
FieldGroupEntries.f-row(fieldgroup="auxiliary")
.qrcode-inner(v-for="qrcode in store.renderedFieldGroups['code']") {{ qrcode.label }}
.pass-container.apple-pass-back
FieldGroupEntries.pass-inner.f-column(fieldgroup="back")
</template>
<style lang="css">
.apple-pass-preview {
display: grid;
gap: 1em;
grid-template-columns: repeat(auto-fit, minmax(auto, 360px));
align-items: start;
.apple-pass-front {
background-image: v-bind("theme.bg_image");
background-color: v-bind("theme.bg_color");
color: v-bind("theme.fg_color");
background-size: cover;
background-position: center;
padding-top: 0.25em;
}
.pass-content {
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
}
.head-row {
align-items: center;
gap: 0.5rem;
}
.logo {
max-height: 3lh;
object-fit: contain;
flex-grow: 0;
}
.logo-text {
font-size: 1.5em;
font-weight: bold;
min-height: 2lh;
flex: 1 1;
align-content: center;
text-wrap: nowrap;
}
.header {
flex: 1 0 fit-content;
text-align: end;
line-height: 1.25;
gap: 0.25rem;
justify-content: end;
.pass-content {
font-size: 1.25em;
}
}
.primary {
.pass-content {
font-size: 1.5em;
}
}
.hero {
align-self: center;
max-width: 100%;
}
.apple-pass-front .pass-label {
font-weight: bold;
font-size: 0.8em;
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
color: v-bind("theme.label_color");
}
.apple-pass-back .pass-label {
font-weight: bold;
}
}
</style>
@@ -1,23 +0,0 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { StoreKey } from "../../walletStore";
import { dateTimeFormat, i18nstringLocalize } from "../../helpers";
import Google from "./google.vue";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
import FieldGroupEntries from "./FieldGroupEntries.vue";
const props = defineProps<{ data: any }>();
</script>
<template lang="pug">
Google(:data="data")
template(#card-rows)
FieldGroupEntries.card-row(fieldgroup="second_row")
FieldGroupEntries.card-row(fieldgroup="third_row")
template(#details)
FieldGroupEntries.f-column(fieldgroup="details")
</template>
@@ -1,59 +0,0 @@
<script setup lang="ts">
import { inject } from "vue";
import { StoreKey } from "../../walletStore";
import { dateTimeFormat, i18nstringLocalize } from "../../helpers";
import Google from "./google.vue";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
const props = defineProps<{ data: any }>();
function isActive(fieldgroup: string) {
return store.layout.fieldgroups[fieldgroup]?.active;
}
</script>
<template lang="pug">
Google(:data="data")
template(#card-rows)
div.card-row(v-if="isActive('date')")
div
div.pass-label {{ gettext("Date") }}
div.pass-content {{ dateTimeFormat(data.date_from, {"dateStyle": "medium"}) }}
div
div.pass-label {{ gettext("Time") }}
div.pass-content {{ dateTimeFormat(data.date_from, {"timeStyle": "short"}) }}
div.card-row(v-if="isActive('seating')")
div
div.pass-label {{ gettext("Section") }}
div.pass-content {{ gettext("Ground floor") }}
div
div.pass-label {{ gettext("Row") }} / {{ gettext("Seat") }}
div.pass-content 5 / 2
template(#details)
div.pass-details-row(v-if="isActive('ticket_holder')")
div.pass-label {{ gettext("Ticket Holder") }}
div.pass-content {{ gettext("John Doe") }}
div.pass-details-row(v-if="isActive('venue')")
div.pass-label {{ i18nstringLocalize(data.venue?.[0]) }}
div.pass-content {{ i18nstringLocalize(data.venue?.[1]) }}
div.pass-details-row(v-if="isActive('date')")
div.pass-label {{ gettext("Event Start Time") }}
div.pass-content {{ i18nstringLocalize(data.venue?.[1]) }}
div.pass-details-row
div.pass-label {{ gettext("Ticket Type") }}
div.pass-content {{ gettext("Sample product") }}
div.pass-details-row
div.pass-label {{ gettext("Ticket Number") }}
div.pass-content ABCDE-1
div.pass-details-row
div.pass-label {{ gettext("Order Number") }}
div.pass-content ABCDE
a.pass-details-link.f-row(:href="data.event_page")
div
i.fa.fa-globe
div.pass-label
| {{ " " + gettext("Website") }}
</template>
@@ -1,135 +0,0 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { StoreKey } from "../../walletStore";
import { dateTimeFormat, i18nstringLocalize } from "../../helpers";
const store = inject(StoreKey)!;
const props = defineProps<{ data: any }>();
function isActive(fieldgroup: string) {
return store.layout.fieldgroups[fieldgroup]?.active;
}
const qrcode = computed(() => store.renderedFieldGroups["code"]?.[0]);
</script>
<template lang="pug">
div.pass-preview-google(style="")
div.f-column
.pass-container.f-row
img.logo(:src="store.getSettingValue('logo')?.url")
div
.bold {{ data.event }}
div(v-if="isActive('date')") {{ dateTimeFormat(data.date_from, {"dateStyle": "medium"}) }}
div(v-else) {{ data.organizer }}
.pass-container.google-main(:data-background-color="store.getSettingValue('bg_color')")
.pass-inner
.f-row
img.logo(:src="store.getSettingValue('logo')?.url")
div.logo-text {{ data.organizer }}
.top-row
div(v-if="isActive('venue')") {{ i18nstringLocalize(data.venue?.[0]) }}
.event-name {{ data.event }}
.f-column
slot(name="card-rows")
.qrcode-inner(v-for="qrcode in store.renderedFieldGroups['code']") {{ qrcode.label }}
.f-column
img.hero(:src="store.getSettingValue('hero')?.url")
.pass-container.google-pass-details
.pass-inner
slot(name="details")
</template>
<style lang="css">
.pass-preview-google {
display: grid;
gap: 1em;
grid-template-columns: repeat(auto-fit, minmax(auto, 360px));
align-items: start;
.pass-container {
border-radius: 1em;
}
.google-main {
--background-color: attr(data-background-color type(<color>));
color: contrast-color(var(--background-color));
}
.logo {
height: 2lh;
aspect-ratio: 1;
border-radius: 50%;
background-color: white;
object-fit: fill;
}
.logo-text {
align-self: center;
font-size: 1.5em;
}
.top-row > .event-name {
font-size: 1.75em;
}
.card-row {
display: flex;
flex-direction: row;
gap: inherit;
> div {
flex: 0 1 100%;
}
&:has(> :first-child:nth-last-child(2)) {
/* twoItems */
> :nth-child(2) {
text-align: end;
}
}
&:has(> :first-child:nth-last-child(3)) {
/* threeItems */
> :nth-child(2) {
text-align: center;
}
> :nth-child(3) {
text-align: end;
}
}
}
.card-row {
.pass-label {
font-size: 0.9em;
}
}
.hero {
align-self: center;
max-width: 100%;
}
.google-pass-details {
.pass-label {
font-weight: bold;
}
.pass-content {
font-size: 0.9em;
white-space: pre-wrap;
}
}
.pass-details-link {
color: black;
}
}
</style>
@@ -1,28 +0,0 @@
<script setup lang="ts">
import { watchEffect } from '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>
@@ -1,29 +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";
import Checkbox from "./input/checkbox.vue";
const props = defineProps<{
field?: Setting;
}>();
const store = inject(StoreKey)!;
</script>
<template lang="pug">
div.form-group
Input(v-if='field.type == "text" || field.type == "float" || field.type == "color"'
:required="field.required" @update:modelValue="(v) => store.setSetting(field.identifier, v)"
:modelValue="store.settings[field.identifier]" :inline="true"
:label="field.label" :type="field.type" :help_text="field.help_text"
:min="field.min" :max="field.max" :defaultVal="field.default")
FileInput(v-if='field.type == "image"' :label="field.label" :required="field.required"
:help_text="field.help_text" @update:modelValue="(v) => store.setSetting(field.identifier, v)"
:modelValue="store.settings[field.identifier]" :default="field.default")
.form-group.row(v-if='field.type == "bool"')
div.col-md-3
div.col-md-9
Checkbox(:required="field.required" @update:modelValue="(v) => store.setSetting(field.identifier, v)"
:modelValue="store.settings[field.identifier]" :label="field.label" :help_text="field.help_text")
</template>
@@ -1,25 +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"
)
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,38 +0,0 @@
export function i18nstringLocalize(s: LocalizableString, locale?: string): string {
if (typeof s === 'string') {
return s
}
if (s === null) {
return null
}
if (!locale) {
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]
}
}
}
export function dateTimeFormat(d: string, options?: Record<string, string>): string {
const locale = document.body.attributes['data-pretixlocale'].value
return new Intl.DateTimeFormat(locale, options).format(new Date(d))
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,111 +0,0 @@
type Platform = {
identifier: string;
name: string;
styles: Styles;
};
type Style = {
identifier: string;
name: string;
fieldgroups: FieldGroupDefinition[];
settings: Setting[];
preview_renderer: string;
preview_data: Record<string, string>;
};
type Placeholder = {
label: string;
sample: string;
required_context: string[];
};
type Styles = Record<string, Style>;
type Placeholders = Record<string, Placeholder>;
type PlaceholderConfig = Record<string, Placeholders>;
type Platforms = Platform[];
type BaseFieldGroupDefinition = {
type: FieldGroupType;
identifier: string;
name: string;
description: string;
required: boolean;
};
type FieldGroupDefinition =
| PlaceholderFieldGroupDefinition
| PredefinedFieldGroupDefinition;
type FieldGroupDisplay = "plain" | "with_label";
type FieldGroupType = "placeholder" | "predefined";
type FieldContentType = "text" | "image";
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
type: "placeholder";
content_type: FieldContentType;
default_entries: FieldEntry[];
min_entries: number | null;
max_entries: number | null;
context_args: string[];
overflows: string[];
default_overflow: string | null;
};
type PredefinedFieldGroupDefinition = BaseFieldGroupDefinition & {
type: "predefined";
default_active: boolean;
};
type LocalizableString = null | string | Record<string, string>;
type PlaceholderFieldEntry = {
type: "placeholder";
label: LocalizableString;
content?: string;
};
type CustomFieldEntry = {
type: "custom";
label?: LocalizableString;
content?: LocalizableString;
};
type FieldEntry = PlaceholderFieldEntry | CustomFieldEntry;
type Setting = {
identifier: string;
label: string;
type: "text" | "image" | "color" | "float" | "bool";
required: boolean;
help_text: string;
default: 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 = {
style: string;
fieldgroups: Record<string, FieldGroupConfig>;
settings: {};
file_settings: Record<string, WalletFile>;
};
type ServerSideFile = { url: string; name: string };
type ClientSideFile = { file: File | null; identifier?: string; url: string };
type WalletFile = ServerSideFile | ClientSideFile;
@@ -1 +0,0 @@
../../../../../../../pretix/static/pretixpresale/widget/src/lib/store.ts
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

@@ -1,30 +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 ?? "{}",
),
placeholders: JSON.parse(
document.querySelector("#placeholders")?.textContent ?? "{}",
),
locales: JSON.parse(document.querySelector("#locales")?.textContent ?? "{}"),
csrfToken: document.querySelector<HTMLInputElement>(
"input[name=csrfmiddlewaretoken]",
)?.value!!,
layoutId: mountEl.dataset.layoutId!,
baseUrl: mountEl.dataset.baseUrl!,
previewUrl: mountEl.dataset.previewUrl!,
});
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,392 +0,0 @@
import { i18nstringLocalize } from "./helpers.js";
import { createStore } from "./lib/store";
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 || fieldgroup.default_active };
} else if (fieldgroup.type == "placeholder") {
return {
overflow: fieldgroup.default_overflow,
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;
placeholders: PlaceholderConfig;
locales: Record<string, string>;
csrfToken: string;
layoutId: string;
baseUrl: string;
previewUrl: string;
}) {
return createStore({
state: () => ({
...config,
loaded: false,
activePlatform: config.platforms[0].identifier,
name: null as string | null,
platformLayouts: {} as Record<string, PlatformLayout>,
}),
getters: {
platform(): 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: Record<string, string | { name: string; url: string }> =
{};
for (const setting of this.style.settings) {
if (
setting.type == "text" ||
setting.type == "color" ||
setting.type == "float" ||
setting.type == "bool"
) {
settings[setting.identifier] =
this.layout.settings[setting.identifier];
} else if (setting.type == "image") {
const val = this.layout.file_settings[setting.identifier];
if (val) {
settings[setting.identifier] = val;
} else {
settings[setting.identifier] = null;
}
}
}
return settings;
},
renderedFieldGroups() {
const content = {};
const placeholderFieldGroups = this.style.fieldgroups.filter(
(x) => x.type === "placeholder",
);
for (const fieldgroup of placeholderFieldGroups) {
content[fieldgroup.identifier] = [];
const layout_group = this.layout.fieldgroups[
fieldgroup.identifier
] as any as PlaceholderFieldGroupConfig;
for (const entry of layout_group.entries) {
let label = i18nstringLocalize(entry.label);
let value = null;
if (entry.type === "placeholder") {
const placeholder =
this.placeholders[fieldgroup.content_type][entry.content];
if (!placeholder) {
continue;
}
if (!label) {
label = i18nstringLocalize(placeholder.label);
}
value = placeholder?.sample;
} else if (entry.type == "custom") {
value = i18nstringLocalize(entry.content);
}
content[fieldgroup.identifier].push({
label,
content: value,
});
}
}
for (const fieldgroup of placeholderFieldGroups) {
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].splice(
fieldgroup.max_entries,
);
if (layout_group.overflow) {
content[layout_group.overflow].unshift(...overflow);
}
}
}
return content;
},
fieldgroups(): Record<string, FieldGroupDefinition> {
return Object.fromEntries(
this.style.fieldgroups.map((x) => [x.identifier, x]),
);
},
},
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(`${this.baseUrl}${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
];
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;
}
}
},
getSettingValue(identifier: string): Setting | { url: string } {
const setting = this.getSetting(identifier);
if (!setting) return;
if (
setting.type == "text" ||
setting.type == "color" ||
setting.type == "float" ||
setting.type == "bool"
) {
return this.layout.settings[setting.identifier] || setting.default;
} else if (setting.type == "image") {
const val = this.layout.file_settings[setting.identifier];
if (val && "url" in val && val.url) {
return val;
} else if (setting.default) {
return { url: setting.default };
}
}
},
setSetting(identifier: string, value: string | File) {
const setting = this.getSetting(identifier);
if (!setting) return;
if (setting.type === "image") {
if (this.layout.file_settings[setting.identifier]?.url) {
URL.revokeObjectURL(
this.layout.file_settings[setting.identifier].url,
);
}
if (value instanceof File) {
this.layout.file_settings[setting.identifier] = {
file: value,
url: value === null ? null : URL.createObjectURL(value),
name: value.name,
};
} else if (value === null) {
this.layout.file_settings[setting.identifier] = {
file: null,
};
}
} else if (
(setting.type == "text" || setting.type == "color") &&
typeof value == "string"
) {
this.layout.settings[setting.identifier] = value;
} else if (setting.type == "float" && typeof value == "string") {
this.layout.settings[setting.identifier] = parseFloat(value);
} else if (setting.type == "float" && typeof value == "number") {
this.layout.settings[setting.identifier] = value;
} else if (setting.type == "bool" && typeof value == "boolean") {
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);
},
serializeFieldgroups(platform, layout: PlatformLayout) {
const styleDefinition = this.getPlatform(platform).styles[layout.style];
return Object.fromEntries(
Object.entries(layout.fieldgroups)
.map(([k, v]) => {
const fieldgroupDefinition = styleDefinition.fieldgroups.find(
(x) => x.identifier == k,
);
if (!fieldgroupDefinition) {
return;
}
if (fieldgroupDefinition.type == "placeholder") {
v = v as PlaceholderFieldGroupConfig;
v = {
...v,
entries: v.entries.filter(
(x) => x.type != "placeholder" || !!x.content,
),
};
}
return [k, v];
})
.filter((x) => !!x),
);
},
async serializePlatformLayout(platform, layout: PlatformLayout) {
const uploadedFiles = Object.fromEntries(
(
await Promise.all(
Object.entries(layout.file_settings).map(async ([k, v]) => {
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: this.serializeFieldgroups(platform, layout),
settings: layout.settings,
},
};
},
async serializeCurrentPlatformLayout() {
return this.serializePlatformLayout(this.activePlatform, this.layout);
},
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) => {
if (!x.ok) {
throw new Error(`Response status: ${x.status}`);
}
return x.json();
})
.then((x) => {
this.parseServerLayout(x);
})
.catch((x) => alert(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,25 +0,0 @@
from pretix.plugins.wallet.styles.base import PassStyle
from .apple import ApplePlatform, AppleWalletEventTicket
from .google import (
GooglePlatform, GoogleWalletCustomEventTicket,
GoogleWalletDefaultEventTicket,
)
AVAILABLE_PLATFORMS = [ApplePlatform, GooglePlatform]
AVAILABLE_STYLES: dict[str, list[type[PassStyle]]] = {
"apple": [AppleWalletEventTicket],
"google": [GoogleWalletDefaultEventTicket, GoogleWalletCustomEventTicket],
}
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"]
-500
View File
@@ -1,500 +0,0 @@
import hashlib
import io
import json
import logging
import zipfile
from typing import Any
import cryptography
import cryptography.hazmat.primitives.hashes
import cryptography.hazmat.primitives.serialization.pkcs7
import cryptography.x509
from django.utils.encoding import force_bytes
from django.utils.translation import gettext as _
from pretix.base.models import OrderPosition
from pretix.plugins.wallet.i18n import (
FormattingLocalizableString,
GettextLocalizableString,
MaybeTranslatedString,
)
from pretix.plugins.wallet.styles.base import (
FieldGroup,
FieldGroupDisplay,
PassStyle,
PlaceholderFieldEntry,
TextFieldGroup,
WalletPlatform,
)
from pretix.plugins.wallet.styles.settings import ImageSettingsField
from .settings import ColorSettingsField, FloatSettingsField, convert_to_png
logger = logging.getLogger()
class ApplePlatform(WalletPlatform):
identifier = "apple"
name = _("Apple")
class StringResource:
entries: dict[str, MaybeTranslatedString]
locales: set[str]
def __init__(self, locales):
self.entries = {}
self.locales = set(locales)
def add_entry(self, key: str, value: MaybeTranslatedString):
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():
if isinstance(entry, str):
output += f'"{self.escape(key)}" = "{self.escape(entry)}";\n'
else:
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 [
ImageSettingsField(
identifier="logo",
label=_("Logo"),
help_text=_("Will be displayed on the top left corner of the pass"),
default="pretixplugins/wallet/logo.png",
),
ImageSettingsField(
identifier="icon",
label=_("Icon"),
help_text=_("Will be displayed as the file icon"),
default="pretixplugins/wallet/icon.png",
),
ImageSettingsField(identifier="background", label=_("Background Image")),
ColorSettingsField(
identifier="bg_color",
label=_("Background Color"),
),
ColorSettingsField(
identifier="fg_color",
label=_("Foreground Color"),
help_text=_(
"This color is ignored if a background image is set. Instead, the text will be displayed in white."
),
default=self.event.settings.primary_color,
),
ColorSettingsField(
identifier="label_color", label=_("Label Color"), default="#000000"
),
FloatSettingsField(
identifier="lat",
label=_("Latitude"),
min=-90,
max=90,
default=self.event.geo_lat,
),
FloatSettingsField(
identifier="long",
label=_("Longitude"),
min=-180,
max=180,
default=self.event.geo_lon,
),
]
def pass_content(self, fields, strings):
raise NotImplementedError()
def generate_pass_json(self, fields, op, strings):
event = op.subevent or op.order.event
tz = event.timezone
ticket = (
FormattingLocalizableString(
"{name} - {variation}", name=op.item.name, variation=op.variation.value
)
if op.variation
else op.item.name
)
description = FormattingLocalizableString(
GettextLocalizableString.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),
}
# TODO: Apples specs says this should be "specified as a CSS-style RGB triple, such as rgb(100, 10, 110).", but hex codes work as well
if bg_color := self.get_cleaned_setting("bg_color"):
pass_json["backgroundColor"] = bg_color
if fg_color := self.get_cleaned_setting("fg_color"):
pass_json["foregroundColor"] = fg_color
if label_color := self.get_cleaned_setting("label_color"):
pass_json["labelColor"] = label_color
if self.get_cleaned_setting("background"):
# iOS ignores the set foregroundColor if a background is set and uses white instead.
# Explicitly set white in this case so the pass looks the same on all platforms
# https://developer.apple.com/forums/thread/809417
pass_json["foregroundColor"] = "#ffffff"
# macOS always shows transparency as black, iOS as backgroundColor.
# Explicitly set black in this case so the pass looks the same on all platforms
pass_json["backgroundColor"] = "#000000"
if "expirationDate" not in pass_json:
if op.valid_until:
pass_json["expirationDate"] = op.valid_until.astimezone(tz).isoformat()
elif event.settings.show_date_to and event.date_to:
pass_json["expirationDate"] = event.date_to.astimezone(tz).isoformat()
if "relevantDates" not in pass_json:
if op.valid_from and op.valid_to:
pass_json["relevantDates"] = [
{
"startDate": op.valid_from.astimezone(tz).isoformat(),
"endDate": op.valid_to.astimezone(tz).isoformat(),
}
]
elif op.valid_from:
pass_json["relevantDates"] = [
{"date": op.valid_from.astimezone(tz).isoformat()}
]
elif event.settings.show_date_to and event.date_to:
pass_json["relevantDates"] = [
{
"startDate": event.date_from.astimezone(tz).isoformat(),
"endDate": event.date_to.astimezone(tz).isoformat(),
}
]
else:
pass_json["relevantDates"] = [
{"date": event.date_from.astimezone(tz).isoformat()}
]
# TODO: also fill relevantDates from program times
if "locations" not in pass_json:
if (lat := self.get_cleaned_setting("lat")) and (
long := self.get_cleaned_setting("long")
):
pass_json["locations"] = {
"latitude": lat,
"longitude": long,
}
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)
if file := self.get_cleaned_setting("logo"):
logo = convert_to_png(
file, (480, 150)
) # TODO: check max_size against apple HIG
if file := self.get_cleaned_setting("icon"):
icon = convert_to_png(
file, max_size=(87, 87)
) # TODO: check max_size against apple HIG
pkpass.add_file("icon.png", icon.read())
pkpass.add_file("logo.png", logo.read())
if file := self.get_cleaned_setting("background"):
background = convert_to_png(
file, max_size=(540, 660)
) # TODO: check max_size against apple HIG
pkpass.add_file("background.png", background.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")
@property
def fieldgroups(self) -> list[FieldGroup]:
return [
TextFieldGroup(
identifier="logo_text",
name=_("Logo text"),
max_entries=1,
display=FieldGroupDisplay.PLAIN,
context_args={"order_position"},
can_overflow=False,
),
TextFieldGroup(
identifier="header",
name=_("Header"),
max_entries=3,
context_args={"order_position"},
default_entries=[
PlaceholderFieldEntry(
content="admission_or_date_from",
label=GettextLocalizableString.gettext("Admission"),
),
],
default_overflow="back",
),
TextFieldGroup(
identifier="primary",
name=_("Primary"),
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderFieldEntry(
content="event",
)
],
description=_("These fields appear prominently featured on the pass."),
required=True,
context_args={"order_position"},
default_overflow="back",
),
TextFieldGroup(
identifier="secondary",
name=_("Secondary"),
max_entries=4,
context_args={"order_position"},
default_entries=[
PlaceholderFieldEntry(
content="item_with_variation",
label=GettextLocalizableString.gettext("Product"),
)
],
default_overflow="back",
),
# 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="auxiliary",
name=_("Auxiliary"),
max_entries=4,
context_args={"order_position"},
default_entries=[
PlaceholderFieldEntry(
content="seat",
label=GettextLocalizableString.gettext("Seat"),
),
PlaceholderFieldEntry(
content="attendee_name",
),
PlaceholderFieldEntry(
content="program_start",
label=GettextLocalizableString.gettext("From"),
),
PlaceholderFieldEntry(
content="program_end",
label=GettextLocalizableString.gettext("To"),
),
],
default_overflow="back",
),
TextFieldGroup(
identifier="code",
name=_("QR-Code"),
max_entries=1,
display=FieldGroupDisplay.PLAIN,
default_entries=[
PlaceholderFieldEntry(
content="secret",
)
],
context_args={"order_position"},
),
TextFieldGroup(
identifier="back",
name=_("Back"),
context_args={"order_position"},
default_entries=[
PlaceholderFieldEntry(
content="admission",
),
PlaceholderFieldEntry(
content="attendee_name",
),
PlaceholderFieldEntry(
content="order_email",
label=GettextLocalizableString.gettext("Ordered by"),
),
PlaceholderFieldEntry(
content="organizer",
),
PlaceholderFieldEntry(
content="organizer_contact",
),
PlaceholderFieldEntry(
content="order_code",
),
PlaceholderFieldEntry(
content="purchase_date",
),
PlaceholderFieldEntry(
content="website",
),
],
),
]
@property
def preview_renderer(self) -> str | None:
return "apple_eventticket"
def convert_fields(self, strings, fields, prefix):
# TODO: detect if a string-value/string-label is the same as a generated field name
# eg if a placeholder returns "primary-0-label", this would not be shown to the user but instead replaces with the label
converted = []
for i, f in enumerate(fields):
converted_field = {**f, "key": f"{prefix}-{i}"}
if "label" in converted_field:
strings.add_entry(f"{prefix}-{i}-label", converted_field["label"])
converted_field["label"] = f"{prefix}-{i}-label"
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
-569
View File
@@ -1,569 +0,0 @@
import enum
from functools import cached_property
from typing import TypedDict
import jsonschema
from django.core.exceptions import ValidationError
from django.core.files import File
from i18nfield.strings import LazyI18nString
from pretix.api.helpers import handle_file_upload
from pretix.base.models import OrderPosition
from ..i18n import MaybeTranslatedString, localizable_to_dict
from ..placeholders import (
WalletPlaceholderRenderer,
get_available_context,
get_wallet_placeholder_renderer,
get_wallet_placeholders,
)
from .settings import SettingsField, SettingsFile
class WalletPlatform:
identifier: str
name: str
class LayoutContext(TypedDict):
placeholders: dict[str, dict]
placeholder_renderer: WalletPlaceholderRenderer
class FieldGroupType(enum.Enum):
PLACEHOLDER = "placeholder"
PREDEFINED = "predefined"
class FieldGroupDisplay(enum.Enum):
PLAIN = "plain"
WITH_LABEL = "with_label"
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,
locales: list[str],
context: LayoutContext,
remaining_fields: list["FieldGroup"],
):
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: MaybeTranslatedString | None
content: T
def __init__(
self,
type: FieldEntryType,
content: T,
label: MaybeTranslatedString | None = None,
):
self.type = type
self.label = label
self.content = content
def asdict(self, locales: list[str]) -> dict:
return {
"type": self.type.value,
"content": self.content,
"label": (
localizable_to_dict(self.label, locales)
if self.label is not None
else None
),
}
class PlaceholderFieldEntry(FieldEntry[str]):
type = FieldEntryType.PLACEHOLDER
content: str
def __init__(self, content: str, label: MaybeTranslatedString | None = None):
self.label = label
self.content = content
class CustomFieldEntry(FieldEntry[MaybeTranslatedString]):
type: FieldEntryType
content: MaybeTranslatedString
def asdict(self, locales: list[str]) -> dict:
return {
"type": self.type.value,
"content": localizable_to_dict(self.content, locales),
"label": localizable_to_dict(self.label, locales) if self.label else None,
}
class PredefinedFieldGroup(FieldGroup):
type = FieldGroupType.PREDEFINED
default_active: bool
def __init__(
self,
identifier: str,
name: str,
description=None,
required=False,
default_active=True,
):
self.identifier = identifier
self.name = name
self.required = required
self.description = description or ""
self.default_active = default_active
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: LayoutContext,
):
return {"type": "object", "properties": {"active": {"type": "boolean"}}}
def asdict(self, *args, **kwargs):
return {
**super().asdict(*args, **kwargs),
"default_active": self.default_active,
}
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
can_overflow: bool
default_overflow: str | None
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,
can_overflow: bool = True,
default_overflow: 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()
self.can_overflow = can_overflow
self.default_overflow = default_overflow
if self.required and (self.min_entries is None or self.min_entries < 1):
self.min_entries = 1
def asdict(
self,
locales: list[str],
context: LayoutContext,
remaining_fields: list[FieldGroup],
):
return {
**super().asdict(locales, context, remaining_fields),
"content_type": self.content_type.value,
"default_entries": [x.asdict(locales) for x in self.default_entries],
"display": self.display.value,
"min_entries": self.min_entries,
"max_entries": self.max_entries,
"context_args": list(
get_available_context(
self.context_args, context["placeholder_renderer"].transformations
)
),
"overflows": self.overflows(
remaining_fields, context, self.available_placeholders(context)
),
"default_overflow": self.default_overflow
}
def available_placeholders(self, context):
content_type_placeholders = (
context["placeholders"].get(self.content_type.value, {}).values()
)
renderer = context["placeholder_renderer"]
available_placeholders = [
x.identifier
for x in content_type_placeholders
if renderer.is_available(x, self.context_args)
]
return available_placeholders
def overflows(self, remaining_fields, context, available_placeholders):
if self.can_overflow:
return [
f.identifier
for f in remaining_fields
if isinstance(f, PlaceholderFieldGroup)
and f.content_type == self.content_type
and f.display == self.display
and set(available_placeholders)
<= set(f.available_placeholders(context))
]
else:
return []
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: LayoutContext,
):
available_placeholders = self.available_placeholders(context)
return {
"type": "object",
"properties": {
"entries": self.entries_schema(placeholders=available_placeholders),
"overflow": {
"anyOf": [
{"type": "null"},
{
"type": "string",
"enum": self.overflows(
remaining_fields, context, available_placeholders
),
},
]
},
},
"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 PassStyle:
identifier: str # unique within platform
name: str
def __init__(
self, event, layout=None, file_settings: dict[str, File] | None = None
):
self.event = event
self.layout = layout
self.file_settings = file_settings or {}
self.placeholders = get_wallet_placeholders(self.event)
@property
def fieldgroups(self) -> list[FieldGroup]:
# 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
return []
@property
def settings(self) -> list[SettingsField]:
return []
@cached_property
def _settings_dict(self):
return {x.identifier: x for x in self.settings}
@property
def preview_renderer(self) -> str | None:
return None
@property
def preview_data(self) -> dict:
return {}
def get_cleaned_setting(
self, setting_identifier: str
) -> SettingsFile | str | float | bool | None:
if not self.layout:
raise ValueError("`layout` needs to be set")
setting = self._settings_dict.get(setting_identifier)
if not setting:
return None
if setting.type == "image":
val = self.file_settings.get(setting.identifier)
else:
val = self.layout.get("settings", {}).get(
setting.identifier, setting.default
)
return setting.clean(val)
def asdict(self):
context = LayoutContext(
placeholders=self.placeholders,
placeholder_renderer=get_wallet_placeholder_renderer(),
)
return {
"identifier": self.identifier,
"name": self.name,
"fieldgroups": [
x.asdict(
self.event.settings.locales,
context,
remaining_fields=self.fieldgroups[i:],
)
for i, x in enumerate(self.fieldgroups, 1)
],
"preview_renderer": self.preview_renderer,
"preview_data": self.preview_data,
"settings": [x.asdict(self.event.settings.locales) for x in self.settings],
}
def layout_schema(self):
context = LayoutContext(
placeholders=self.placeholders,
placeholder_renderer=get_wallet_placeholder_renderer(),
)
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, 1)
},
"required": [
group.identifier for group in self.fieldgroups if group.required
],
},
"settings": {
"type": "object",
"properties": {
setting.identifier: setting.layout_schema()
for setting in self.settings
if setting.layout_schema()
},
"required": [
setting.identifier
for setting in self.settings
if setting.required and setting.layout_schema()
],
},
},
"$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 and setting.type == "text" 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 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,
getattr(request, "auth", None),
{"image/png", "image/jpeg"},
)
elif setting.identifier in file_settings:
res[setting.identifier] = None
return res
def _render_placeholder_fieldgroup_entries(self, group, context):
group_fields = []
if group.identifier not in self.layout["fieldgroups"]:
return group_fields
for field in self.layout["fieldgroups"][group.identifier]["entries"]:
field_entry: dict[str, MaybeTranslatedString | None] = {}
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"] = 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)
return group_fields
def get_pass_fields(self, op: OrderPosition):
if not self.layout:
raise ValueError("`layout` needs to be set")
context = get_wallet_placeholder_renderer(event=self.event, order_position=op)
fields = {}
for group in self.fieldgroups:
if not isinstance(group, PlaceholderFieldGroup):
continue
fields.setdefault(group.identifier, [])
fields[group.identifier] += self._render_placeholder_fieldgroup_entries(
group, context
)
if group.min_entries and len(fields[group.identifier]) < group.min_entries:
raise ValueError(
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(fields[group.identifier])} were provided"
)
if (
overflow_group := self.layout["fieldgroups"]
.get(group.identifier, {})
.get("overflow")
):
fields.setdefault(overflow_group, [])
fields[overflow_group] += fields[group.identifier][group.max_entries :]
fields[group.identifier] = fields[group.identifier][: group.max_entries]
return fields
def group_is_active(self, identifier: str):
if not self.layout:
raise ValueError("`layout` needs to be set")
return self.layout["fieldgroups"].get(identifier, {}).get("active", False)
def generate(self, op: OrderPosition):
raise NotImplementedError()
-468
View File
@@ -1,468 +0,0 @@
import uuid
from urllib.parse import urljoin
from django.utils import translation
from django.utils.formats import date_format
from django.utils.translation import gettext as _
from pretix.base.models import Event, OrderPosition
from pretix.base.settings import GlobalSettingsObject
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.plugins.wallet.i18n import (
FormattingLocalizableString, GettextLocalizableString, LazyLocalizable,
)
from pretix.plugins.wallet.styles.base import (
FieldGroup, FieldGroupDisplay, PassStyle,
PlaceholderFieldEntry, PredefinedFieldGroup, TextFieldGroup,
WalletPlatform,
)
from .google_wallet import (
AnimationType, ConfirmationCodeLabel, DoorsOpenLabel, EventTicketClass,
EventTicketObject, State,
)
from .settings import (
BooleanSettingsField, ColorSettingsField, FloatSettingsField, ImageSettingsField
)
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, op: OrderPosition):
instance_uuid = _get_instance_uuid()
issuer_id = event.settings.get("wallet_google_issuer_id")
return "%s.pretix-%s-%s-%s-%s-%s" % (
issuer_id,
instance_uuid,
event.organizer.slug,
event.slug,
op.item_id,
op.variation_id or 0,
)
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 GoogleWalletEventTicketStyle(PassStyle):
platform = GooglePlatform
def generate(self, op):
self.op = op
class_object = self._generate_class()
ticket_object = self._generate_object(op, class_id=class_object.id)
return class_object, ticket_object
@property
def settings(self):
return [
ImageSettingsField(
identifier="logo",
label=_("Logo"),
default=self.event.settings.logo_image
or "pretixplugins/wallet/logo.png",
),
# TODO: wide logo
ImageSettingsField(
identifier="hero",
label=_("Hero Image"),
),
ColorSettingsField(
identifier="bg_color",
label=_("Background Color"),
default=self.event.settings.primary_color,
),
FloatSettingsField(
identifier="lat",
label=_("Latitude"),
min=-90,
max=90,
default=self.event.geo_lat,
),
FloatSettingsField(
identifier="long",
label=_("Longitude"),
min=-180,
max=180,
default=self.event.geo_lon,
),
BooleanSettingsField(
identifier="shimmer", label=_("Add shimmer animation around barcode")
),
]
@property
def fieldgroups(self) -> list[FieldGroup]:
return [
PredefinedFieldGroup(identifier="venue", name=_("Venue")),
PredefinedFieldGroup(identifier="date", name=_("Date")),
TextFieldGroup(
identifier="code",
name=_("QR-Code"),
max_entries=1,
display=FieldGroupDisplay.PLAIN,
default_entries=[
PlaceholderFieldEntry(
content="secret",
)
],
context_args={"order_position"},
),
]
@property
def preview_data(self):
return {
"event": str(self.event),
"organizer": str(self.event.organizer),
"date_from": self.event.date_from.astimezone(
self.event.timezone
).isoformat(),
"venue": self.venue(),
"event_page": eventreverse_absolute(self.event, "presale:event.index"),
}
def venue(self):
if not str(self.event.location).strip():
return
def _get_name():
lines = str(self.event.location).strip().splitlines()
return lines[0]
def _get_address():
lines = str(self.event.location).splitlines()
# We must provide at least one address line each for the name and address - no way around it.
if len(lines) > 1:
return "\n".join(lines[1:])
else:
return lines[0]
return LazyLocalizable(_get_name), LazyLocalizable(_get_address)
def _generate_class(self):
output_class = EventTicketClass(
self.event.organizer.name,
get_class_id(self.event, self.op),
self.event.name,
self.event.settings.locales,
self.event.settings.locale,
)
output_class.homepage_uri(
eventreverse_absolute(self.event, "presale:event.index"),
GettextLocalizableString.gettext("Website"),
)
# TODO: merchantLocations, locations is deprecated
# if (lat := self.get_cleaned_setting("lat")) and (
# long := self.get_cleaned_setting("long")
# ):
# output_class.locations(lat, long)
output_class.country_code(self.event.settings.locale)
if hero := self.get_cleaned_setting("hero"):
output_class.hero_image(
urljoin(
eventreverse_absolute(self.event, "presale:event.index"), hero.url
),
self.event.name,
)
if logo := self.get_cleaned_setting("logo"):
output_class.logo(
urljoin(
eventreverse_absolute(self.event, "presale:event.index"), logo.url
),
self.event.name,
)
output_class.hex_background_color(self.get_cleaned_setting("bg_color"))
output_class.confirmation_code_label(ConfirmationCodeLabel.ORDER_NUMBER)
if self.group_is_active("venue") and (venue := self.venue()):
output_class.venue(*venue)
if self.get_cleaned_setting("shimmer"):
output_class.security_animation(AnimationType.FOIL_SHIMMER)
if self.group_is_active("date"):
output_class.start(
self.event.date_from.astimezone(self.event.timezone).isoformat()
)
if self.event.settings.show_date_to and self.event.date_to:
output_class.end(
self.event.date_to.astimezone(self.event.timezone).isoformat()
)
if self.event.date_admission:
output_class.doors_open(
self.event.date_admission.astimezone(
self.event.timezone
).isoformat(),
DoorsOpenLabel.DOORS_OPEN,
)
# TODO: callback url
# output_class.callback_url(eventreverse_absolute(event.organizer,"plugins:wallet:google_webhook",))
return output_class
def _generate_object(self, op: OrderPosition, class_id: str):
output_object = EventTicketObject(
get_object_id(op),
class_id,
State.ACTIVE,
self.event.settings.locales,
self.event.settings.locale,
)
fields = self.get_pass_fields(op)
if fields["code"]:
output_object.barcode(fields["code"][0]["value"])
output_object.reservation_info(op.order.code)
output_object.ticket_number(op.code)
output_object.valid_time_interval(op.valid_from, op.valid_until)
output_object.ticket_type(
FormattingLocalizableString(
"{name} - {variation}", name=op.item.name, variation=op.variation.value
)
if op.variation
else op.item.name
)
return output_object
class GoogleWalletDefaultEventTicket(GoogleWalletEventTicketStyle):
identifier = "event"
name = "Default Event Ticket"
@property
def preview_renderer(self):
return "google_eventticket_default"
@property
def fieldgroups(self):
return [
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
PredefinedFieldGroup(identifier="ticket_holder", name=_("Ticket Holder")),
*super().fieldgroups,
]
@property
def _preview_layout_front_card(self):
return [
{
"fieldgroup": "date",
"sample": [
{
"content": date_format(
self.event.date_from.astimezone(self.event.timezone),
"SHORT_DATE_FORMAT",
),
"label": "Date",
},
{
"content": date_format(
self.event.date_from.astimezone(self.event.timezone),
"TIME_FORMAT",
),
"label": "Time",
},
],
},
{
"fieldgroup": "seating",
"sample": [
{"content": _("Ground floor"), "label": _("Section")},
{"content": "5 / 2", "label": _("Row") + " / " + _("Seat")},
],
},
]
@property
def _preview_layout_back_details(self):
venue = self.venue()
if not venue:
venue = _("Sample Conference Center\nHeidelberg, Germany").split("\n", 1)
return [
{
"fieldgroup": "ticket_holder",
"sample": [
{"content": _("John Doe"), "label": _("Ticket Holder")},
],
},
{
"fieldgroup": "venue",
"sample": [
{"content": venue[1], "label": venue[0]},
],
},
{
"fieldgroup": "date",
"sample": [
{
"content": date_format(
self.event.date_from.astimezone(self.event.timezone),
"DATETIME_FORMAT",
),
"label": _("Event Start Time"),
},
],
},
{"content": _("Sample product"), "label": _("Ticket Type")},
{"content": "ABCDE-1", "label": _("Ticket Number")},
{"content": "ABCDE", "label": _("Order Number")},
{
"content": eventreverse_absolute(self.event, "presale:event.index"),
"label": _("Website"),
},
]
def ticket_holder_name(self, op):
return op.attendee_name or (op.addon_to.attendee_name if op.addon_to else None)
def _generate_object(self, op: OrderPosition, class_id: str):
output_object = super()._generate_object(op, class_id)
if self.group_is_active("ticket_holder") and self.ticket_holder_name(op):
output_object.ticket_holder_name(self.ticket_holder_name(op))
if self.group_is_active("seating") and (seat := self.op.seat):
if seat.zone_name:
output_object.section(seat.zone_name)
if seat.row_label:
output_object.row(seat.row_label)
elif seat.row_name:
output_object.row(seat.row_name)
if seat.seat_label:
output_object.seat(seat.seat_label)
elif seat.seat_number:
output_object.seat(seat.seat_number)
return output_object
class GoogleWalletCustomEventTicket(GoogleWalletEventTicketStyle):
identifier = "event_custom"
name = "Custom Event Ticket"
@property
def preview_renderer(self):
return "google_eventticket_custom"
@property
def fieldgroups(self):
return [
*super().fieldgroups,
TextFieldGroup(
identifier="second_row",
name=_("Second Row"),
max_entries=3,
context_args={"order_position"},
),
TextFieldGroup(
identifier="third_row",
name=_("Third Row"),
max_entries=3,
context_args={"order_position"},
),
TextFieldGroup(
identifier="details",
name=_("Details"),
context_args={"order_position"},
),
]
def _generate_class(self):
output_class = super()._generate_class()
output_class.card_template_override(
[
[
"object.textModulesData['second_row-0']"
"object.textModulesData['second_row-1']"
"object.textModulesData['second_row-2']"
],
[
"object.textModulesData['third_row-0']",
"object.textModulesData['third_row-1']",
"object.textModulesData['third_row-2']",
],
]
)
output_class.details_item_infos(
[
f"object.textModulesData['details-{i}']"
for i in range(
len(
self.layout["fieldgroups"].get("details", {}).get("entries", [])
)
)
]
)
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)
for row in ["second_row", "third_row", "details"]:
for i, field in enumerate(fields.get(row, [])):
output_object.add_text_module(
f"{row}-{i}",
field["label"],
field["value"],
)
return output_object
@@ -1,439 +0,0 @@
import enum
import time
from datetime import datetime
from typing import Iterable
from google.auth import crypt, jwt
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account
from pretix.plugins.wallet.i18n import (
MaybeTranslatedString, localizable_to_dict,
)
class ReviewStatus(enum.StrEnum):
APPROVED = "APPROVED"
DRAFT = "DRAFT"
REJECTED = "REJECTED"
UNDER_REVIEW = "UNDER_REVIEW"
REVIEW_STATUS_UNSPECIFIED = "REVIEW_STATUS_UNSPECIFIED"
class ConfirmationCodeLabel(enum.StrEnum):
CONFIRMATION_CODE = "CONFIRMATION_CODE"
CONFIRMATION_NUMBER = "CONFIRMATION_NUMBER"
ORDER_NUMBER = "ORDER_NUMBER"
RESERVATION_NUMBER = "RESERVATION_NUMBER"
class AnimationType(enum.StrEnum):
FOIL_SHIMMER = "FOIL_SHIMMER"
class DoorsOpenLabel(enum.StrEnum):
DOORS_OPEN = "DOORS_OPEN"
GATES_OPEN = "GATES_OPEN"
class State(enum.StrEnum):
ACTIVE = "ACTIVE"
COMPLETED = "COMPLETED"
EXPIRED = "EXPIRED"
INACTIVE = "INACTIVE"
class BarcodeType(enum.StrEnum):
AZTEC = "AZTEC"
CODE_39 = "CODE_39"
CODE_128 = "CODE_128"
CODABAR = "CODABAR"
DATA_MATRIX = "DATA_MATRIX"
EAN_8 = "EAN_8"
EAN_13 = "EAN_13"
EAN13 = "EAN13"
ITF_14 = "ITF_14"
PDF_417 = "PDF_417"
PDF417 = "PDF417"
QR_CODE = "QR_CODE"
UPC_A = "UPC_A"
TEXT_ONLY = "TEXT_ONLY"
class Resource(enum.StrEnum):
EVENT_TICKET_CLASS = "eventTicketClass"
EVENT_TICKET_OBJECT = "eventTicketObject"
def item(field):
return {"firstValue": {"fields": [{"fieldPath": field}]}}
def card_row_template_info(fields: Iterable[str]):
items = [item(field) for field in fields]
if len(items) == 3:
return {
"threeItems": {
"startItem": items[0],
"middleItem": items[1],
"endItem": items[2],
}
}
elif len(items) == 2:
return {
"twoItems": {
"startItem": items[0],
"endItem": items[1],
}
}
else:
return {
"oneItem": {
"startItem": items[0],
}
}
def localized_string(
string: MaybeTranslatedString, locales: set[str], default_locale: str
):
if isinstance(string, str):
return {
"defaultValue": {
"language": default_locale,
"value": string,
}
}
else:
string_dict = localizable_to_dict(string, locales)
default_string = string_dict.pop(default_locale)
return {
"defaultValue": {
"language": default_locale,
"value": default_string,
},
"translatedValues": [
{"language": key, "value": value} for key, value in string_dict.items()
],
}
def localized_uri(
uri: str, description: MaybeTranslatedString, locales: set[str], default_locale: str
):
return {
"uri": uri,
"localizedDescription": localized_string(description, locales, default_locale),
}
class Image(dict):
def __init__(
self,
uri: str,
content_description: MaybeTranslatedString,
locales: set[str],
default_locale: str,
):
self["uri"] = uri
self["contentDescription"] = localized_string(
content_description, locales, default_locale
)
class BaseGoogleWalletObject:
resource: Resource
id: str
data: dict
locales: set[str]
default_locale: str
def _localized_string(self, string: MaybeTranslatedString):
return localized_string(string, self.locales, self.default_locale)
def _localized_uri(
self,
uri: str,
description: MaybeTranslatedString,
):
return localized_uri(uri, description, self.locales, self.default_locale)
def _image(self, uri: str, content_description: MaybeTranslatedString):
return {
"sourceUri": {"uri": uri},
"contentDescription": self._localized_string(content_description),
}
def serialize(self, mini=True):
if mini:
return {"id": self.id}
else:
return self.data
class EventTicketClass(BaseGoogleWalletObject):
resource = Resource.EVENT_TICKET_CLASS
def __init__(
self,
issuer_name: str,
class_id: str,
event_name: MaybeTranslatedString,
locales: set[str],
default_locale: str,
):
self.id = class_id
self.default_locale = default_locale
self.locales = locales
self.data = {
"id": class_id,
"issuerName": issuer_name,
"eventName": localized_string(
event_name, self.locales, self.default_locale
),
"reviewStatus": ReviewStatus.UNDER_REVIEW,
}
def homepage_uri(
self,
uri: str,
description: MaybeTranslatedString,
):
self.data["homepageUri"] = self._localized_uri(uri, description)
def country_code(self, code: str):
self.data["countryCode"] = code
def hero_image(self, uri: str, content_description: MaybeTranslatedString):
self.data["heroImage"] = self._image(uri, content_description)
def logo(self, uri: str, content_description: MaybeTranslatedString):
self.data["logo"] = self._image(uri, content_description)
def wide_logo(self, uri: str, content_description: MaybeTranslatedString):
self.data["wideLogo"] = self._image(uri, content_description)
def hex_background_color(self, hex_background_color: str):
self.data["hexBackgroundColor"] = hex_background_color
def confirmation_code_label(self, label: ConfirmationCodeLabel):
self.data["confirmationCodeLabel"] = label
def venue(self, name: MaybeTranslatedString, address: MaybeTranslatedString):
self.data["venue"] = {
"name": self._localized_string(name),
"address": self._localized_string(address),
}
def security_animation(self, security_animation: AnimationType):
self.data["securityAnimation"] = security_animation
def doors_open(
self,
doors_open: str,
doors_open_label: None | DoorsOpenLabel | MaybeTranslatedString = None,
):
self.data.setdefault("dateTime", {})
self.data["dateTime"]["doorsOpen"] = doors_open
if doors_open_label and isinstance(doors_open_label, DoorsOpenLabel):
self.data["dateTime"]["doorsOpenLabel"] = doors_open_label
elif doors_open_label:
self.data["dateTime"]["customDoorsOpenLabel"] = self._localized_string(
doors_open_label
)
def start(self, start):
self.data.setdefault("dateTime", {})
self.data["dateTime"]["start"] = start
def end(self, end):
self.data.setdefault("dateTime", {})
self.data["dateTime"]["end"] = end
def callback_url(self, callback_url: str):
self.data["callbackOptions"] = {"url": callback_url}
def card_template_override(self, overrides: list[list[str]]):
self.data.setdefault("classTemplateInfo", {})
self.data["classTemplateInfo"].setdefault("cardTemplateOverride", {})
self.data["classTemplateInfo"]["cardTemplateOverride"][
"cardRowTemplateInfos"
] = [card_row_template_info(x) for x in overrides]
def details_item_infos(self, fields: list[str]):
self.data.setdefault("classTemplateInfo", {})
self.data["classTemplateInfo"].setdefault("detailsTemplateOverride", {})
self.data["classTemplateInfo"]["detailsTemplateOverride"][
"detailsItemInfos"
] = [{"item": item(field)} for field in fields]
def add_text_module(
self, id: str, header: MaybeTranslatedString, body: MaybeTranslatedString
):
self.data.setdefault("textModulesData", [])
self.data["textModulesData"].append(
{
"id": id,
"localizedHeader": self._localized_string(header),
"localizedBody": self._localized_string(body),
}
)
class EventTicketObject(BaseGoogleWalletObject):
resource = Resource.EVENT_TICKET_OBJECT
def __init__(
self,
object_id: str,
class_id: str,
state: State,
locales: set[str],
default_locale: str,
):
self.id = object_id
self.locales = locales
self.default_locale = default_locale
self.data = {
"id": object_id,
"classId": class_id,
"state": state,
}
def barcode(
self,
value: str,
alternate_text: str | None = None,
type: BarcodeType = BarcodeType.QR_CODE,
):
self.data["barcode"] = {
"type": type,
"value": value,
}
if alternate_text:
self.data["barcode"]["alternateText"] = alternate_text
def reservation_info(self, confirmation_code: str):
self.data["reservationInfo"] = {
"confirmationCode": confirmation_code,
}
def ticket_number(self, ticket_number: str):
self.data["ticket_number"] = ticket_number
def valid_time_interval(self, start: datetime | None, end: datetime | None):
self.data.setdefault("validTimeInterval", {})
if start:
self.data["validTimeInterval"]["start"] = {"date": start.isoformat()}
if end:
self.data["validTimeInterval"]["end"] = {"date": end.isoformat()}
def ticket_type(self, ticket_type: MaybeTranslatedString):
self.data["ticketType"] = self._localized_string(ticket_type)
def ticket_holder_name(self, name):
self.data["ticketHolderName"] = name
def seat(self, seat):
self.data.setdefault("seatInfo", {})
self.data["seatInfo"]["seat"] = self._localized_string(seat)
def row(self, row):
self.data.setdefault("seatInfo", {})
self.data["seatInfo"]["row"] = self._localized_string(row)
def section(self, section):
self.data.setdefault("seatInfo", {})
self.data["seatInfo"]["section"] = self._localized_string(section)
def add_text_module(
self, id: str, header: MaybeTranslatedString, body: MaybeTranslatedString
):
self.data.setdefault("textModulesData", [])
self.data["textModulesData"].append(
{
"id": id,
"localizedHeader": self._localized_string(header),
"localizedBody": self._localized_string(body),
}
)
class ButtonJWT(dict):
def __init__(
self,
issuer,
origins,
payload: dict[str, list[BaseGoogleWalletObject]],
precreated=True,
):
serialized_payload = {}
for k, v in payload.items():
serialized_payload[k] = [x.serialize(mini=precreated) for x in v]
self.payload = serialized_payload
self.issuer = issuer
self.origins = origins
def serialize(self):
return {
"iss": self.issuer,
"aud": "google",
"typ": "savetowallet",
"iat": int(time.time()),
"payload": self.payload,
"origins": self.origins,
}
class Comms:
def __init__(self, service_account_info: dict):
credentials = service_account.Credentials.from_service_account_info(
service_account_info,
scopes=["https://www.googleapis.com/auth/wallet_object.issuer"],
)
self._session = AuthorizedSession(credentials)
self._signer = crypt.RSASigner.from_service_account_info(service_account_info)
self.client_email = service_account_info.get("client_email")
def get_item(self, resource: Resource, resource_id: str):
result = self._session.get(
f"https://walletobjects.googleapis.com/walletobjects/v1/{resource}/{resource_id}"
)
if result.status_code == 200:
return result.json()
elif result.status_code == 404:
return False
def put_item(self, resource: Resource, resource_id: str, payload: dict):
item = self.get_item(resource, payload["id"])
if item is None:
raise Exception("API Error")
elif not item:
result = self._session.post(
f"https://walletobjects.googleapis.com/walletobjects/v1/{resource}?strict=true",
json=payload,
)
else:
result = self._session.put(
f"https://walletobjects.googleapis.com/walletobjects/v1/{resource}/{payload['id']}?strict=true",
json=payload,
)
print(result.text)
result.raise_for_status()
return result.json()
def sign_jwt(self, payload):
encoded = jwt.encode(self._signer, payload)
if not encoded:
return False
return encoded.decode("utf-8")
@@ -1,240 +0,0 @@
import tempfile
from typing import Any, Callable, Literal, Protocol, runtime_checkable
from django.conf import settings
from django.contrib.staticfiles import finders
from django.core.files import File
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.models.fields.files import FieldFile
from django.forms import ValidationError
from django.templatetags.static import static
from django.utils.translation import gettext as _
SettingType = Literal["image", "text", "float", "color", "bool"]
JsonSerializable = int | bool | float | str | None
class SettingsField[T, I = T]:
identifier: str
label: str
type: SettingType
help_text: str | None
required: bool
default: Any | None
def asdict(self, locales: list[str]):
return {
"identifier": self.identifier,
"label": self.label,
"type": self.type,
"help_text": self.help_text,
"required": self.required,
"default": self.get_default(),
}
def layout_schema(self) -> dict[str, Any] | None:
raise NotImplementedError()
def get_default(self) -> JsonSerializable:
if isinstance(self.default, JsonSerializable):
return self.default
elif self.default:
raise NotImplementedError()
def clean(self, value: I | None) -> T | None:
if value:
return value
if self.default:
return self.default
class TextSettingsField(SettingsField[str]):
def __init__(
self,
identifier: str,
label: str,
help_text: str | None = None,
required: bool = False,
default: str | None = None,
):
self.identifier = identifier
self.label = label
self.type = "text"
self.help_text = help_text
self.required = required
self.default = default
def layout_schema(self) -> dict[str, Any] | None:
schema = {"type": "string"}
if not self.required:
schema = {"oneOf": [schema, {"type": "null"}]}
return schema
class ColorSettingsField(SettingsField[str]):
def __init__(
self,
identifier: str,
label: str,
help_text: str | None = None,
required: bool = False,
default: str | None = None,
):
self.identifier = identifier
self.label = label
self.type = "color"
self.help_text = help_text
self.required = required
self.default = default
def layout_schema(self) -> dict[str, Any] | None:
# TODO: validate that '#' + 6 hex chars
schema = {"type": "string"}
if not self.required:
schema = {"oneOf": [schema, {"type": "null"}]}
return schema
class FloatSettingsField(SettingsField[float]):
min: float | None
max: float | None
def __init__(
self,
identifier: str,
label: str,
help_text=None,
required: bool = False,
min: float | None = None,
max: float | None = None,
default: float | None = None,
):
self.type = "float"
self.identifier = identifier
self.label = label
self.help_text = help_text
self.required = required
self.min = min
self.max = max
self.default = default
def layout_schema(self):
schema: dict[str, Any] = {"type": "number"}
if self.min is not None:
schema["minimum"] = self.min
if self.max is not None:
schema["maximum"] = self.max
if not self.required:
schema = {"oneOf": [schema, {"type": "null"}]}
return schema
def asdict(self, locales: list[str]):
return super().asdict(locales) | {"min": self.min, "max": self.max}
class BooleanSettingsField(SettingsField[bool]):
def __init__(
self,
identifier: str,
label: str,
help_text: str | None = None,
default: bool = False,
):
self.type = "bool"
self.identifier = identifier
self.label = label
self.help_text = help_text
self.required = False
self.default = default
def layout_schema(self):
return {"type": "boolean"}
@runtime_checkable
class UrlAndReadFile(Protocol):
url: str
open: Callable
seek: Callable
read: Callable
class SettingsFile:
url: str
file: UrlAndReadFile
def __init__(self, url, file):
self.url = url
self.file = file
class ImageSettingsField(SettingsField[SettingsFile, FieldFile]):
default: str | UrlAndReadFile | None = None
def __init__(
self,
identifier: str,
label: str,
help_text: str | None = None,
default: str | UrlAndReadFile | None = None,
):
self.identifier = identifier
self.label = label
self.type = "image"
self.help_text = help_text
self.required = False
if isinstance(default, UrlAndReadFile):
self.default = default
elif isinstance(default, str):
if not finders.find(default):
raise ValueError(f"Unknown file: {default}")
self.default = default
elif default:
raise ValueError("Unsupported `default`")
def get_default(self):
if isinstance(self.default, str):
return static(self.default)
elif isinstance(self.default, File):
return self.default.url
def layout_schema(self):
return
def clean(self, value):
if value:
return SettingsFile(value.url, value)
if isinstance(self.default, UrlAndReadFile):
return SettingsFile(self.default.url, self.default)
if isinstance(self.default, str):
return SettingsFile(static(self.default), File(None, finders.find(self.default)))
def convert_to_png(file: SettingsFile, max_size=None):
# TODO: move validation to upload
try:
from PIL import Image, ImageOps
except ImportError:
return file
file.file.open("rb")
file.file.seek(0)
try:
with (
Image.open(file.file, formats=settings.PILLOW_FORMATS_IMAGE) as im,
tempfile.NamedTemporaryFile("rb", suffix=".png") as tmpfile,
):
im = ImageOps.exif_transpose(im)
if max_size:
im.thumbnail(max_size, resample=Image.Resampling.BICUBIC)
im.save(tmpfile.name)
tmpfile.seek(0)
return SimpleUploadedFile("picture.png", tmpfile.read(), "image/png")
except IOError:
raise ValidationError(
_("The file you uploaded could not be converted to PNG format.")
)
@@ -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,24 +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" }}
{{ placeholders|json_script:"placeholders" }}
{{ locales|json_script:"locales" }}
<div id="editor" data-layout-id="{{ object.pk }}"
data-base-url="{% url 'api-v1:walletlayout-list' organizer=request.organizer.slug event=request.event.slug %}"
data-preview-url="{% url 'plugins:wallet:preview' organizer=request.organizer.slug event=request.event.slug %}"></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)
-309
View File
@@ -1,309 +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 json
import logging
import os
import tempfile
import zipfile
from collections import OrderedDict
from functools import cached_property
from django import forms
from django.conf import settings
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.utils.translation import gettext as _
from pretix.base.forms import SecretKeySettingsField
from pretix.base.models import Event, Order
from pretix.base.settings import SettingsSandbox
from pretix.base.ticketoutput import BaseTicketOutput
from pretix.control.forms import ClearableBasenameFileInput
from pretix.plugins.wallet.styles.base import WalletPlatform
from .forms import CertificateFileField, validate_rsa_privkey
from .models import GoogleWalletInstance, WalletPlatformLayout
from .styles.apple import ApplePlatform
from .styles.google import GooglePlatform
from .styles.google_wallet import ButtonJWT, Comms, BaseGoogleWalletObject
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 get_layout(self, op):
if hasattr(op.item, "walletlayout"):
wallet_layout = op.item.walletlayout.layout
else:
wallet_layout = op.event.wallet_layouts.get(default=True)
platform_layout: WalletPlatformLayout = get_object_or_404(
wallet_layout.platform_layouts, platform=self.platform.identifier
)
return platform_layout.pass_layout
def generate(self, position):
return self.get_layout(position).generate(position)
def dedup(list):
new = []
for i in list:
if not any(x.data == i.data for x in new):
new.append(i)
return new
class GoogleWalletTicketOutput(WalletOutput):
identifier = "wallet_google"
verbose_name = _("Google")
download_button_text = "Add to Google Wallet"
platform = GooglePlatform
is_cacheable = False
@property
def confirmation_text(self) -> str | None:
return _(
"Please be aware, that contrary to other virtual wallets/passes (like Apple Wallet), Google Wallet Passes are not "
"handled offline. Every pass that is created, has to be transmitted to Google Inc.\n"
"\n"
'By clicking "Confirm" below, we will transfer some of your personal information, which is '
"necessary to provide you with your Google Wallet Pass, to Google Inc.\n"
"\n"
"Please be aware, that there is no way to delete the data, once it has been transmitted.\n"
)
@classmethod
def get_global_settings(cls, **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,
),
),
]
)
@cached_property
def comms(self):
return Comms(
json.loads(self.event.settings.get("wallet_google_credentials").read())
)
def put_item_cached(self, object: BaseGoogleWalletObject):
with transaction.atomic():
instance, created = GoogleWalletInstance.objects.get_or_create(
event=self.event,
resource=object.resource,
identifier=object.id,
defaults={"data": object.data},
)
if created or instance.data != object.data:
result = self.comms.put_item(object.resource, object.id, object.data)
instance.data = object.data
instance.save(update_fields=["data"])
return result
def generate(self, position):
class_object, ticket_object = self.get_layout(position).generate(position)
self.put_item_cached(class_object)
self.put_item_cached(ticket_object)
generated_jwt = self.comms.sign_jwt(
ButtonJWT(
origins=[settings.SITE_URL],
issuer=self.comms.client_email,
payload={"eventTicketObjects": [ticket_object]},
).serialize()
)
return (
"googlepaypass",
"text/uri-list",
"https://pay.google.com/gp/v/save/%s" % generated_jwt,
)
def generate_order(self, order: Order):
class_objects = []
ticket_objects = []
for position in self.get_tickets_to_print(order):
class_object, ticket_object = self.get_layout(position).generate(position)
class_objects.append(class_object)
ticket_objects.append(ticket_object)
for co in dedup(class_objects):
self.put_item_cached(co)
for to in dedup(ticket_objects):
self.put_item_cached(to)
generated_jwt = self.comms.sign_jwt(
ButtonJWT(
origins=[settings.SITE_URL],
issuer=self.comms.client_email,
payload={"eventTicketObjects": ticket_objects},
).serialize()
)
return (
"googlepaypass",
"text/uri-list",
"https://pay.google.com/gp/v/save/%s" % generated_jwt,
)
class AppleWalletTicketOutput(WalletOutput):
identifier = "wallet_apple"
verbose_name = _("Apple")
download_button_text = "Add to Apple Wallet"
platform = ApplePlatform
multi_download_enabled = True
@classmethod
def get_global_settings(cls, **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",
SecretKeySettingsField(
label=_("Apple Wallet Pass key password"),
required=False,
help_text=_(
"Optional, only necessary if the key entered above requires a password to use."
),
),
),
]
)
def generate_order(self, order: Order):
with tempfile.NamedTemporaryFile() as d:
with zipfile.ZipFile(d, 'w') as zipf:
for pos in self.get_tickets_to_print(order):
fname, __, content = self.generate(pos)
zipf.writestr('{}-{}{}'.format(
order.code, pos.positionid, os.path.splitext(fname)[1]
), content)
d.seek(0)
return '{}-{}.pkpasses'.format(order.code, self.identifier), 'application/vnd.apple.pkpasses', d.read()
OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput]
-65
View File
@@ -1,65 +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 .api import WalletLayoutViewSet
from .views import (
LayoutCreateView, LayoutDelete, LayoutEditorView, LayoutListView,
LayoutPreviewView, LayoutSetDefault,
)
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>[^/]+)/$",
LayoutSetDefault.as_view(),
name="default",
),
re_path(
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/delete/(?P<layout>[^/]+)/$",
LayoutDelete.as_view(),
name="delete",
),
]
event_router.register("walletlayouts", WalletLayoutViewSet)
-270
View File
@@ -1,270 +0,0 @@
import json
from typing import Any
from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import BadRequest
from django.db import transaction
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views.generic import (
CreateView, DeleteView, DetailView, ListView, View,
)
from pretix_vrpayment_wero.payment import HttpRequest
from pretix.base.i18n import language
from pretix.base.middleware import add_to_response_csp
from pretix.base.services.tickets import get_preview_position
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.helpers.database import rolledback_transaction
from pretix.helpers.models import modelclone
from pretix.plugins.wallet.i18n import localizable_to_dict
from .models import WalletLayout
from .placeholders import (
get_wallet_placeholder_renderer, get_wallet_placeholders,
)
from .styles import (
AVAILABLE_PLATFORMS, AVAILABLE_STYLES, AVAILABLE_STYLES_DICT,
)
def get_editor_placeholders(event):
with (
rolledback_transaction(),
language(event.settings.locale, event.settings.region),
):
p = get_preview_position(event)
context = get_wallet_placeholder_renderer(order_position=p)
placeholders = {
t: {
pid: {
"label": (localizable_to_dict(p.label, event.settings.locales)),
"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["placeholders"] = 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")
file_settings = json.loads(request.POST.get("file_settings"))
base_layout = (
event.wallet_layouts.get(pk=request.POST.get("id"))
.platform_layouts.filter(platform=platform_id)
.first()
)
if base_layout:
base_file_settings = {
fs.key: fs.file for fs in base_layout.file_settings.all()
}
else:
base_file_settings = {}
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)
file_settings = {
**base_file_settings,
**l.extract_file_settings(request, file_settings),
}
layout = style(event, layout, file_settings)
layout.validate()
fname, mimet, data = layout.generate(p)
if mimet == "text/uri-list":
return redirect(data)
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())
@@ -222,11 +222,10 @@
{% if line in tickets_with_download %}
{% for b in download_buttons %}
<form action="{% if position_page and line.addon_to %}{% eventurl event "presale:event.order.position.download" secret=line.addon_to.web_secret order=order.code output=b.identifier pid=line.pk position=line.addon_to.positionid %}{% elif position_page %}{% eventurl event "presale:event.order.position.download" secret=line.web_secret order=order.code output=b.identifier pid=line.pk position=line.positionid %}{% else %}{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.pk %}{% endif %}"
method="post" data-asynctask data-asynctask-download class="download-btn-form{% if b.javascript_required or b.confirmation_text %} requirejs{% endif %}">
method="post" data-asynctask data-asynctask-download class="download-btn-form{% if b.javascript_required %} requirejs{% endif %}">
{% csrf_token %}
<button type="submit"
class="btn btn-sm {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}"
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
class="btn btn-sm {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
<span class="fa {{ b.icon }}" aria-hidden="true"></span> {{ b.text }}
</button>
</form>
@@ -1,6 +1,5 @@
{% load i18n %}
{% load eventurl %}
{% load rich_text %}
{% if download_email_required %}
<div class="panel panel-primary">
<div class="panel-heading">
@@ -44,11 +43,10 @@
{% for b in download_buttons %}
{% if b.multi %}
<form action="{% eventurl event "presale:event.order.download.combined" secret=order.secret order=order.code output=b.identifier %}"
method="post" data-asynctask data-asynctask-download class="download-btn-form {% if b.javascript_required or b.confirmation_text %}requirejs{% endif %}">
method="post" data-asynctask data-asynctask-download class="download-btn-form">
{% csrf_token %}
<button type="submit"
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}"
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
<span class="fa {{ b.icon }}" aria-hidden="true"></span> {{ b.multi_text }}
</button>
</form>
@@ -64,12 +62,11 @@
<div>
{% for b in download_buttons %}
<form action="{% if position_page and tickets_with_download.0.addon_to %}{% eventurl event "presale:event.order.position.download" secret=tickets_with_download.0.addon_to.web_secret order=order.code output=b.identifier pid=tickets_with_download.0.pk position=tickets_with_download.0.addon_to.positionid %}{% elif position_page %}{% eventurl event "presale:event.order.position.download" secret=tickets_with_download.0.web_secret order=order.code output=b.identifier pid=tickets_with_download.0.pk position=tickets_with_download.0.positionid %}{% else %}{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=tickets_with_download.0.pk %}{% endif %}"
method="post" data-asynctask data-asynctask-download
class="download-btn-form {% if b.javascript_required or b.confirmation_text %}requirejs{% endif %}">
method="post" data-asynctask data-asynctask-download
class="download-btn-form{% if b.javascript_required %} requirejs{% endif %}">
{% csrf_token %}
<button type="submit"
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}"
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
<span class="fa {{ b.icon }}" aria-hidden="true"></span> {{ b.long_text }}
</button>
</form>
@@ -147,14 +147,3 @@
{% dialog "dialog-nothing-to-add" label_nothing_to_add description_nothing_to_add icon="exclamation-circle" %}
<p class="modal-card-confirm"><button class="btn btn-primary">{% trans "OK" %}</button></p>
{% enddialog %}
{% trans "Confirmation required" as confirmation_title %}
{% dialog "confirmationmodal" confirmation_title "" icon="shield" %}
<div class="modal-card-confirm modal-card-confirm-spread">
<button type="button" class="btn btn-default btn-lg" commandfor="confirmationmodal" command="close">{% trans "Cancel" %}</button>
<button type="button" id="confirmationmodal-confirm"
class="btn btn-primary btn-lg">
{% trans "Confirm" %}
</button>
</div>
{% enddialog %}
+15 -41
View File
@@ -43,8 +43,6 @@ import re
from collections import Counter, OrderedDict, defaultdict
from decimal import Decimal
from urllib.parse import quote, urlencode
from datetime import timedelta
from uuid import UUID
from django import forms
from django.conf import settings
@@ -64,14 +62,12 @@ from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.generic import ListView, TemplateView, View
from pretixeu.views import reverse
from pretix.base.auth import has_event_access_permission
from pretix.base.models import (
CachedTicket, Checkin, GiftCard, Invoice, Order, OrderPosition, Quota,
TaxRule,
)
from pretix.base.models.base import CachedFile
from pretix.base.models.orders import (
CachedCombinedTicket, InvoiceAddress, OrderFee, OrderPayment, OrderRefund,
QuestionAnswer,
@@ -287,8 +283,7 @@ class TicketPageMixin:
'icon': provider.download_button_icon or 'fa-download',
'identifier': provider.identifier,
'multi': provider.multi_download_enabled,
'javascript_required': provider.javascript_required,
'confirmation_text': provider.confirmation_text,
'javascript_required': provider.javascript_required
})
return buttons
@@ -1187,6 +1182,9 @@ class AnswerDownload(EventViewMixin, OrderDetailMixin, View):
class OrderDownloadMixin:
def get_success_url(self, value):
return self.get_self_url()
@cached_property
def output(self):
responses = register_ticket_outputs.send(self.request.event)
@@ -1220,33 +1218,12 @@ class OrderDownloadMixin:
):
return self.error(OrderError(_('Please click the link we sent you via email to download your tickets.')))
if self.output.is_cacheable and (ct := self.get_last_ct()):
ct = self.get_last_ct()
if ct:
return self.success(ct)
if not self.output.is_cacheable:
cf = CachedFile(web_download=True, date=now(), expires=now() + timedelta(hours=2))
cf.bind_to_session(request)
cf.save()
else:
cf = None
return self.do('orderposition' if 'position' in kwargs else 'order',
self.order_position.pk if 'position' in kwargs else self.order.pk,
self.output.identifier, cf.pk if cf else None)
def get_success_url(self, value):
if isinstance(value, UUID):
cf = CachedFile.objects.filter(pk=value).first()
else:
cf = None
if not cf or not cf.allowed_for_session(self.request):
return self.get_self_url()
if cf.type == 'text/uri-list':
return cf.file.file.read().decode()
else:
return reverse('cachedfile.download', kwargs={"id":str(value)})
self.output.identifier)
def get_success_message(self, value):
return ""
@@ -1285,20 +1262,17 @@ class OrderDownloadMixin:
content_type=value.type
)
else:
return redirect(self.get_success_url(value))
return redirect(self.get_self_url())
def get_last_ct(self):
if self.output.is_cacheable:
if 'position' in self.kwargs:
ct = CachedTicket.objects.filter(
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
).last()
else:
ct = CachedCombinedTicket.objects.filter(
order=self.order, provider=self.output.identifier, file__isnull=False
).last()
if 'position' in self.kwargs:
ct = CachedTicket.objects.filter(
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
).last()
else:
ct = None
ct = CachedCombinedTicket.objects.filter(
order=self.order, provider=self.output.identifier, file__isnull=False
).last()
if not ct or not ct.file:
return None
return ct
@@ -283,18 +283,6 @@ $(function () {
return false
})
$('#loadingmodal').prop('closedBy', 'none')
document.querySelectorAll('button[data-confirmation-text]').forEach((element) => {
element.addEventListener('click', (evt) => {
evt.preventDefault();
const confirmationModal = document.getElementById("confirmationmodal");
const onConfirm = () => { confirmationModal.close(); $(evt.target.form).trigger("submit"); }
confirmationModal.querySelector("#confirmationmodal-description").innerHTML = element.dataset.confirmationText
confirmationModal.querySelector("#confirmationmodal-confirm").addEventListener('click', onConfirm, {'once': true})
confirmationModal.addEventListener('close', () => confirmationModal.removeEventListener('click', onConfirm))
confirmationModal.showModal()
})
})
})
var waitingDialog = {
@@ -20,10 +20,10 @@ function pgettext (context, msgid) {
return msgid
}
function interpolate (fmt, object, named) {
function interpolate (fmt, obj, named) {
if (named) {
return fmt.replace(/%\(\w+\)s/g, function (match) { return String(object[match.slice(2, -2)]) })
return fmt.replace(/%\(\w+\)s/g, function (match) { return String(obj[match.slice(2, -2)]) })
} else {
return fmt.replace(/%s/g, function (match) { return String(object.shift()) })
return fmt.replace(/%s/g, function (match) { return String(obj.shift()) })
}
}
@@ -53,9 +53,6 @@
.download-desktop {
display: block;
width: percentage((4 / $grid-columns));
& .btn {
margin-bottom: 5px;
}
}
.product {
width: percentage((4 / $grid-columns));
@@ -208,6 +205,7 @@
word-break: break-all;
display: block;
width: 100%;
margin-bottom: 5px;
}
}
+93
View File
@@ -1070,6 +1070,99 @@ def test_event_update_live_free_product(token_client, organizer, event, free_ite
assert resp.status_code == 200
@pytest.mark.django_db
def test_event_update_live_required_meta_data_missing(token_client, organizer, event, free_quota):
organizer.meta_properties.create(name="required_prop", required=True)
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"live": True
},
format='json'
)
assert resp.status_code == 400
assert list(resp.data.keys()) == ['live']
assert len(resp.data['live']) == 1
assert '<a' not in resp.data['live'][0]
assert 'required_prop' in resp.data['live'][0]
event.refresh_from_db()
assert not event.live
@pytest.mark.django_db
def test_event_update_live_required_meta_data_set_in_same_request(token_client, organizer, event, free_quota):
organizer.meta_properties.create(name="required_prop", required=True)
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"live": True,
"meta_data": {
"type": "Conference",
"required_prop": "set",
}
},
format='json'
)
assert resp.status_code == 200
assert resp.data['live'] is True
event.refresh_from_db()
assert event.live
with scopes_disabled():
assert event.meta_values.filter(property__name="required_prop", value="set").exists()
@pytest.mark.django_db
def test_live_event_update_removing_required_meta_data(token_client, organizer, event, free_quota):
prop = organizer.meta_properties.create(name="required_prop", required=True)
with scopes_disabled():
event.meta_values.create(property=prop, value="set")
event.live = True
event.save()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
"type": "Conference",
}
},
format='json'
)
assert resp.status_code == 400
assert list(resp.data.keys()) == ['live']
assert 'required_prop' in resp.data['live'][0]
event.refresh_from_db()
assert event.live
with scopes_disabled():
assert event.meta_values.filter(property__name="required_prop", value="set").exists()
@pytest.mark.django_db
def test_live_event_update_removing_required_meta_data_while_going_offline(token_client, organizer, event, free_quota):
prop = organizer.meta_properties.create(name="required_prop", required=True)
with scopes_disabled():
event.meta_values.create(property=prop, value="set")
event.live = True
event.save()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"live": False,
"meta_data": {
"type": "Conference",
}
},
format='json'
)
assert resp.status_code == 200
event.refresh_from_db()
assert not event.live
with scopes_disabled():
assert not event.meta_values.filter(property__name="required_prop").exists()
@pytest.mark.django_db
def test_event_update_plugins(token_client, organizer, event, free_item, free_quota):
resp = token_client.patch(
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more