mirror of
https://github.com/pretix/pretix.git
synced 2026-11-13 01:47:07 +00:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f88fd5878 | ||
|
|
4fa1b01e3a | ||
|
|
e1cf77365a | ||
|
|
382843dfdb | ||
|
|
942251abe5 | ||
|
|
a55ac639cf | ||
|
|
723c184dd6 | ||
|
|
88d46f265c | ||
|
|
973c6631ae | ||
|
|
f54b41bc24 | ||
|
|
664ecacc4b | ||
|
|
574eff5b82 | ||
|
|
a7f62d6201 | ||
|
|
fb582173e6 | ||
|
|
9816588c6a | ||
|
|
f537731073 | ||
|
|
1046a4179d | ||
|
|
0d824049ce | ||
|
|
9ae3ec9a87 | ||
|
|
93155c6d92 | ||
|
|
b3e40e9536 | ||
|
|
cd4da853da | ||
|
|
aa03e47009 | ||
|
|
fae07bfc47 | ||
|
|
d2b6affe8e | ||
|
|
f95b97d570 | ||
|
|
c9368672f1 | ||
|
|
c9b040b15b | ||
|
|
076fbc10be | ||
|
|
f4ecd9885f | ||
|
|
491a9ad22b | ||
|
|
f018d32804 | ||
|
|
0f1f492397 | ||
|
|
21d44d15e3 | ||
|
|
f834ea8b86 | ||
|
|
1b3e954368 | ||
|
|
3e8c1dba85 | ||
|
|
2c1c792aa3 | ||
|
|
6874727dbc | ||
|
|
651c0daada | ||
|
|
23531419e4 | ||
|
|
46a05cab74 | ||
|
|
43de592766 | ||
|
|
d73f688bc4 | ||
|
|
dd6bde0d93 | ||
|
|
8c947bd515 | ||
|
|
18f2260712 | ||
|
|
b7b70e8f88 | ||
|
|
9a6d45f625 | ||
|
|
f2e7e6a8af | ||
|
|
ba1f2c3deb | ||
|
|
e4585aceb5 | ||
|
|
7f97992650 | ||
|
|
87c9b2337b | ||
|
|
439b6fe205 | ||
|
|
59bb4102f4 | ||
|
|
7819612bc2 | ||
|
|
995357c3c5 | ||
|
|
b435ed6369 | ||
|
|
6aae1ace8a | ||
|
|
29c9620812 | ||
|
|
935f8d195c | ||
|
|
d222434d50 | ||
|
|
66024bd1a6 | ||
|
|
81323a0250 | ||
|
|
24dc3fc217 | ||
|
|
a35cad3546 | ||
|
|
623b524bc3 | ||
|
|
916418bef3 | ||
|
|
c98be87cbe | ||
|
|
b3afdd866a | ||
|
|
c96c6d8184 | ||
|
|
b08ca1a410 | ||
|
|
d01018a108 | ||
|
|
c14673e8d4 | ||
|
|
f80a3a61bc | ||
|
|
557b520e7c | ||
|
|
da146097d5 | ||
|
|
046590cdf9 | ||
|
|
6b33fc2e31 | ||
|
|
2ab9298a09 | ||
|
|
81a6929627 | ||
|
|
53e09bc3c0 | ||
|
|
dda8ffaabf | ||
|
|
015d5a42a7 | ||
|
|
7d939e0f96 | ||
|
|
ce12943e50 | ||
|
|
ec1afe4fbf | ||
|
|
a66edc7853 | ||
|
|
bb6d3629ca |
No files matched your search
@@ -7,6 +7,7 @@ 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'
|
||||
@@ -16,6 +17,7 @@ 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'
|
||||
|
||||
@@ -40,7 +40,7 @@ we generally use a custom ``form_valid`` method on our ``FormView`` that looks l
|
||||
def form_valid(self, form):
|
||||
if form.has_changed():
|
||||
self.request.event.log_action('pretix.event.changed', user=self.request.user, data={
|
||||
k: getattr(self.request.event, k) for k in form.changed_data
|
||||
k: form.cleaned_data.get(k) for k in form.changed_data
|
||||
})
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return super().form_valid(form)
|
||||
|
||||
@@ -44,7 +44,6 @@ const ignores = globalIgnores([
|
||||
'src/pretix/static/pretixbase/js/asynctask.js',
|
||||
'src/pretix/static/pretixbase/js/details.js',
|
||||
'src/pretix/static/pretixbase/js/gettextstub.js',
|
||||
'src/pretix/static/pretixbase/js/i18nstring.js',
|
||||
'src/pretix/static/pretixcontrol/js/menu.js',
|
||||
'src/pretix/static/pretixcontrol/js/ui/editor.js',
|
||||
'src/pretix/static/pretixcontrol/js/ui/geo.js',
|
||||
|
||||
+2
-2
@@ -18,12 +18,12 @@
|
||||
"doc": "doc"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:control": "vite",
|
||||
"dev:control": "vite --clearScreen=false",
|
||||
"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",
|
||||
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin src/pretix/plugins/wallet",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -60,6 +60,8 @@ dependencies = [
|
||||
"djangorestframework>=3.18.3",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==5.*",
|
||||
"google-auth=2.62.*",
|
||||
"httpx==0.28.*",
|
||||
"isoweek",
|
||||
"jsonschema",
|
||||
"kombu==5.6.*",
|
||||
|
||||
@@ -31,6 +31,7 @@ ignore =
|
||||
src/tests/plugins/stripe/*
|
||||
src/tests/plugins/sendmail/*
|
||||
src/tests/plugins/ticketoutputpdf/*
|
||||
src/tests/plugins/wallet/*
|
||||
.*
|
||||
CODE_OF_CONDUCT.md
|
||||
CONTRIBUTING.md
|
||||
|
||||
@@ -66,6 +66,7 @@ INSTALLED_APPS = [
|
||||
'pretix.plugins.returnurl',
|
||||
'pretix.plugins.autocheckin',
|
||||
'pretix.plugins.webcheckin',
|
||||
'pretix.plugins.wallet',
|
||||
'django_countries',
|
||||
'oauth2_provider',
|
||||
'phonenumber_field',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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
|
||||
@@ -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 (
|
||||
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.orders import PrintLog
|
||||
@@ -76,6 +76,7 @@ from pretix.base.services.checkin import (
|
||||
from pretix.base.services.media import perform_media_exchange
|
||||
from pretix.base.signals import checkin_annulled
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.api.helpers import handle_file_upload
|
||||
|
||||
with scopes_disabled():
|
||||
class CheckinListFilter(FilterSet):
|
||||
@@ -329,35 +330,6 @@ 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:
|
||||
@@ -802,7 +774,12 @@ 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)
|
||||
given_answers[q] = handle_file_upload(
|
||||
answers_data[str(q.pk)],
|
||||
user,
|
||||
auth,
|
||||
allowed_types=('image/png', 'image/jpeg', 'image/gif', 'application/pdf')
|
||||
)
|
||||
else:
|
||||
given_answers[q] = None
|
||||
else:
|
||||
|
||||
@@ -653,8 +653,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
request.event, op, force_invalidate=True, save=True
|
||||
)
|
||||
order.save(update_fields=['secret'])
|
||||
CachedTicket.objects.filter(order_position__order=order).delete()
|
||||
CachedCombinedTicket.objects.filter(order=order).delete()
|
||||
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk,
|
||||
'order': order.pk})
|
||||
order.log_action(
|
||||
|
||||
@@ -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 das de_Informal
|
||||
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such as 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)
|
||||
|
||||
@@ -25,7 +25,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import Country
|
||||
|
||||
from pretix.base.models import Invoice
|
||||
from pretix.base.signals import EventPluginRegistry, Registry
|
||||
from pretix.base.signals import PluginAwareRegistry, Registry
|
||||
|
||||
|
||||
class TransmissionType:
|
||||
@@ -220,7 +220,7 @@ class TransmissionProvider:
|
||||
return None
|
||||
|
||||
|
||||
class TransmissionProviderRegistry(EventPluginRegistry):
|
||||
class TransmissionProviderRegistry(PluginAwareRegistry):
|
||||
def __init__(self):
|
||||
super().__init__({
|
||||
'identifier': lambda o: getattr(o, 'identifier'),
|
||||
|
||||
@@ -21,21 +21,29 @@
|
||||
#
|
||||
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, CachedTicket, Event, InvoiceAddress, Order,
|
||||
OrderPosition,
|
||||
)
|
||||
from pretix.base.services.tasks import EventTask, ProfiledTask
|
||||
from pretix.base.services.tasks import (
|
||||
ProfiledTask, TransactionAwareProfiledEventTask,
|
||||
)
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.base.signals import (
|
||||
invalidate_ticket_cache, register_ticket_outputs,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
|
||||
@@ -52,6 +60,7 @@ def generate_orderposition(order_position: int, provider: str):
|
||||
if prov.identifier == provider:
|
||||
filename, ttype, data = prov.generate(order_position)
|
||||
path, ext = os.path.splitext(filename)
|
||||
|
||||
for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider):
|
||||
ct.delete()
|
||||
ct = CachedTicket.objects.create(order_position=order_position, provider=provider,
|
||||
@@ -70,6 +79,7 @@ def generate_order(order: int, provider: str):
|
||||
if prov.identifier == provider:
|
||||
filename, ttype, data = prov.generate_order(order)
|
||||
path, ext = os.path.splitext(filename)
|
||||
|
||||
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
|
||||
ct.delete()
|
||||
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext,
|
||||
@@ -91,35 +101,55 @@ class DummyRollbackException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def preview(event: int, provider: str):
|
||||
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 = {}):
|
||||
event = Event.objects.get(id=event)
|
||||
|
||||
with rolledback_transaction(), language(event.settings.locale, event.settings.region):
|
||||
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"))
|
||||
p = get_preview_position(event)
|
||||
|
||||
responses = register_ticket_outputs.send(event)
|
||||
for receiver, response in responses:
|
||||
prov = response(event)
|
||||
if prov.identifier == provider:
|
||||
return prov.generate(p)
|
||||
return prov.generate(p, **provider_arguments)
|
||||
|
||||
|
||||
def get_tickets_for_order(order, base_position=None):
|
||||
@@ -206,8 +236,8 @@ def get_tickets_for_order(order, base_position=None):
|
||||
return tickets
|
||||
|
||||
|
||||
@app.task(base=EventTask, acks_late=True)
|
||||
def invalidate_cache(event: Event, item: int=None, provider: str=None, order: int=None, **kwargs):
|
||||
@app.task(base=TransactionAwareProfiledEventTask, acks_late=True)
|
||||
def invalidate_cache(event: Event, item: int | None=None, provider: str | None=None, order: int | None=None, **kwargs):
|
||||
qs = CachedTicket.objects.filter(order_position__order__event=event)
|
||||
qsc = CachedCombinedTicket.objects.filter(order__event=event)
|
||||
|
||||
@@ -226,3 +256,46 @@ def invalidate_cache(event: Event, item: int=None, provider: str=None, order: in
|
||||
ct.delete()
|
||||
for ct in qsc:
|
||||
ct.delete()
|
||||
|
||||
invalidate_ticket_cache.send(event, item=item, provider=provider, order=order)
|
||||
|
||||
|
||||
def get_download_buttons(event, show_disabled):
|
||||
buttons = []
|
||||
|
||||
responses = register_ticket_outputs.send(event)
|
||||
for receiver, response in responses:
|
||||
provider = response(event)
|
||||
if provider.is_meta:
|
||||
continue
|
||||
if not provider.is_enabled and not show_disabled:
|
||||
continue
|
||||
buttons.append({
|
||||
'text': provider.download_button_text or 'Download',
|
||||
'multi_text': provider.multi_download_button_text or 'Download',
|
||||
'long_text': provider.long_download_button_text or 'Download',
|
||||
'icon': provider.download_button_icon or 'fa-download',
|
||||
'identifier': provider.identifier,
|
||||
'multi': provider.multi_download_enabled,
|
||||
'javascript_required': provider.javascript_required,
|
||||
})
|
||||
return buttons
|
||||
|
||||
|
||||
def get_wallet_buttons(event: Event, positions: list[OrderPosition], show_disabled: bool):
|
||||
from pretix.plugins.wallet.platforms import get_available_platforms
|
||||
platforms = list(get_available_platforms(event, show_disabled))
|
||||
|
||||
buttons: dict[str | OrderPosition, list] = {"combined": []}
|
||||
for platform in platforms:
|
||||
if any(platform.is_available_for_position(pos) for pos in positions):
|
||||
attr, cont = platform.render_download_button()
|
||||
buttons["combined"].append({"identifier": platform.identifier, "attributes": attr, "content": cont})
|
||||
|
||||
for position in positions:
|
||||
buttons[position] = []
|
||||
for platform in platforms:
|
||||
if platform.is_available_for_position(position):
|
||||
attr, cont = platform.render_download_button()
|
||||
buttons[position].append({"identifier": platform.identifier, "attributes": attr, "content": cont})
|
||||
return buttons
|
||||
+39
-16
@@ -34,7 +34,7 @@
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
from typing import Any, Callable, Generic, List, Tuple, TypeVar
|
||||
from typing import Any, Callable, Generic, Iterable, List, Tuple, TypeVar
|
||||
|
||||
import django.dispatch
|
||||
from django.apps import apps
|
||||
@@ -440,7 +440,8 @@ class Registry:
|
||||
meta = {k: accessor(obj) for k, accessor in self.keys.items()}
|
||||
tup = (obj, meta)
|
||||
for key, value in meta.items():
|
||||
self.by_key[key][value] = tup
|
||||
self.by_key[key].setdefault(value, [])
|
||||
self.by_key[key][value].append(tup)
|
||||
self.registered_entries[obj] = meta
|
||||
|
||||
if len(objs) == 1:
|
||||
@@ -465,16 +466,33 @@ class Registry:
|
||||
return clz
|
||||
return reg
|
||||
|
||||
def get(self, **kwargs):
|
||||
(key, value), = kwargs.items()
|
||||
return self.by_key.get(key).get(value, (None, None))
|
||||
def get(self, **kwargs) -> tuple[Any, dict[str, Any]] | tuple[None, None]:
|
||||
result = list(self.filter(**kwargs))
|
||||
|
||||
def filter(self, **kwargs):
|
||||
return (
|
||||
(entry, meta)
|
||||
for entry, meta in self.registered_entries.items()
|
||||
if all(value == meta[key] for key, value in kwargs.items())
|
||||
)
|
||||
if len(result) == 1:
|
||||
return result[0]
|
||||
elif len(result) >= 1:
|
||||
raise RuntimeError(f"get() returned more than one -- it returned {len(result)}!")
|
||||
return (None, None)
|
||||
|
||||
def filter(self, **kwargs) -> Iterable[tuple[Any, dict[str, Any]]]:
|
||||
for key in kwargs.keys():
|
||||
if key not in self.keys:
|
||||
raise ValueError(f"Unknown key: {key!r}")
|
||||
|
||||
if len(kwargs) == 1:
|
||||
# fast route: lookup using `self.by_key`
|
||||
(key, value), = kwargs.items()
|
||||
return self.by_key.get(key, {}).get(value, [])
|
||||
else:
|
||||
return (
|
||||
(entry, meta)
|
||||
for entry, meta in self.registered_entries.items()
|
||||
if all(value == meta[key] for key, value in kwargs.items())
|
||||
)
|
||||
|
||||
def all(self):
|
||||
return self.registered_entries.keys()
|
||||
|
||||
|
||||
class PluginAwareRegistry(Registry):
|
||||
@@ -522,11 +540,7 @@ class PluginAwareRegistry(Registry):
|
||||
return result
|
||||
|
||||
def get(self, active_in=None, **kwargs):
|
||||
item, meta = super().get(**kwargs)
|
||||
if meta and active_in is not None:
|
||||
if not is_app_active(active_in, meta['plugin']):
|
||||
return None, None
|
||||
return item, meta
|
||||
return super().get(active_in=active_in, **kwargs)
|
||||
|
||||
|
||||
EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
|
||||
@@ -1218,3 +1232,12 @@ This signal is sent out each time the information for a Device is modified.
|
||||
Both the original and updated versions of the Device are included to allow
|
||||
receivers to see what has been updated.
|
||||
"""
|
||||
|
||||
invalidate_ticket_cache = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``item: int | None``, ``provider: str | None``, ``order: int | None``
|
||||
|
||||
This signal is sent out when the ticket cache is invalidated.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
{% for key, value in attributes.items %}{% if key != "class" %}{% if value is not False %} {{ key }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endif %}{% endfor %}
|
||||
@@ -36,10 +36,10 @@ register = template.Library()
|
||||
|
||||
|
||||
@register.filter("money")
|
||||
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False):
|
||||
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False) -> str:
|
||||
if isinstance(value, (float, int, str)):
|
||||
if value == '':
|
||||
return value
|
||||
return value # pyright: ignore[reportReturnType]
|
||||
value = Decimal(value)
|
||||
if value is None:
|
||||
value = Decimal('0.00')
|
||||
|
||||
@@ -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]:
|
||||
def generate_order(self, order: Order) -> Tuple[str, str, str | bytes]:
|
||||
"""
|
||||
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 ``order.positions_with_tickets`` which applies the
|
||||
If you want, you can just iterate over ``self.get_tickets_to_print`` which applies the
|
||||
appropriate filters for you.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
@@ -192,6 +192,17 @@ 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:
|
||||
"""
|
||||
|
||||
@@ -57,8 +57,8 @@ RE_ASYNC_ID = re.compile(r"^[a-zA-Z0-9\-]+$")
|
||||
|
||||
|
||||
class AsyncMixin:
|
||||
success_url = None
|
||||
error_url = None
|
||||
success_url: str | None = None
|
||||
error_url: str | None = None
|
||||
known_errortypes = []
|
||||
|
||||
def get_success_url(self, value):
|
||||
|
||||
@@ -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 payment method is enabled.
|
||||
errors are only raised if the provider is enabled.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -481,5 +481,15 @@
|
||||
<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>
|
||||
@@ -29,11 +29,13 @@
|
||||
{% for provider in providers %}
|
||||
<div class="panel panel-default ticketoutput-panel">
|
||||
<div class="panel-heading">
|
||||
<a href="{% url "control:event.settings.tickets.preview" event=request.event.slug organizer=request.organizer.slug output=provider.identifier %}"
|
||||
class="btn btn-default btn-sm pull-right flip {% if not provider.evaluated_preview_allowed %}disabled{% endif %}"
|
||||
target="_blank">
|
||||
{% trans "Preview" %}
|
||||
</a>
|
||||
{% if not provider.is_meta %}
|
||||
<a href="{% url "control:event.settings.tickets.preview" event=request.event.slug organizer=request.organizer.slug output=provider.identifier %}"
|
||||
class="btn btn-default btn-sm pull-right flip {% if not provider.evaluated_preview_allowed %}disabled{% endif %}"
|
||||
target="_blank">
|
||||
{% trans "Preview" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<h3 class="panel-title">
|
||||
{% with 'ticketoutput_'|add:provider.identifier|add:'__enabled' as field_name %}
|
||||
{% bootstrap_field provider.form|getitem:field_name layout='inline' label=provider.verbose_name %}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{% load l10n %}
|
||||
{% load phone_format %}
|
||||
{% load getitem %}
|
||||
{% load dialog %}
|
||||
{% block title %}
|
||||
{% blocktrans trimmed with code=order.code %}
|
||||
Order details: {{ code }}
|
||||
@@ -573,6 +574,19 @@
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% for b in wallet_buttons|getitem:line %}
|
||||
{% comment %} TODO(wallet): move view to core {% endcomment %}
|
||||
<form action="{% url "plugins:wallet:event.order.wallet.get" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk platform=b.identifier %}"
|
||||
method="post" data-asynctask data-asynctask-download
|
||||
class="form-inline helper-display-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-xs btn-default {{ b.attributes.class }}"
|
||||
{% include "pretixbase/fragments/attributes.html" with attributes=b.attributes %}>
|
||||
{{ b.content }}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<button type="submit" data-toggle="qrcode" data-qrcode="{{ line.secret }}"
|
||||
class="btn btn-xs btn-default">
|
||||
|
||||
@@ -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'):
|
||||
if provider.identifier == self.kwargs.get('output') and not provider.is_meta:
|
||||
return provider
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
@@ -1085,6 +1085,11 @@ 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,
|
||||
@@ -1094,17 +1099,17 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
|
||||
provider.form.fields = OrderedDict(
|
||||
[
|
||||
('ticketoutput_%s_%s' % (provider.identifier, k), v)
|
||||
for k, v in provider.settings_form_fields.items()
|
||||
for k, v in provider_settings_fields.items()
|
||||
]
|
||||
)
|
||||
provider.settings_content = provider.settings_content_render(self.request)
|
||||
provider.settings_content = provider_settings_content
|
||||
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_form_fields.items():
|
||||
for k, v in provider_settings_fields.items():
|
||||
if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)):
|
||||
provider.evaluated_preview_allowed = False
|
||||
break
|
||||
|
||||
@@ -1540,10 +1540,11 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
|
||||
}
|
||||
for f in self.plugin_forms:
|
||||
change_data.update({
|
||||
k: (f.cleaned_data.get(k).name
|
||||
f"{f.prefix}-{k}": (
|
||||
f.cleaned_data.get(k).name
|
||||
if isinstance(f.cleaned_data.get(k), File)
|
||||
else f.cleaned_data.get(k))
|
||||
for k in f.changed_data
|
||||
else f.cleaned_data.get(k)
|
||||
) for k in f.changed_data
|
||||
})
|
||||
|
||||
meta_changed = {}
|
||||
|
||||
@@ -110,7 +110,7 @@ from pretix.base.services.stats import order_overview
|
||||
from pretix.base.services.tax import (
|
||||
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
|
||||
)
|
||||
from pretix.base.services.tickets import generate
|
||||
from pretix.base.services.tickets import generate, get_download_buttons, get_wallet_buttons
|
||||
from pretix.base.signals import order_modified, register_ticket_outputs
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
@@ -544,7 +544,8 @@ class OrderDetail(OrderView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['items'] = self.get_items()
|
||||
items = self.get_items()
|
||||
ctx['items'] = items
|
||||
ctx['has_cancellation_fee'] = any(f.fee_type == OrderFee.FEE_TYPE_CANCELLATION for f in ctx['items']['fees'])
|
||||
ctx['event'] = self.request.event
|
||||
ctx['payments'] = self.order.payments.order_by('-created')
|
||||
@@ -566,7 +567,8 @@ class OrderDetail(OrderView):
|
||||
|
||||
pending_sum = self.order.pending_sum
|
||||
ctx['overpaid'] = pending_sum * -1
|
||||
ctx['download_buttons'] = self.download_buttons
|
||||
ctx['download_buttons'] = get_download_buttons(self.request.event, show_disabled=True)
|
||||
ctx['wallet_buttons'] = get_wallet_buttons(self.request.event, items['positions'], show_disabled=True)
|
||||
ctx['payment_refund_sum'] = self.order.payment_refund_sum
|
||||
ctx['pending_sum'] = pending_sum
|
||||
ctx['uncancelled_invoice'] = self.order.invoices.exclude(
|
||||
@@ -575,22 +577,6 @@ class OrderDetail(OrderView):
|
||||
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
def download_buttons(self):
|
||||
buttons = []
|
||||
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
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
|
||||
})
|
||||
return buttons
|
||||
|
||||
def get_items(self):
|
||||
queryset = self.object.all_positions
|
||||
|
||||
@@ -706,9 +692,9 @@ 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.'))
|
||||
|
||||
ct = self.get_last_ct()
|
||||
if ct:
|
||||
if ct := self.get_last_ct():
|
||||
return self.success(ct)
|
||||
|
||||
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)
|
||||
@@ -738,7 +724,7 @@ class OrderDownload(AsyncAction, OrderView):
|
||||
content_type=value.type
|
||||
)
|
||||
else:
|
||||
return redirect(self.get_self_url())
|
||||
return redirect(self.get_success_url(value))
|
||||
|
||||
def get_last_ct(self):
|
||||
ct = CachedTicket.objects.filter(
|
||||
|
||||
@@ -21,11 +21,13 @@
|
||||
#
|
||||
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)
|
||||
@@ -47,6 +49,13 @@ 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):
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.services import tickets
|
||||
|
||||
from .models import TicketLayout, TicketLayoutItem
|
||||
|
||||
|
||||
@@ -65,5 +63,3 @@ class TicketLayoutItemForm(forms.ModelForm):
|
||||
return
|
||||
else:
|
||||
return super().save(commit=commit)
|
||||
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk, 'provider': 'pdf',
|
||||
'item': self.instance.item_id})
|
||||
@@ -0,0 +1,21 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
@@ -0,0 +1,131 @@
|
||||
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 pretix.base.services import tickets
|
||||
|
||||
from .models import WalletLayout, WalletPlatformLayout
|
||||
from .platforms import wallet_platform_registry, pass_style_registry
|
||||
|
||||
|
||||
class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
|
||||
platform = serializers.ChoiceField(choices=[])
|
||||
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 get_fields(self):
|
||||
fields = super().get_fields()
|
||||
fields["platform"].choices = [
|
||||
p.identifier for p, _ in wallet_platform_registry.filter(active_in=self.context['event'])
|
||||
]
|
||||
return fields
|
||||
|
||||
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:
|
||||
style, _meta = pass_style_registry.get(active_in=self.context['event'], identifier=(platform, style))
|
||||
if not style:
|
||||
raise ValidationError({"style": _("Invalid 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 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=serializer.to_representation(serializer.instance),
|
||||
)
|
||||
tickets.invalidate_cache.apply_async(
|
||||
kwargs={"event": self.request.event.pk, "provider": "wallet"}
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
#
|
||||
# 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
|
||||
from .platforms import apple # NOQA
|
||||
from .platforms import google # NOQA
|
||||
|
||||
def installed(self, event):
|
||||
from pretix.plugins.wallet.platforms import wallet_platform_registry, pass_style_registry
|
||||
|
||||
wallet_layout, created = event.wallet_layouts.get_or_create(
|
||||
default=True,
|
||||
defaults={
|
||||
'name': _('Default layout'),
|
||||
}
|
||||
)
|
||||
if created:
|
||||
for platform, _meta in wallet_platform_registry.filter(active_in=event):
|
||||
if not platform.default_style:
|
||||
continue
|
||||
style, _meta = pass_style_registry.get(active_in=event, platform=platform.identifier, identifier=platform.default_style)
|
||||
if not style:
|
||||
continue
|
||||
layout = style(event).default_layout
|
||||
wallet_layout.platform_layouts.get_or_create(platform=platform.identifier, defaults={"style": style.identifier, "layout": layout})
|
||||
@@ -0,0 +1,119 @@
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from django import forms
|
||||
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 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):
|
||||
self.instance.event = self.event
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
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):
|
||||
self.event = kwargs.pop("event")
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["layout"].queryset = self.event.wallet_layouts.all()
|
||||
|
||||
def save(self, commit=True):
|
||||
if self.has_changed():
|
||||
if self.cleaned_data["layout"] is None:
|
||||
if self.instance.pk:
|
||||
self.instance.delete()
|
||||
else:
|
||||
self.instance.layout = self.cleaned_data["layout"]
|
||||
super().save(commit=commit)
|
||||
return self.instance
|
||||
@@ -0,0 +1,77 @@
|
||||
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: ... # noqa: E704
|
||||
|
||||
|
||||
# marked Promise so DjangoJSONEncoder automatically transforms it into a string
|
||||
class LocalizableBase(Promise):
|
||||
"""
|
||||
Base for classes conforming to `Localizable` by implementing a language-aware __str__.
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,235 @@
|
||||
# Generated by Django 5.2.17 on 2026-11-09 16:47
|
||||
|
||||
import django.db.models.deletion
|
||||
import pretix.base.models.base
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"pretixbase",
|
||||
"0311_fix_unshredded_invoices",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AppleWalletDevice",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("device_library_id", models.CharField()),
|
||||
("push_token", models.CharField()),
|
||||
("last_push", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CombinedAppleWalletPass",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("file", models.FileField(max_length=255, null=True, upload_to="")),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"order",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="pretixbase.order",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WalletLayout",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=190)),
|
||||
("default", models.BooleanField(default=False)),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="wallet_layouts",
|
||||
to="pretixbase.event",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-default", "name"),
|
||||
},
|
||||
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.OneToOneField(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="walletlayout",
|
||||
to="pretixbase.item",
|
||||
),
|
||||
),
|
||||
(
|
||||
"layout",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="item_assignments",
|
||||
to="wallet.walletlayout",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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",
|
||||
),
|
||||
),
|
||||
],
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AppleWalletPass",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("serial_number", models.CharField()),
|
||||
("pass_type", models.CharField()),
|
||||
("manifest", models.CharField()),
|
||||
("content", models.FileField(upload_to="")),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"order_position",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="pretixbase.orderposition",
|
||||
),
|
||||
),
|
||||
(
|
||||
"registered_devices",
|
||||
models.ManyToManyField(
|
||||
related_name="passes", to="wallet.applewalletdevice"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("pass_type", "serial_number")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GoogleWalletInstance",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("resource", models.CharField()),
|
||||
("identifier", models.CharField()),
|
||||
("data", models.JSONField()),
|
||||
("usages", models.IntegerField(default=0)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="pretixbase.event",
|
||||
),
|
||||
),
|
||||
(
|
||||
"order_position",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="pretixbase.orderposition",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("resource", "identifier")},
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="walletlayout",
|
||||
constraint=models.UniqueConstraint(
|
||||
models.F("event"),
|
||||
condition=models.Q(("default", True)),
|
||||
name="one_default_wallet_per_event",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="walletplatformlayout",
|
||||
unique_together={("parent", "platform")},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="applewalletdevice",
|
||||
name="device_library_id",
|
||||
field=models.CharField(unique=True),
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,208 @@
|
||||
#
|
||||
# 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.crypto import salted_hmac, get_random_string, constant_time_compare
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.models import LoggedModel, OrderPosition
|
||||
from pretix.base.models.orders import Order
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def allow_delete(self):
|
||||
return not self.default
|
||||
|
||||
|
||||
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 .platforms import pass_style_registry
|
||||
|
||||
style, _meta = pass_style_registry.get(active_in=self.parent.event, identifier=(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 ValueError(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()
|
||||
|
||||
|
||||
class AppleWalletDevice(models.Model):
|
||||
device_library_id = models.CharField(unique=True)
|
||||
push_token = models.CharField()
|
||||
last_push = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class AppleWalletPassQuerySet(models.QuerySet):
|
||||
def get_by_auth_token(self, received_message):
|
||||
dummy = get_random_string(16)
|
||||
pk, received_token = received_message.split(":")
|
||||
|
||||
try:
|
||||
pass_obj = self.get(order_position__pk=pk)
|
||||
except AppleWalletPass.DoesNotExist:
|
||||
# Do a hash comparison as well to harden against timing attacks
|
||||
dummy_hash = salted_hmac(
|
||||
key_salt="pretix.plugins.wallet.AppleWalletPass.auth_token",
|
||||
algorithm="sha256",
|
||||
value=str(12345),
|
||||
secret=dummy,
|
||||
).hexdigest()
|
||||
constant_time_compare(
|
||||
dummy_hash,
|
||||
received_token,
|
||||
)
|
||||
raise AppleWalletPass.DoesNotExist
|
||||
|
||||
if constant_time_compare(
|
||||
pass_obj.auth_token,
|
||||
received_message,
|
||||
):
|
||||
return pass_obj
|
||||
else:
|
||||
raise AppleWalletPass.DoesNotExist
|
||||
|
||||
|
||||
class AppleWalletPass(models.Model):
|
||||
order_position = models.OneToOneField(OrderPosition, on_delete=models.PROTECT)
|
||||
|
||||
serial_number = models.CharField()
|
||||
pass_type = models.CharField()
|
||||
manifest = models.CharField()
|
||||
content = models.FileField()
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
registered_devices = models.ManyToManyField(
|
||||
AppleWalletDevice, related_name="passes"
|
||||
)
|
||||
|
||||
objects = AppleWalletPassQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
unique_together = [
|
||||
("pass_type", "serial_number"),
|
||||
]
|
||||
|
||||
@property
|
||||
def auth_token(self):
|
||||
return (
|
||||
str(self.order_position.pk)
|
||||
+ ":"
|
||||
+ salted_hmac(
|
||||
key_salt="pretix.plugins.wallet.AppleWalletPass.auth_token",
|
||||
algorithm="sha256",
|
||||
secret=self.order_position.web_secret,
|
||||
value=str(self.order_position.pk),
|
||||
).hexdigest()
|
||||
)
|
||||
|
||||
|
||||
class CombinedAppleWalletPass(models.Model):
|
||||
order = models.OneToOneField(Order, on_delete=models.CASCADE)
|
||||
file = models.FileField(null=True, blank=True, max_length=255)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class GoogleWalletInstance(models.Model):
|
||||
event = models.ForeignKey("pretixbase.Event", on_delete=models.CASCADE)
|
||||
order_position = models.ForeignKey(
|
||||
OrderPosition, on_delete=models.CASCADE, null=True
|
||||
)
|
||||
resource = models.CharField()
|
||||
identifier = models.CharField()
|
||||
data = models.JSONField()
|
||||
usages = models.IntegerField(default=0)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("resource", "identifier"),)
|
||||
@@ -0,0 +1,14 @@
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,302 @@
|
||||
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))
|
||||
@@ -0,0 +1,314 @@
|
||||
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
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Iterable
|
||||
from pretix.base.models import Event
|
||||
|
||||
from .base import (
|
||||
PassStyle,
|
||||
WalletPlatform,
|
||||
wallet_platform_registry,
|
||||
pass_style_registry,
|
||||
)
|
||||
|
||||
|
||||
def get_available_platforms(
|
||||
event: Event, show_disabled: bool = False
|
||||
) -> Iterable[WalletPlatform]:
|
||||
for p, _ in wallet_platform_registry.filter(active_in=event):
|
||||
platform = p(event)
|
||||
if platform.is_available() and (show_disabled or platform.is_enabled()):
|
||||
yield platform
|
||||
|
||||
|
||||
def get_platform(event: Event, platform: str, show_disabled: bool) -> WalletPlatform | None:
|
||||
for p in get_available_platforms(event, show_disabled):
|
||||
if p.identifier == platform:
|
||||
return p
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_available_platforms",
|
||||
"get_platform",
|
||||
"pass_style_registry",
|
||||
"PassStyle",
|
||||
"wallet_platform_registry",
|
||||
"WalletPlatform",
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
from .platform import ApplePlatform, SignedZipFile, StringResource
|
||||
from .styles import AppleWalletEventTicket
|
||||
|
||||
__all__ = [
|
||||
"ApplePlatform",
|
||||
"AppleWalletEventTicket",
|
||||
"SignedZipFile",
|
||||
"StringResource",
|
||||
]
|
||||
@@ -0,0 +1,276 @@
|
||||
from collections import OrderedDict
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
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.orders import Order
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.plugins.wallet.i18n import (
|
||||
MaybeTranslatedString,
|
||||
)
|
||||
from ..base import (
|
||||
WalletPlatform, wallet_platform_registry
|
||||
)
|
||||
from ...models import CombinedAppleWalletPass
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django import forms
|
||||
from django.utils.functional import classproperty
|
||||
|
||||
from pretix.base.forms import SecretKeySettingsField
|
||||
from pretix.control.forms import ClearableBasenameFileInput
|
||||
|
||||
from ...forms import CertificateFileField, validate_rsa_privkey
|
||||
|
||||
logger = logging.getLogger("pretix.plugins.wallet.apple")
|
||||
|
||||
|
||||
@wallet_platform_registry.register
|
||||
class ApplePlatform(WalletPlatform):
|
||||
identifier = "apple"
|
||||
name = _("Apple Wallet")
|
||||
|
||||
@classmethod
|
||||
def get_global_settings(cls, **kwargs):
|
||||
return OrderedDict(
|
||||
[
|
||||
(
|
||||
# TODO: extract from pass certificate
|
||||
"wallet_apple_team_id",
|
||||
forms.CharField(
|
||||
label=_("Apple Wallet Pass team ID"),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
# TODO: extract from pass certificate
|
||||
"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,
|
||||
),
|
||||
),
|
||||
(
|
||||
# TODO: download this server-side
|
||||
"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(self, position, layout=None) -> str:
|
||||
if not layout:
|
||||
op_layout = self.get_layout(position)
|
||||
if not op_layout:
|
||||
raise ValueError(
|
||||
_(
|
||||
"WalletPlatform.generate called without layout and without position layout"
|
||||
)
|
||||
)
|
||||
layout = op_layout.pass_layout
|
||||
|
||||
layout.generate(position)
|
||||
|
||||
return eventreverse_absolute(
|
||||
self.event,
|
||||
"plugins:wallet:apple_download",
|
||||
kwargs={
|
||||
"order": position.order.code,
|
||||
"secret": position.order.secret,
|
||||
"position": position.pk,
|
||||
},
|
||||
)
|
||||
|
||||
def generate_preview(self, position, layout):
|
||||
pass_obj = layout.generate(position)
|
||||
data = pass_obj.content.file.read()
|
||||
pass_obj.content.delete()
|
||||
return "pkpass", "application/vnd.apple.pkpass", data
|
||||
|
||||
def generate_order(self, order: Order):
|
||||
with tempfile.TemporaryFile() as d:
|
||||
with zipfile.ZipFile(d, "w") as zipf:
|
||||
for pos in order.positions_with_tickets:
|
||||
if self.is_available_for_position(pos):
|
||||
layout = self.get_layout(pos)
|
||||
if not layout:
|
||||
raise ValueError(
|
||||
"Impossible: is_available_for_position==True but no layout"
|
||||
)
|
||||
|
||||
pass_obj = layout.pass_layout.generate(pos, False)
|
||||
zipf.writestr(
|
||||
"{}-{}.pkpass".format(order.code, pos.positionid),
|
||||
pass_obj.content.read(),
|
||||
)
|
||||
|
||||
d.seek(0)
|
||||
data = d.read()
|
||||
|
||||
if hasattr(order, "combinedapplewalletpass"):
|
||||
order.combinedapplewalletpass.delete()
|
||||
|
||||
ct = CombinedAppleWalletPass.objects.create(
|
||||
order=order,
|
||||
file=None,
|
||||
)
|
||||
ct.file.save(
|
||||
"{}-{}.pkpasses".format(order.code, self.identifier), ContentFile(data)
|
||||
)
|
||||
|
||||
return eventreverse_absolute(
|
||||
self.event,
|
||||
"plugins:wallet:apple_download_combined",
|
||||
kwargs={"order": order.code, "secret": order.secret},
|
||||
)
|
||||
|
||||
def is_available(self):
|
||||
for field in [
|
||||
"wallet_apple_team_id",
|
||||
"wallet_apple_pass_type_id",
|
||||
"wallet_apple_certificate",
|
||||
"wallet_apple_ca_certificate",
|
||||
"wallet_apple_key",
|
||||
]:
|
||||
if not self.event.settings.get(field):
|
||||
return False
|
||||
return super().is_available()
|
||||
|
||||
@classproperty
|
||||
def default_style(cls): # noqa: N805
|
||||
return "event_1"
|
||||
|
||||
|
||||
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(), manifest.decode()
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,433 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.translation import gettext as _
|
||||
from django.db import transaction
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.plugins.wallet.i18n import (
|
||||
FormattingLocalizableString, GettextLocalizableString,
|
||||
)
|
||||
from ...models import AppleWalletPass
|
||||
from ..base import (
|
||||
FieldGroup, FieldGroupDisplay, PassStyle, PlaceholderFieldEntry,
|
||||
TextFieldGroup, pass_style_registry
|
||||
)
|
||||
from ..settings import ImageSettingsField
|
||||
|
||||
from ..settings import ColorSettingsField, FloatSettingsField, convert_to_png
|
||||
from .platform import ApplePlatform, SignedZipFile, StringResource
|
||||
|
||||
logger = logging.getLogger("pretix.plugins.wallet.apple")
|
||||
|
||||
|
||||
class AppleWalletStyle(PassStyle):
|
||||
platform = ApplePlatform
|
||||
|
||||
@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, pass_type, team_id, serial_number, auth_token
|
||||
):
|
||||
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)
|
||||
|
||||
pass_json = {
|
||||
"formatVersion": 1,
|
||||
"description": "description",
|
||||
"organizationName": self.event.organizer.name,
|
||||
"passTypeIdentifier": pass_type,
|
||||
"teamIdentifier": team_id,
|
||||
"serialNumber": serial_number,
|
||||
"webServiceURL": eventreverse_absolute(self.event, "presale:event.index")
|
||||
+ "_wallet/apple/",
|
||||
"authenticationToken": auth_token,
|
||||
**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
|
||||
|
||||
@transaction.atomic
|
||||
def generate(self, op: OrderPosition, preview: bool = False):
|
||||
pass_type = self.event.settings.wallet_apple_pass_type_id
|
||||
team_id = self.event.settings.wallet_apple_team_id
|
||||
serial_number = "%s-%s-%s-%d" % (
|
||||
self.event.organizer.slug,
|
||||
self.event.slug,
|
||||
op.order.code,
|
||||
op.pk,
|
||||
)
|
||||
|
||||
if preview:
|
||||
pass_obj = AppleWalletPass(order_position=op, pass_type=pass_type, serial_number=serial_number)
|
||||
else:
|
||||
pass_obj, _ = AppleWalletPass.objects.select_for_update(
|
||||
of=OF_SELF
|
||||
).get_or_create(
|
||||
order_position=op,
|
||||
pass_type=pass_type,
|
||||
defaults={
|
||||
"serial_number": serial_number,
|
||||
},
|
||||
)
|
||||
|
||||
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, pass_type, team_id, pass_obj.serial_number, pass_obj.auth_token
|
||||
)
|
||||
|
||||
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, manifest = pkpass.finish()
|
||||
|
||||
if preview:
|
||||
pass_obj.content.file = ContentFile(result, name=filename)
|
||||
else:
|
||||
if pass_obj.content and pass_obj.manifest != manifest:
|
||||
pass_obj.content.delete()
|
||||
|
||||
if pass_obj.manifest != manifest:
|
||||
pass_obj.content.save(filename, ContentFile(result, name=filename))
|
||||
pass_obj.manifest = manifest
|
||||
pass_obj.save()
|
||||
|
||||
return pass_obj
|
||||
|
||||
|
||||
@pass_style_registry.register
|
||||
class AppleWalletEventTicket(AppleWalletStyle):
|
||||
identifier = "event_1"
|
||||
name = _("Event Ticket Layout")
|
||||
|
||||
@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
|
||||
@@ -0,0 +1,731 @@
|
||||
import enum
|
||||
from functools import cached_property
|
||||
from typing import Any, Iterable, 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 pretix.base.models.orders import Order
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
from pretix.base.signals import PluginAwareRegistry, is_app_active
|
||||
from pretix.plugins.wallet.models import WalletLayout, WalletPlatformLayout
|
||||
|
||||
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
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.html import format_html
|
||||
from django.utils.functional import classproperty
|
||||
|
||||
|
||||
class WalletPlatform:
|
||||
identifier: str
|
||||
name: str
|
||||
|
||||
# TODO: register urls here?
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
self.wallet_settings = SettingsSandbox("ticketoutput", "wallet", event)
|
||||
|
||||
def is_enabled(self):
|
||||
if not self.wallet_settings.get("_enabled", as_type=bool):
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
def is_available_for_position(self, op: OrderPosition):
|
||||
return op.generate_ticket and bool(self.get_layout(op))
|
||||
|
||||
@classmethod
|
||||
def get_global_settings(cls, **kwargs):
|
||||
"""
|
||||
Return a list of fields for the global settings form, as a dict of (setting name, form field).
|
||||
"""
|
||||
return {}
|
||||
|
||||
@classproperty
|
||||
def default_style(cls) -> None | str: # noqa: N805
|
||||
return None
|
||||
|
||||
def render_download_button(self) -> tuple[dict[str, str] | None, str]:
|
||||
"""
|
||||
Render the download button that should be shown.
|
||||
|
||||
returns: A tuple of (attr, content).
|
||||
attr: the html attributes that should be added to the button element as a dict[str, str] or None
|
||||
content: the content of the button
|
||||
"""
|
||||
return None, format_html(
|
||||
'<span class="fa fa-download"></span> {}',
|
||||
_("Add to {wallet}").format(wallet=self.name),
|
||||
)
|
||||
|
||||
def get_layout(self, op) -> WalletPlatformLayout | None:
|
||||
if hasattr(op.item, "walletlayout"):
|
||||
wallet_layout = op.item.walletlayout.layout
|
||||
else:
|
||||
try:
|
||||
wallet_layout = op.event.wallet_layouts.get(default=True)
|
||||
except WalletLayout.DoesNotExist:
|
||||
return None
|
||||
return wallet_layout.platform_layouts.filter(platform=self.identifier).first()
|
||||
|
||||
def generate(self, position) -> str:
|
||||
"""
|
||||
This method should generate the wallet pass and return a url the user should be sent
|
||||
to to retrieve that pass (e.g. via download or by redirecting to the wallet provider)
|
||||
|
||||
.. note:: You should cache the generated files where possible and only regenerate
|
||||
if the pass content has changed.
|
||||
"""
|
||||
op_layout = self.get_layout(position)
|
||||
if not op_layout:
|
||||
raise ValueError(_("No layout defined for order position"))
|
||||
layout = op_layout.pass_layout
|
||||
return layout.generate(position)
|
||||
|
||||
def generate_order(self, order: Order) -> str:
|
||||
"""
|
||||
This method should generate the wallet passes for all positions in `order` and return
|
||||
a url the user should be sent to to retrieve them (e.g. via download or by redirecting
|
||||
to the wallet provider)
|
||||
|
||||
.. note:: You should cache the generated files where possible and only regenerate
|
||||
if the pass contents have changed.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def generate_preview(
|
||||
self, position: OrderPosition, layout: "PassStyle"
|
||||
) -> str | tuple[str, str, bytes]:
|
||||
"""
|
||||
This method should generate the wallet pass for `position` and either return a string containing
|
||||
a url the user should be sent to or a a tuple of an extension (str), a mimetype (str) and the data
|
||||
(bytes) for a file that should be downloaded to the user.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
def default(self, locales: list[str]) -> dict[str, Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
def default(self, locales):
|
||||
return {"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 default(self, locales):
|
||||
return {"entries": [x.asdict(locales) for x in self.default_entries]}
|
||||
|
||||
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:
|
||||
platform: type[WalletPlatform]
|
||||
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 {}
|
||||
|
||||
@property
|
||||
def default_layout(self) -> dict:
|
||||
return {
|
||||
"fieldgroups": {
|
||||
fieldgroup.identifier: fieldgroup.default(self.event.settings.locales)
|
||||
for fieldgroup in self.fieldgroups
|
||||
},
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class PluginAwareClassRegistry[T](PluginAwareRegistry):
|
||||
base_class: type[T]
|
||||
|
||||
def register(self, *objs):
|
||||
for obj in objs:
|
||||
if not issubclass(obj, self.base_class):
|
||||
raise TypeError(f"Entries must be derived from {self.base_class}")
|
||||
|
||||
return super().register(*objs)
|
||||
|
||||
def filter(
|
||||
self, active_in=None, **kwargs
|
||||
) -> Iterable[tuple[type[T], dict[str, Any]]]:
|
||||
result = super().filter(**kwargs)
|
||||
if active_in is not None:
|
||||
result = (
|
||||
(entry, meta)
|
||||
for entry, meta in result
|
||||
if is_app_active(active_in, meta["plugin"])
|
||||
)
|
||||
return result
|
||||
|
||||
def get(
|
||||
self, active_in=None, **kwargs
|
||||
) -> tuple[type[T], dict[str, Any]] | tuple[None, None]:
|
||||
return super().get(active_in=active_in, **kwargs)
|
||||
|
||||
|
||||
class WalletPlatformRegistry(PluginAwareClassRegistry[WalletPlatform]):
|
||||
base_class = WalletPlatform
|
||||
|
||||
def __init__(self):
|
||||
super().__init__({"identifier": lambda o: o.identifier})
|
||||
|
||||
|
||||
class PassStyleRegistry(PluginAwareClassRegistry[PassStyle]):
|
||||
base_class = PassStyle
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
{
|
||||
"identifier": lambda o: (o.platform.identifier, o.identifier),
|
||||
"platform": lambda o: o.platform.identifier,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
wallet_platform_registry = WalletPlatformRegistry()
|
||||
pass_style_registry = PassStyleRegistry()
|
||||
@@ -0,0 +1,8 @@
|
||||
from .platform import GooglePlatform
|
||||
from .styles import GoogleWalletCustomEventTicket, GoogleWalletDefaultEventTicket
|
||||
|
||||
__all__ = [
|
||||
"GooglePlatform",
|
||||
"GoogleWalletCustomEventTicket",
|
||||
"GoogleWalletDefaultEventTicket",
|
||||
]
|
||||
@@ -0,0 +1,525 @@
|
||||
import base64
|
||||
import datetime
|
||||
import enum
|
||||
import json
|
||||
import struct
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
import requests
|
||||
from Crypto.Hash import SHA256
|
||||
from Crypto.PublicKey import ECC
|
||||
from Crypto.Signature import DSS
|
||||
from google.auth import crypt, jwt
|
||||
from google.auth.transport.requests import AuthorizedSession
|
||||
from google.oauth2 import service_account
|
||||
|
||||
from pretix.base.models.orders import OrderPosition
|
||||
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
|
||||
order_position = None
|
||||
|
||||
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"] = {"animationType": 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,
|
||||
order_position: OrderPosition
|
||||
):
|
||||
self.id = object_id
|
||||
self.locales = locales
|
||||
self.default_locale = default_locale
|
||||
self.order_position = order_position
|
||||
|
||||
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.datetime | None, end: datetime.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, resource_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}/{resource_id}?strict=true",
|
||||
json=payload,
|
||||
)
|
||||
|
||||
result.raise_for_status()
|
||||
|
||||
return result.json()
|
||||
|
||||
def patch_item(self, resource: Resource, resource_id: str, payload: dict):
|
||||
result = self._session.patch(
|
||||
f"https://walletobjects.googleapis.com/walletobjects/v1/{resource}/{resource_id}?strict=true",
|
||||
json=payload,
|
||||
)
|
||||
|
||||
result.raise_for_status()
|
||||
|
||||
return result.json()
|
||||
|
||||
def sign_jwt(self, payload):
|
||||
return jwt.encode(self._signer, payload).decode()
|
||||
|
||||
|
||||
def pack(string):
|
||||
ret = struct.pack("<Q", len(string))[:4] + bytes(string, "utf-8")
|
||||
return ret
|
||||
|
||||
|
||||
class GooglePaySignatureManager:
|
||||
ROOT_KEYS_URL = "https://pay.google.com/gp/m/issuer/keys"
|
||||
SENDER_ID = "GooglePayPasses"
|
||||
PROTOCOL = "ECv2SigningOnly"
|
||||
|
||||
def __init__(self, recipient_id: str):
|
||||
self.recipient_id = recipient_id
|
||||
|
||||
def root_keys(self):
|
||||
r = requests.get(self.ROOT_KEYS_URL)
|
||||
r.raise_for_status()
|
||||
return r.json()["keys"]
|
||||
|
||||
def verify_signature(self, keys, salt, message, signatures):
|
||||
salt = b"".join(pack(x) for x in salt)
|
||||
for key in keys:
|
||||
if datetime.datetime.fromtimestamp(
|
||||
int(key["keyExpiration"]) / 1000, tz=datetime.UTC
|
||||
) > datetime.datetime.now(datetime.UTC):
|
||||
pk = ECC.import_key(base64.b64decode(key["keyValue"]))
|
||||
verifier = DSS.new(pk, "fips-186-3", "der")
|
||||
for signature in signatures:
|
||||
try:
|
||||
verifier.verify(
|
||||
SHA256.new(salt + pack(self.PROTOCOL) + pack(message)),
|
||||
base64.b64decode(signature),
|
||||
)
|
||||
return message
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError("No valid signature found")
|
||||
|
||||
def unseal(self, data):
|
||||
for k in (
|
||||
"signature",
|
||||
"intermediateSigningKey",
|
||||
"protocolVersion",
|
||||
"signedMessage",
|
||||
):
|
||||
if k not in data:
|
||||
raise ValueError(f"Missing key: {k}")
|
||||
|
||||
intermediate = self.verify_signature(
|
||||
self.root_keys(),
|
||||
[self.SENDER_ID],
|
||||
data["intermediateSigningKey"]["signedKey"],
|
||||
data["intermediateSigningKey"]["signatures"],
|
||||
)
|
||||
intermediateSigningKey = json.loads(intermediate)
|
||||
message = self.verify_signature(
|
||||
[intermediateSigningKey],
|
||||
[self.SENDER_ID, self.recipient_id],
|
||||
data["signedMessage"],
|
||||
[data["signature"]],
|
||||
)
|
||||
message = json.loads(message)
|
||||
|
||||
if datetime.datetime.fromtimestamp(
|
||||
int(message["expTimeMillis"]) / 1000, tz=datetime.UTC
|
||||
) <= datetime.datetime.now(datetime.UTC):
|
||||
raise ValueError("stale event")
|
||||
|
||||
return message
|
||||
@@ -0,0 +1,245 @@
|
||||
import json
|
||||
import uuid
|
||||
from functools import cached_property
|
||||
from typing import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext as _
|
||||
from django import forms
|
||||
from django.utils.crypto import salted_hmac
|
||||
|
||||
from pretix.base.models import Event, OrderPosition
|
||||
from pretix.base.models.orders import Order
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.plugins.wallet.models import GoogleWalletInstance
|
||||
from ..base import (
|
||||
PassStyle,
|
||||
WalletPlatform,
|
||||
wallet_platform_registry
|
||||
)
|
||||
|
||||
from .google_wallet import (
|
||||
BaseGoogleWalletObject,
|
||||
ButtonJWT,
|
||||
Comms,
|
||||
)
|
||||
from django.utils.html import format_html
|
||||
from django.utils.functional import classproperty
|
||||
|
||||
|
||||
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.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.wallet_google_issuer_id
|
||||
|
||||
op_secret = salted_hmac(
|
||||
key_salt="pretix.plugins.wallet.google.object_id",
|
||||
algorithm="sha256",
|
||||
secret=op.web_secret,
|
||||
value=str(op.pk),
|
||||
).hexdigest()
|
||||
|
||||
return f"{issuer_id}.pretix-{instance_uuid}-{op_secret}"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def dedup(list):
|
||||
new = []
|
||||
for i in list:
|
||||
if not any(x.data == i.data for x in new):
|
||||
new.append(i)
|
||||
return new
|
||||
|
||||
|
||||
@wallet_platform_registry.register
|
||||
class GooglePlatform(WalletPlatform):
|
||||
identifier = "google"
|
||||
name = _("Google Wallet")
|
||||
|
||||
def render_download_button(self) -> tuple[dict[str, str] | None, str]:
|
||||
confirmation_text = _(
|
||||
"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"
|
||||
)
|
||||
return (
|
||||
{"data-confirmation-text": confirmation_text},
|
||||
format_html(
|
||||
'<span class="fa fa-download"></span> {}',
|
||||
_("Add to {wallet}").format(wallet=self.name),
|
||||
),
|
||||
)
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@classproperty
|
||||
def default_style(self):
|
||||
return "event"
|
||||
|
||||
@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, "order_position": object.order_position},
|
||||
)
|
||||
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 _get_jwt(self, ticket_objects):
|
||||
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},
|
||||
precreated=True,
|
||||
).serialize()
|
||||
)
|
||||
|
||||
return f"https://pay.google.com/gp/v/save/{generated_jwt}"
|
||||
|
||||
def generate(self, position):
|
||||
# TODO?: early-exit caching (if object for position exists and layout is None)
|
||||
op_layout = self.get_layout(position)
|
||||
if not op_layout:
|
||||
raise ValueError(
|
||||
_(
|
||||
"WalletPlatform.generate called without layout and without position layout"
|
||||
)
|
||||
)
|
||||
layout = op_layout.pass_layout
|
||||
|
||||
class_object, ticket_object = layout.generate(position)
|
||||
self.put_item_cached(class_object)
|
||||
|
||||
return self._get_jwt([ticket_object])
|
||||
|
||||
def generate_preview(self, position: OrderPosition, layout: PassStyle) -> str:
|
||||
class_object, ticket_object = layout.generate(position)
|
||||
self.put_item_cached(class_object)
|
||||
|
||||
return self._get_jwt([ticket_object])
|
||||
|
||||
def generate_order(self, order: Order):
|
||||
class_objects = []
|
||||
ticket_objects = []
|
||||
for position in order.positions_with_tickets:
|
||||
if self.is_available_for_position(position):
|
||||
layout = self.get_layout(position)
|
||||
if not layout:
|
||||
raise ValueError(
|
||||
"Impossible: is_available_for_position==True but no layout"
|
||||
)
|
||||
|
||||
class_object, ticket_object = layout.pass_layout.generate(position, False)
|
||||
class_objects.append(class_object)
|
||||
ticket_objects.append(ticket_object)
|
||||
|
||||
for co in dedup(class_objects):
|
||||
self.put_item_cached(co)
|
||||
|
||||
return self._get_jwt(ticket_objects)
|
||||
|
||||
def is_available(self):
|
||||
for field in ["wallet_google_issuer_id", "wallet_google_credentials"]:
|
||||
if not self.event.settings.get(field):
|
||||
return False
|
||||
return super().is_available()
|
||||
@@ -0,0 +1,345 @@
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.plugins.wallet.i18n import (
|
||||
FormattingLocalizableString, GettextLocalizableString, LazyLocalizable,
|
||||
)
|
||||
|
||||
from ..base import (
|
||||
FieldGroup, FieldGroupDisplay, PassStyle, PlaceholderFieldEntry,
|
||||
PredefinedFieldGroup, TextFieldGroup, pass_style_registry
|
||||
)
|
||||
|
||||
from .google_wallet import (
|
||||
AnimationType, ConfirmationCodeLabel, DoorsOpenLabel, EventTicketClass, EventTicketObject,
|
||||
State,
|
||||
)
|
||||
|
||||
from ..settings import (
|
||||
BooleanSettingsField, ColorSettingsField, FloatSettingsField,
|
||||
ImageSettingsField,
|
||||
)
|
||||
from .platform import GooglePlatform, get_class_id, get_object_id
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
output_class.callback_url(
|
||||
eventreverse_absolute(
|
||||
self.event,
|
||||
"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,
|
||||
order_position=op
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@pass_style_registry.register
|
||||
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,
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pass_style_registry.register
|
||||
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
|
||||
@@ -0,0 +1,242 @@
|
||||
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.")
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
#
|
||||
# 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.models.event import Event
|
||||
from pretix.base.signals import (
|
||||
register_global_settings,
|
||||
register_ticket_outputs,
|
||||
invalidate_ticket_cache,
|
||||
periodic_task,
|
||||
)
|
||||
from pretix.control.signals import item_forms
|
||||
from pretix.plugins.wallet.platforms.google.platform import get_object_id
|
||||
|
||||
from .forms import WalletLayoutItemForm
|
||||
from .models import WalletLayout, WalletLayoutItem
|
||||
from .ticketoutput import WalletSettingsHolder
|
||||
from .platforms import wallet_platform_registry
|
||||
from .tasks import disable_wallet_instance, send_update_apns
|
||||
from .models import AppleWalletPass, CombinedAppleWalletPass
|
||||
from .tasks import get_pass
|
||||
from .models import GoogleWalletInstance
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from pretix.base.logentrytypes import log_entry_types, EventLogEntryType
|
||||
|
||||
|
||||
@receiver(register_ticket_outputs, dispatch_uid="output_wallet")
|
||||
def register_output(sender, **kwargs):
|
||||
return WalletSettingsHolder
|
||||
|
||||
|
||||
for platform in wallet_platform_registry.all():
|
||||
if hasattr(platform, "get_global_settings"):
|
||||
register_global_settings.connect(
|
||||
platform.get_global_settings,
|
||||
dispatch_uid=f"wallet_global_settings_{platform.identifier}",
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@receiver(periodic_task, dispatch_uid="wallet_periodic")
|
||||
def periodic_tasks(sender, **kwargs):
|
||||
# TODO: move to platform?
|
||||
send_update_apns.apply_async()
|
||||
|
||||
|
||||
# TODO: move to platform?
|
||||
@receiver(invalidate_ticket_cache, dispatch_uid="invalidate_ticket_cache_wallet_apple")
|
||||
def invalidate_apple(
|
||||
sender: Event, item: int | None, provider: str | None, order: int | None, **kwargs
|
||||
):
|
||||
if provider and provider != "wallet":
|
||||
return
|
||||
|
||||
pass_qs = AppleWalletPass.objects.filter(order_position__order__event=sender)
|
||||
passes_qs = CombinedAppleWalletPass.objects.filter(order__event=sender)
|
||||
|
||||
if item:
|
||||
pass_qs = pass_qs.filter(order_position__item_id=item)
|
||||
passes_qs = passes_qs.filter(order__all_positions__item_id=item)
|
||||
|
||||
if order:
|
||||
pass_qs = pass_qs.filter(order_position__order_id=order)
|
||||
passes_qs = passes_qs.filter(order_id=order)
|
||||
|
||||
for pass_obj in pass_qs:
|
||||
get_pass.apply_async(
|
||||
kwargs={
|
||||
"model": "orderposition",
|
||||
"pk": pass_obj.order_position_id,
|
||||
"platform_identifier": "apple",
|
||||
"show_disabled": False,
|
||||
}
|
||||
)
|
||||
|
||||
for passes_obj in passes_qs:
|
||||
passes_obj.delete()
|
||||
|
||||
|
||||
@receiver(invalidate_ticket_cache, dispatch_uid="invalidate_ticket_cache_wallet_google")
|
||||
def invalidate_google(
|
||||
sender: Event, item: int | None, provider: str | None, order: int | None, **kwargs
|
||||
):
|
||||
if provider and provider != "wallet":
|
||||
return
|
||||
|
||||
inst_qs = GoogleWalletInstance.objects.filter(event=sender).exclude(
|
||||
order_position__isnull=True
|
||||
)
|
||||
|
||||
if item:
|
||||
inst_qs = inst_qs.filter(order_position__item_id=item)
|
||||
|
||||
if order:
|
||||
inst_qs = inst_qs.filter(order_position__order_id=order)
|
||||
|
||||
for inst in inst_qs:
|
||||
if get_object_id(inst.order_position) != inst.identifier:
|
||||
# the OPs web_secret was changed
|
||||
disable_wallet_instance.apply_async(kwargs={"pk": inst.pk})
|
||||
get_pass.apply_async(
|
||||
kwargs={
|
||||
"model": "orderposition",
|
||||
"pk": inst.order_position_id,
|
||||
"platform_identifier": "google",
|
||||
"show_disabled": False,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@log_entry_types.new_from_dict(
|
||||
{
|
||||
"pretix.plugins.wallet.layout.created": _(
|
||||
"The wallet layout has been created."
|
||||
),
|
||||
"pretix.plugins.wallet.layout.changed": _(
|
||||
"The wallet layout has been changed."
|
||||
),
|
||||
"pretix.plugins.wallet.layout.deleted": _(
|
||||
"The wallet layout has been deleted."
|
||||
),
|
||||
}
|
||||
)
|
||||
class WalletLogEntryType(EventLogEntryType):
|
||||
object_link_wrapper = _("Wallet Layout {val}")
|
||||
object_link_viewname = "plugins:wallet:edit"
|
||||
content_type = WalletLayout
|
||||
|
||||
def object_link_args(self, content_object):
|
||||
return {"layout": content_object.pk}
|
||||
|
||||
def object_link_display_name(self, content_object):
|
||||
return content_object.name
|
||||
Whitespace-only changes.
@@ -0,0 +1,31 @@
|
||||
#
|
||||
# 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.
|
||||
"""
|
||||
@@ -0,0 +1,195 @@
|
||||
<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,
|
||||
};
|
||||
|
||||
const preview_data = computed(() => {
|
||||
return {
|
||||
data: store.style?.preview_data,
|
||||
fieldgroups: store.fieldgroups,
|
||||
fieldgroup_contents: store.renderedFieldGroups,
|
||||
settings: store.settingsValues,
|
||||
};
|
||||
});
|
||||
</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]" v-bind="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>
|
||||
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
@@ -0,0 +1,62 @@
|
||||
<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" 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" 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>
|
||||
@@ -0,0 +1,31 @@
|
||||
<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>
|
||||
@@ -0,0 +1,86 @@
|
||||
<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>
|
||||
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<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 { i18nstring_localize } from "/src/pretix/static/pretixbase/js/i18nstring.ts";
|
||||
|
||||
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, i18nstring_localize(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 (i18nstring_localize(entry.label)) {
|
||||
base = entry.label
|
||||
} else if (placeholder) {
|
||||
base = placeholder.label
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
for (const locale of Object.keys(store.locales)) {
|
||||
placeholders[locale] = i18nstring_localize(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, i18nstring_localize(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>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<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>
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<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>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import Google from "./google.vue";
|
||||
|
||||
import FieldGroupEntries from "./FieldGroupEntries.vue";
|
||||
|
||||
const props = defineProps();
|
||||
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
Google(v-bind="props")
|
||||
template(#card-rows)
|
||||
FieldGroupEntries.card-row(fieldgroup="second_row")
|
||||
FieldGroupEntries.card-row(fieldgroup="third_row")
|
||||
|
||||
template(#details)
|
||||
FieldGroupEntries.f-column(fieldgroup="details")
|
||||
|
||||
</template>
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import { dateTimeFormat } from "../../helpers";
|
||||
import Google from "./google.vue";
|
||||
import { i18nstring_localize } from "/src/pretix/static/pretixbase/js/i18nstring.ts";
|
||||
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 {{ i18nstring_localize(data.venue?.[0]) }}
|
||||
div.pass-content {{ i18nstring_localize(data.venue?.[1]) }}
|
||||
div.pass-details-row(v-if="isActive('date')")
|
||||
div.pass-label {{ gettext("Event Start Time") }}
|
||||
div.pass-content {{ i18nstring_localize(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>
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { dateTimeFormat } from "../../helpers";
|
||||
import { i18nstring_localize } from "/src/pretix/static/pretixbase/js/i18nstring.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
data: any;
|
||||
fieldgroups: Record<string, FieldGroupConfig>;
|
||||
fieldgroup_contents: Record<
|
||||
string,
|
||||
{ label?: LocalizableString; content: LocalizableString }
|
||||
>;
|
||||
settings: Record<string, SettingValue>;
|
||||
}>();
|
||||
|
||||
function isActive(fieldgroup: string) {
|
||||
return props.fieldgroups[fieldgroup]?.active;
|
||||
}
|
||||
|
||||
const qrcode = computed(() => props.fieldgroup_contents["code"]?.[0]);
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.pass-preview-google(style="")
|
||||
div.f-column
|
||||
.pass-container.f-row
|
||||
img.logo(:src="props.settings['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="props.settings['bg_color']")
|
||||
.pass-inner
|
||||
.f-row
|
||||
img.logo(:src="props.settings['logo']?.url")
|
||||
div.logo-text {{ data.organizer }}
|
||||
|
||||
.top-row
|
||||
div(v-if="isActive('venue')") {{ i18nstring_localize(data.venue?.[0]) }}
|
||||
.event-name {{ data.event }}
|
||||
|
||||
.f-column
|
||||
slot(name="card-rows")
|
||||
|
||||
.qrcode-inner {{ qrcode.label }}
|
||||
|
||||
.f-column
|
||||
img.hero(:src="props.settings['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>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
@@ -0,0 +1,25 @@
|
||||
<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>
|
||||
@@ -0,0 +1,57 @@
|
||||
<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>
|
||||
@@ -0,0 +1,4 @@
|
||||
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.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,111 @@
|
||||
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 SettingValue = string | number | boolean | WalletFile | { url: 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;
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../../pretix/static/pretixpresale/widget/src/lib/store.ts
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,30 @@
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,413 @@
|
||||
import { i18nstring_localize } from "/src/pretix/static/pretixbase/js/i18nstring.ts";
|
||||
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, SettingValue> = {};
|
||||
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;
|
||||
},
|
||||
settingsValues(): Record<string, SettingValue> {
|
||||
const settings: Record<string, SettingValue> = {};
|
||||
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] || setting.default;
|
||||
} else if (setting.type == "image") {
|
||||
const val = this.layout.file_settings[setting.identifier];
|
||||
if (val && "url" in val && val.url) {
|
||||
settings[setting.identifier] = val;
|
||||
} else if (setting.default) {
|
||||
settings[setting.identifier] = { url: setting.default };
|
||||
}
|
||||
}
|
||||
}
|
||||
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 = i18nstring_localize(entry.label);
|
||||
let value = null;
|
||||
if (entry.type === "placeholder") {
|
||||
const placeholder =
|
||||
this.placeholders[fieldgroup.content_type][entry.content];
|
||||
if (!placeholder) {
|
||||
continue;
|
||||
}
|
||||
if (!label) {
|
||||
label = i18nstring_localize(placeholder.label);
|
||||
}
|
||||
value = placeholder?.sample;
|
||||
} else if (entry.type == "custom") {
|
||||
value = i18nstring_localize(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): SettingValue {
|
||||
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));
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
from pretix.base.models import Event, Order, OrderPosition
|
||||
from pretix.base.services.tasks import ProfiledTask
|
||||
from pretix.celery_app import app
|
||||
import ssl
|
||||
import httpx
|
||||
import tempfile
|
||||
from django.utils.timezone import now
|
||||
from django.db.models import OuterRef, Exists
|
||||
|
||||
from pretix.plugins.wallet.platforms import get_platform
|
||||
from pretix.plugins.wallet.platforms.google.google_wallet import Resource, State
|
||||
from pretix.plugins.wallet.platforms.google.platform import GooglePlatform
|
||||
from .models import AppleWalletPass, AppleWalletDevice, GoogleWalletInstance
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
@app.task
|
||||
@scopes_disabled()
|
||||
def send_update_apns():
|
||||
events_to_push = Event.objects.filter(
|
||||
plugins__icontains="pretix.plugins.wallet"
|
||||
).filter(
|
||||
Exists(
|
||||
AppleWalletDevice.objects.filter(
|
||||
Exists(
|
||||
AppleWalletPass.objects.filter(
|
||||
registered_devices=OuterRef("pk"),
|
||||
updated_at__gte=OuterRef("last_push"),
|
||||
order_position__order__event=OuterRef(OuterRef("pk")),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for event in events_to_push:
|
||||
devices_to_push = AppleWalletDevice.objects.filter(
|
||||
Exists(
|
||||
AppleWalletPass.objects.filter(
|
||||
registered_devices=OuterRef("pk"),
|
||||
updated_at__gte=OuterRef("last_push"),
|
||||
order_position__order__event=event,
|
||||
)
|
||||
)
|
||||
)
|
||||
if not devices_to_push.exists():
|
||||
return
|
||||
|
||||
ca_cert = event.settings.wallet_apple_certificate.read()
|
||||
cert = event.settings.wallet_apple_certificate.read()
|
||||
key = event.settings.wallet_apple_key.read()
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
|
||||
with tempfile.NamedTemporaryFile("w") as certfile:
|
||||
certfile.write(key)
|
||||
certfile.write("\n")
|
||||
certfile.write(cert)
|
||||
certfile.write("\n")
|
||||
certfile.write(ca_cert)
|
||||
certfile.flush()
|
||||
ctx.load_cert_chain(
|
||||
certfile=certfile.name,
|
||||
password=event.settings.wallet_apple_key_password,
|
||||
)
|
||||
|
||||
client = httpx.Client(verify=ctx, http1=False, http2=True)
|
||||
|
||||
for device in devices_to_push:
|
||||
device.last_push = now()
|
||||
client.post(
|
||||
f"https://api.push.apple.com/3/device/{device.push_token}", json={}
|
||||
)
|
||||
device.save()
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def get_pass(model: str, pk: int, platform_identifier: str, show_disabled: bool):
|
||||
if model == "order":
|
||||
order = Order.objects.select_related("event").get(id=pk)
|
||||
platform = get_platform(order.event, platform_identifier, show_disabled)
|
||||
if not platform:
|
||||
return
|
||||
return platform.generate_order(order)
|
||||
elif model == "orderposition":
|
||||
position = OrderPosition.objects.select_related("order", "order__event").get(
|
||||
id=pk
|
||||
)
|
||||
platform = get_platform(position.order.event, platform_identifier, show_disabled)
|
||||
if not platform:
|
||||
return
|
||||
return platform.generate(position)
|
||||
|
||||
|
||||
@app.task()
|
||||
def disable_wallet_instance(pk: int):
|
||||
try:
|
||||
obj = GoogleWalletInstance.objects.get(pk=pk)
|
||||
except GoogleWalletInstance.DoesNotExist:
|
||||
return
|
||||
|
||||
platform = GooglePlatform(obj.order_position.event)
|
||||
platform.comms.patch_item(Resource(obj.resource), obj.identifier, {"state": State.INACTIVE})
|
||||
obj.delete()
|
||||
@@ -0,0 +1,35 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% 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 %}
|
||||
{% if object.allow_delete %}
|
||||
<p>{% blocktrans with name=object.name %}Are you sure you want to delete <strong>"{{ name }}"</strong>?{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<p>{% blocktrans %}You cannot delete the default layout.{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<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>
|
||||
{% if object.allow_delete %}
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% 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>
|
||||
{% if l.allow_delete %}
|
||||
<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>
|
||||
{% else %}
|
||||
<button class="btn btn-danger btn-sm" disabled><i class="fa fa-trash"></i></button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% 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>
|
||||
@@ -0,0 +1,10 @@
|
||||
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)
|
||||
@@ -0,0 +1,43 @@
|
||||
#
|
||||
# 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.template.loader import render_to_string
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from pretix.base.ticketoutput import BaseTicketOutput
|
||||
|
||||
class WalletSettingsHolder(BaseTicketOutput):
|
||||
"""
|
||||
Mock ticket output provider for control settings
|
||||
TODO: replace with custom view?
|
||||
"""
|
||||
identifier = "wallet"
|
||||
verbose_name = _("Wallet")
|
||||
|
||||
is_meta = True
|
||||
is_enabled = False
|
||||
preview_allowed = False
|
||||
|
||||
def settings_content_render(self, request) -> str:
|
||||
return render_to_string(
|
||||
"pretixplugins/wallet/settings_content.html", {"request": request}
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
#
|
||||
# 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 pretix.plugins.wallet.views.google import CallbackView
|
||||
|
||||
from .api import WalletLayoutViewSet
|
||||
from .views.apple import (
|
||||
DeviceGetUpdatablePassesView,
|
||||
DeviceRegistrationView,
|
||||
GetPassView,
|
||||
LogView,
|
||||
DownloadPassView,
|
||||
DownloadCombinedPassView
|
||||
)
|
||||
from .views.control import (
|
||||
LayoutCreateView,
|
||||
LayoutDelete,
|
||||
LayoutEditorView,
|
||||
LayoutListView,
|
||||
LayoutPreviewView,
|
||||
LayoutSetDefault,
|
||||
WalletGetView
|
||||
)
|
||||
from .views.presale import PresaleWalletGetPass, PresaleWalletGetPassPosition
|
||||
|
||||
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",
|
||||
),
|
||||
re_path(
|
||||
# TODO(wallet): actually provide this view
|
||||
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/orders/(?P<code>[0-9A-Z]+)/wallet/(?P<position>\d+)/(?P<platform>[^/]+)/$",
|
||||
WalletGetView.as_view(),
|
||||
name="event.order.wallet.get",
|
||||
),
|
||||
]
|
||||
|
||||
event_patterns = [
|
||||
re_path(
|
||||
r"_wallet/apple/v1/devices/(?P<device_library_identifier>[^/]+)/registrations/(?P<pass_type_identifier>[^/]+)/(?P<serial_number>[^/]+)$",
|
||||
DeviceRegistrationView.as_view(),
|
||||
name="apple_register",
|
||||
),
|
||||
re_path(
|
||||
r"_wallet/apple/v1/devices/(?P<device_library_identifier>[^/]+)/registrations/(?P<pass_type_identifier>[^/]+)$",
|
||||
DeviceGetUpdatablePassesView.as_view(),
|
||||
name="apple_get-updatable",
|
||||
),
|
||||
re_path(
|
||||
r"_wallet/apple/v1/passes/(?P<pass_type_identifier>[^/]+)/(?P<serial_number>[^/]+)$",
|
||||
GetPassView.as_view(),
|
||||
name="apple_get-pass",
|
||||
),
|
||||
re_path(
|
||||
r"_wallet/apple/v1/log$",
|
||||
LogView.as_view(),
|
||||
name="apple_log",
|
||||
),
|
||||
re_path(
|
||||
r"_wallet/google/callback$",
|
||||
CallbackView.as_view(),
|
||||
name="google_webhook",
|
||||
),
|
||||
re_path(
|
||||
r"^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/wallet/download/(?P<position>[0-9]+)/(?P<platform>[^/]+)$",
|
||||
PresaleWalletGetPass.as_view(),
|
||||
name="presale_event.order.wallet.get",
|
||||
),
|
||||
re_path(
|
||||
r"^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/wallet/apple/download/(?P<position>[0-9]+)/$",
|
||||
DownloadPassView.as_view(),
|
||||
name="apple_download",
|
||||
),
|
||||
re_path(
|
||||
r"^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/wallet/apple/download/$",
|
||||
DownloadCombinedPassView.as_view(),
|
||||
name="apple_download_combined",
|
||||
),
|
||||
re_path(
|
||||
r"^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/wallet/download/(?P<platform>[^/]+)$",
|
||||
PresaleWalletGetPass.as_view(),
|
||||
name="presale_event.order.wallet.get.combined",
|
||||
),
|
||||
re_path(
|
||||
r"^ticket/(?P<order>[^/]+)/(?P<position>\d+)/(?P<secret>[A-Za-z0-9]+)/wallet/download/(?P<pid>[0-9]+)/(?P<platform>[^/]+)$",
|
||||
PresaleWalletGetPassPosition.as_view(),
|
||||
name="presale_event.order.position.wallet.get",
|
||||
),
|
||||
]
|
||||
|
||||
event_router.register("walletlayouts", WalletLayoutViewSet)
|
||||
@@ -0,0 +1,208 @@
|
||||
import datetime
|
||||
from functools import cached_property
|
||||
import logging
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.http import condition
|
||||
from django.http.response import Http404
|
||||
from django.views import View
|
||||
from rest_framework import exceptions, permissions, serializers
|
||||
from rest_framework.authentication import (
|
||||
BaseAuthentication, get_authorization_header,
|
||||
)
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from pretix.base.models.orders import OrderPosition
|
||||
from pretix.helpers.http import ChunkBasedFileResponse
|
||||
from pretix.plugins.wallet.models import AppleWalletDevice, AppleWalletPass, CombinedAppleWalletPass
|
||||
from pretix.presale.views import EventViewMixin
|
||||
from pretix.presale.views.order import OrderDetailMixin
|
||||
|
||||
logger = logging.getLogger("pretix.plugins.wallet.apple")
|
||||
|
||||
|
||||
class ApplePassAuthentication(BaseAuthentication):
|
||||
keyword = "ApplePass"
|
||||
model = AppleWalletPass
|
||||
|
||||
def authenticate(self, request):
|
||||
auth = get_authorization_header(request).split()
|
||||
|
||||
if not auth or auth[0].lower() != self.keyword.lower().encode():
|
||||
return None
|
||||
|
||||
if len(auth) == 1:
|
||||
msg = _("Invalid token header. No credentials provided.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
elif len(auth) > 2:
|
||||
msg = _("Invalid token header. Token string should not contain spaces.")
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
try:
|
||||
token = auth[1].decode()
|
||||
except UnicodeError:
|
||||
msg = _(
|
||||
"Invalid token header. Token string should not contain invalid characters."
|
||||
)
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
if ":" not in token:
|
||||
msg = _(
|
||||
"Invalid token header. Missing seperator."
|
||||
)
|
||||
raise exceptions.AuthenticationFailed(msg)
|
||||
|
||||
try:
|
||||
pass_obj = AppleWalletPass.objects.get_by_auth_token(
|
||||
token
|
||||
)
|
||||
except AppleWalletPass.DoesNotExist:
|
||||
raise exceptions.AuthenticationFailed(_("Invalid token."))
|
||||
|
||||
return (AnonymousUser(), pass_obj)
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return self.keyword
|
||||
|
||||
|
||||
class ApplePassPermission(permissions.BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if not isinstance(request.auth, AppleWalletPass):
|
||||
return False
|
||||
pass_obj = request.auth
|
||||
if pass_obj.serial_number != view.kwargs["serial_number"]:
|
||||
return False
|
||||
if (
|
||||
request.event.settings.wallet_apple_pass_type_id
|
||||
!= view.kwargs["pass_type_identifier"]
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class RegisterPassSerializer(serializers.Serializer):
|
||||
push_token = serializers.CharField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["push_token"].field_name = "pushToken"
|
||||
|
||||
|
||||
class LogEntrySerializer(serializers.Serializer):
|
||||
logs = serializers.ListField()
|
||||
|
||||
|
||||
class DeviceRegistrationView(APIView):
|
||||
authentication_classes = [ApplePassAuthentication]
|
||||
permission_classes = [ApplePassPermission]
|
||||
|
||||
def post(self, request, *args, device_library_identifier, **kwargs):
|
||||
serializer = RegisterPassSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
device, _ = AppleWalletDevice.objects.get_or_create(
|
||||
device_library_id=device_library_identifier,
|
||||
defaults={"push_token": serializer.data["pushToken"]},
|
||||
)
|
||||
device.passes.add(request.auth)
|
||||
return Response({})
|
||||
|
||||
def delete(self, request, *args, device_library_identifier, **kwargs):
|
||||
try:
|
||||
device = AppleWalletDevice.objects.get(
|
||||
device_library_id=device_library_identifier,
|
||||
)
|
||||
except AppleWalletDevice.DoesNotExist:
|
||||
return Response({})
|
||||
device.passes.remove(request.auth)
|
||||
if not device.passes.exists():
|
||||
device.delete()
|
||||
|
||||
return Response({})
|
||||
|
||||
|
||||
class DeviceGetUpdatablePassesView(APIView):
|
||||
authentication_classes = ()
|
||||
permission_classes = ()
|
||||
|
||||
def get(
|
||||
self, request, *args, device_library_identifier, pass_type_identifier, **kwargs
|
||||
):
|
||||
try:
|
||||
device = AppleWalletDevice.objects.get(
|
||||
device_library_id=device_library_identifier,
|
||||
)
|
||||
except AppleWalletDevice.DoesNotExist:
|
||||
raise exceptions.NotFound(_("Unknown device library identifier."))
|
||||
|
||||
passes = device.passes.filter(pass_type=pass_type_identifier)
|
||||
try:
|
||||
last_update = datetime.datetime.fromtimestamp(int(request.GET.get("passesUpdatedSince", 0)), datetime.UTC)
|
||||
passes = passes.filter(
|
||||
updated_at__gt=last_update
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
{
|
||||
"serialNumbers": passes.values_list("serial_number", flat=True),
|
||||
"lastUpdated": str(int(datetime.datetime.now(datetime.UTC).timestamp())),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GetPassView(APIView):
|
||||
authentication_classes = [ApplePassAuthentication]
|
||||
permission_classes = [ApplePassPermission]
|
||||
|
||||
@method_decorator(
|
||||
condition(
|
||||
last_modified_func=lambda request, *args, **kwargs: request.auth.updated_at
|
||||
)
|
||||
)
|
||||
def get(self, request, *args, **kwargs):
|
||||
resp = ChunkBasedFileResponse(request.auth.content)
|
||||
resp["Content-Type"] = "application/vnd.apple.pkpass"
|
||||
return resp
|
||||
|
||||
|
||||
class LogView(APIView):
|
||||
authentication_classes = ()
|
||||
permission_classes = ()
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = LogEntrySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
logger.info("Received apple wallet log messages: %s", serializer.data["logs"])
|
||||
return Response({})
|
||||
|
||||
|
||||
class DownloadPassView(EventViewMixin, OrderDetailMixin, View):
|
||||
@cached_property
|
||||
def order_position(self):
|
||||
try:
|
||||
return self.order.positions.get(pk=self.kwargs.get('position'))
|
||||
except OrderPosition.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if not self.order_position:
|
||||
return Http404()
|
||||
pass_obj = get_object_or_404(AppleWalletPass, order_position=self.order_position)
|
||||
resp = ChunkBasedFileResponse(pass_obj.content.file, content_type="application/vnd.apple.pkpass")
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(pass_obj.serial_number + ".pkpass").encode('ascii', 'ignore')
|
||||
return resp
|
||||
|
||||
|
||||
class DownloadCombinedPassView(EventViewMixin, OrderDetailMixin, View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
pass_obj = get_object_or_404(CombinedAppleWalletPass, order=self.order)
|
||||
resp = ChunkBasedFileResponse(pass_obj.file.file, content_type="application/vnd.apple.pkpasses")
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(self.order.code + ".pkpasses").encode('ascii', 'ignore')
|
||||
return resp
|
||||
@@ -0,0 +1,344 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
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 Http404, 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.base.models.orders import OrderPosition
|
||||
from pretix.base.services import tickets
|
||||
from pretix.base.views.tasks import AsyncAction
|
||||
from pretix.control.views.orders import OrderView
|
||||
from pretix.plugins.wallet.forms import WalletLayoutCreateForm
|
||||
from pretix.plugins.wallet.tasks import get_pass
|
||||
|
||||
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 ..platforms import get_available_platforms, get_platform, pass_style_registry
|
||||
|
||||
|
||||
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, _meta in pass_style_registry.filter(
|
||||
active_in=self.request.event, platform=platform.identifier
|
||||
)
|
||||
},
|
||||
}
|
||||
for platform in sorted(
|
||||
get_available_platforms(self.request.event, show_disabled=True),
|
||||
key=lambda x: x.identifier,
|
||||
)
|
||||
]
|
||||
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, *args, **kwargs):
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
add_to_response_csp(
|
||||
response,
|
||||
{
|
||||
"img-src": ["blob:"],
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
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()
|
||||
self.object.log_action(
|
||||
action="pretix.plugins.wallet.layout.created",
|
||||
user=self.request.user,
|
||||
data={**form.cleaned_data, "copy_from": self.copy_from.pk},
|
||||
)
|
||||
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 = get_platform(event, platform_id, show_disabled=True)
|
||||
if not platform:
|
||||
raise BadRequest("Unknown platform")
|
||||
|
||||
style, _meta = pass_style_registry.get(
|
||||
active_in=event, identifier=(platform_id, style_id)
|
||||
)
|
||||
if not style:
|
||||
raise BadRequest("Unknown style")
|
||||
|
||||
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()
|
||||
|
||||
result = platform.generate_preview(p, layout)
|
||||
if isinstance(result, str):
|
||||
return redirect(result)
|
||||
else:
|
||||
ext, mimet, data = result
|
||||
resp = HttpResponse(
|
||||
data,
|
||||
content_type=mimet,
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="ticket-preview.{}"'.format(
|
||||
ext.lstrip(".")
|
||||
)
|
||||
},
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
class LayoutSetDefault(LayoutDetailView):
|
||||
@transaction.atomic
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
if not obj.default:
|
||||
for layout in request.event.wallet_layouts.filter(default=True):
|
||||
layout.log_action(
|
||||
"pretix.plugins.wallet.layout.changed",
|
||||
user=self.request.user,
|
||||
data={
|
||||
"default": False,
|
||||
},
|
||||
)
|
||||
layout.default = False
|
||||
layout.save(update_fields=["default"])
|
||||
obj.log_action(
|
||||
"pretix.plugins.wallet.layout.changed",
|
||||
user=self.request.user,
|
||||
data={
|
||||
"default": True,
|
||||
},
|
||||
)
|
||||
obj.default = True
|
||||
obj.save(update_fields=["default"])
|
||||
tickets.invalidate_cache.apply_async(
|
||||
kwargs={"event": self.request.event.pk, "provider": "wallet"}
|
||||
)
|
||||
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()
|
||||
if self.object.allow_delete():
|
||||
self.object.log_action(
|
||||
action="pretix.plugins.wallet.layout.deleted", user=self.request.user
|
||||
)
|
||||
self.object.delete()
|
||||
tickets.invalidate_cache.apply_async(
|
||||
kwargs={"event": self.request.event.pk, "provider": "wallet"}
|
||||
)
|
||||
messages.success(self.request, _("The selected layout been deleted."))
|
||||
else:
|
||||
messages.error(self.request, _("The selected layout cannot be deleted."))
|
||||
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
|
||||
class OrderDownloadMixin:
|
||||
task = get_pass
|
||||
|
||||
def get_success_message(self, value):
|
||||
return ""
|
||||
|
||||
def get_success_url(self, value):
|
||||
return value
|
||||
|
||||
@cached_property
|
||||
def platform(self):
|
||||
if platform := self.kwargs.get("platform"):
|
||||
return get_platform(self.request.event, platform, show_disabled=True)
|
||||
|
||||
|
||||
class WalletGetView(OrderDownloadMixin, AsyncAction, OrderView):
|
||||
permission = "event.orders:read"
|
||||
|
||||
def get_error_url(self):
|
||||
return self.get_order_url()
|
||||
|
||||
@cached_property
|
||||
def order_position(self):
|
||||
try:
|
||||
return self.order.positions.get(pk=self.kwargs.get("position"))
|
||||
except OrderPosition.DoesNotExist:
|
||||
return None
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not self.platform:
|
||||
return self.error(_("You requested an invalid wallet platform."))
|
||||
if not self.order_position:
|
||||
raise Http404(
|
||||
_("Unknown order code or not authorized to access this order.")
|
||||
)
|
||||
|
||||
if "position" in kwargs:
|
||||
if not self.platform.is_available_for_position(self.order_position):
|
||||
return self.error(_("Ticket download is not enabled for this product."))
|
||||
return self.do(
|
||||
"orderposition", self.order_position.pk, self.platform.identifier, True
|
||||
)
|
||||
else:
|
||||
return self.do("order", self.order.pk, self.platform.identifier, True)
|
||||
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
|
||||
from django.db import transaction
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from pretix.helpers.database import OF_SELF
|
||||
from pretix.plugins.wallet.models import GoogleWalletInstance
|
||||
from ..platforms.google.google_wallet import (
|
||||
GooglePaySignatureManager, Resource,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("pretix.plugins.wallet.google")
|
||||
|
||||
|
||||
class CallbackView(APIView):
|
||||
authentication_classes = ()
|
||||
permission_classes = ()
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, *args, **kwargs):
|
||||
webhook_json = GooglePaySignatureManager(
|
||||
request.event.settings.wallet_google_issuer_id
|
||||
).unseal(request.data)
|
||||
|
||||
object = (
|
||||
GoogleWalletInstance.objects.select_for_update(of=OF_SELF)
|
||||
.filter(
|
||||
resource=Resource.EVENT_TICKET_OBJECT,
|
||||
identifier=webhook_json["objectId"],
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if object:
|
||||
if webhook_json["eventType"] == "save":
|
||||
object.usages += 1
|
||||
elif webhook_json["eventType"] == "del":
|
||||
object.usages -= 1
|
||||
object.save()
|
||||
|
||||
return Response({})
|
||||
@@ -0,0 +1,104 @@
|
||||
|
||||
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.clickjacking import xframe_options_exempt
|
||||
from django.views.generic import View
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
from pretix.base.services.orders import OrderError
|
||||
from pretix.base.views.tasks import AsyncAction
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
from pretix.presale.views import EventViewMixin
|
||||
from pretix.presale.views.order import (
|
||||
OrderDetailMixin, OrderPositionDetailMixin,
|
||||
)
|
||||
from pretix.plugins.wallet.platforms import get_platform
|
||||
from pretix.plugins.wallet.tasks import get_pass
|
||||
|
||||
|
||||
class OrderDownloadMixin:
|
||||
task = get_pass
|
||||
known_errortypes = ['OrderError']
|
||||
|
||||
def get_success_message(self, value):
|
||||
return ""
|
||||
|
||||
def get_success_url(self, value):
|
||||
return value
|
||||
|
||||
@cached_property
|
||||
def platform(self):
|
||||
if platform := self.kwargs.get('platform'):
|
||||
return get_platform(event=self.request.event, platform=platform, show_disabled=False)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not self.platform:
|
||||
return self.error(OrderError(_('You requested an invalid ticket output type.')))
|
||||
if not self.order or ('position' in kwargs and not self.order_position):
|
||||
raise Http404(_('Unknown order code or not authorized to access this order.'))
|
||||
positions = list(self.order.positions_with_tickets)
|
||||
if not self.order.ticket_download_available or not positions:
|
||||
return self.error(OrderError(_('Ticket download is not (yet) enabled for this order.')))
|
||||
if 'position' in kwargs and self.order_position not in positions:
|
||||
return self.error(OrderError(_('Ticket download is not enabled for this product.')))
|
||||
|
||||
if (
|
||||
self.request.event.settings.ticket_download_require_validated_email and
|
||||
self.order.sales_channel.type == 'web' and
|
||||
not self.order.email_known_to_work
|
||||
):
|
||||
return self.error(OrderError(_('Please click the link we sent you via email to download your tickets.')))
|
||||
|
||||
if "position" in kwargs:
|
||||
if not self.platform.is_available_for_position(self.order_position):
|
||||
return self.error(OrderError(_("Ticket download is not enabled for this product.")))
|
||||
return self.do(
|
||||
"orderposition", self.order_position.pk, self.platform.identifier, False
|
||||
)
|
||||
else:
|
||||
return self.do("order", self.order.pk, self.platform.identifier, False)
|
||||
|
||||
|
||||
@method_decorator(xframe_options_exempt, 'dispatch')
|
||||
class PresaleWalletGetPass(OrderDownloadMixin, EventViewMixin, OrderDetailMixin, AsyncAction, View):
|
||||
def get_error_url(self):
|
||||
return self.get_order_url()
|
||||
|
||||
def get_self_url(self):
|
||||
return eventreverse(self.request.event,
|
||||
'presale:event.order.download' if 'position' in self.kwargs
|
||||
else 'presale:event.order.download.combined',
|
||||
kwargs=self.kwargs)
|
||||
|
||||
@cached_property
|
||||
def order_position(self):
|
||||
try:
|
||||
return self.order.positions.get(pk=self.kwargs.get('position'))
|
||||
except OrderPosition.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
@method_decorator(xframe_options_exempt, 'dispatch')
|
||||
class PresaleWalletGetPassPosition(OrderDownloadMixin, EventViewMixin, OrderPositionDetailMixin, AsyncAction, View):
|
||||
def get_error_url(self):
|
||||
return self.get_position_url()
|
||||
|
||||
def get_self_url(self):
|
||||
return eventreverse(self.request.event,
|
||||
'presale:event.order.position.download',
|
||||
kwargs=self.kwargs)
|
||||
|
||||
@cached_property
|
||||
def order_position(self):
|
||||
try:
|
||||
return self.order.positions.get(
|
||||
Q(pk=self.kwargs.get('pid')) & Q(
|
||||
Q(pk=self.position.pk) | Q(addon_to__id=self.position.pk)
|
||||
)
|
||||
)
|
||||
except OrderPosition.DoesNotExist:
|
||||
return None
|
||||
@@ -1,3 +1,5 @@
|
||||
import { i18nstring_localize } from '/src/pretix/static/pretixbase/js/i18nstring.ts'
|
||||
|
||||
const body = document.body
|
||||
|
||||
export const timezone = body.dataset.timezone ?? 'UTC'
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
data-pretixlocale="{{ request.LANGUAGE_CODE }}" data-timezone="{{ request.event.settings.timezone }}">
|
||||
<div id="app" data-event-name="{{ request.event.name }}"></div>
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/i18nstring.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "moment/moment-with-locales.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "moment/moment-timezone-with-data-1970-2030.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "jquery/js/jquery-3.6.4.min.js" %}"></script>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% load safelink %}
|
||||
{% load rich_text %}
|
||||
{% load money %}
|
||||
|
||||
{% blocktrans asvar s_taxes %}taxes{% endblocktrans %}
|
||||
<div role="table" aria-label="{% trans "Your cart" %}" aria-describedby="{% if cart.is_ordered %}cart-description{% else%}cart-deadline{% endif %}" aria-rowcount="{{ cart.positions|length|add:2 }}">
|
||||
<div class="sr-only" role="rowgroup">
|
||||
@@ -220,16 +221,7 @@
|
||||
{% if download %}
|
||||
<div role="cell" class="download-desktop">
|
||||
{% 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 %} requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
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>
|
||||
{% endfor %}
|
||||
{% include "pretixpresale/event/fragment_item_downloads.html" with line=line %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif line.addon_to %}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load rich_text %}
|
||||
{% load getitem %}
|
||||
|
||||
{% if download_email_required %}
|
||||
<div class="panel panel-primary">
|
||||
<div class="panel-heading">
|
||||
@@ -35,7 +38,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif can_download and download_buttons and order.count_positions and tickets_with_download %}
|
||||
{% elif downloads_available %}
|
||||
<div class="info-download">
|
||||
<h3 class="sr-only">{% trans "Ticket download" %}</h3>
|
||||
{% if tickets_with_download|length > 1 and can_download_multi %} {# never True on ticket page #}
|
||||
@@ -43,7 +46,7 @@
|
||||
{% 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">
|
||||
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 %}">
|
||||
@@ -52,6 +55,18 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for b in wallet_buttons.combined %}
|
||||
{# TODO(wallet): move view to core #}
|
||||
<form action="{% eventurl event "plugins:wallet:presale_event.order.wallet.get.combined" secret=order.secret order=order.code platform=b.identifier %}"
|
||||
method="post" data-asynctask data-asynctask-download class="download-btn-form">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-lg btn-default {{ b.attributes.class }}"
|
||||
{% include "pretixbase/fragments/attributes.html" with attributes=b.attributes %}>
|
||||
{{ b.content }}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
@@ -60,17 +75,7 @@
|
||||
</p>
|
||||
{% elif tickets_with_download|length == 1 %}
|
||||
<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 %} requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
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>
|
||||
{% endfor %}
|
||||
{% include "pretixpresale/event/fragment_item_downloads.html" with line=tickets_with_download.0 button_size="lg" download_text="long_text" %}
|
||||
</div>
|
||||
<p class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
@@ -78,14 +83,14 @@
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-muted">
|
||||
{% blocktrans trimmed %}
|
||||
Please have your ticket ready when entering the event.
|
||||
{% endblocktrans %}<br>
|
||||
{% blocktrans trimmed %}
|
||||
Download your tickets using the buttons below.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
{% blocktrans trimmed %}
|
||||
Please have your ticket ready when entering the event.
|
||||
{% endblocktrans %}<br>
|
||||
{% blocktrans trimmed %}
|
||||
Download your tickets using the buttons below.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif not can_download and plugins_allow_ticket_download and ticket_download_date and event.settings.ticket_download %}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{% load i18n %}
|
||||
{% load icon %}
|
||||
{% load eventurl %}
|
||||
{% load daterange %}
|
||||
{% load safelink %}
|
||||
{% load rich_text %}
|
||||
{% load money %}
|
||||
{% load getitem %}
|
||||
|
||||
{% 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 %} requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-{{ button_size|default:"sm" }} {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
|
||||
{% with download_text=download_text|default:"text" %}
|
||||
<span class="fa {{ b.icon }}" aria-hidden="true"></span> {{ b|getitem:download_text }}
|
||||
{% endwith %}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% for b in wallet_buttons|getitem:line %}
|
||||
{% comment %} TODO(wallet): move views to core {% endcomment %}
|
||||
<form
|
||||
action="{% spaceless %}{% if position_page and line.addon_to %}
|
||||
{% eventurl event "plugins:wallet:presale_event.order.position.wallet.get" secret=line.addon_to.web_secret order=order.code platform=b.identifier pid=line.pk position=line.addon_to.positionid %}
|
||||
{% elif position_page %}
|
||||
{% eventurl event "plugins:wallet:presale_event.order.position.wallet.get" secret=line.web_secret order=order.code platform=b.identifier pid=line.pk position=line.positionid %}
|
||||
{% else %}
|
||||
{% eventurl event "plugins:wallet:presale_event.order.wallet.get" secret=order.secret order=order.code platform=b.identifier position=line.pk %}
|
||||
{% endif %}{% endspaceless %}"
|
||||
method="post" data-asynctask data-asynctask-download class="download-btn-form">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-{{ button_size|default:"sm" }} btn-default {{ b.attributes.class }}"
|
||||
{% include "pretixbase/fragments/attributes.html" with attributes=b.attributes %}>
|
||||
{{ b.content }}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
@@ -147,3 +147,14 @@
|
||||
{% 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 %}
|
||||
@@ -83,7 +83,9 @@ from pretix.base.services.orders import (
|
||||
change_payment_provider,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.tickets import generate, invalidate_cache
|
||||
from pretix.base.services.tickets import (
|
||||
generate, get_download_buttons, get_wallet_buttons, invalidate_cache,
|
||||
)
|
||||
from pretix.base.signals import order_modified, register_ticket_outputs
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.views.mixins import OrderQuestionsViewMixin
|
||||
@@ -260,8 +262,14 @@ class TicketPageMixin:
|
||||
)
|
||||
|
||||
ctx['tickets_with_download'] = [p for p in ctx['cart']['positions'] if p in positions_with_tickets]
|
||||
|
||||
ctx['download_buttons'] = self.download_buttons
|
||||
ctx['wallet_buttons'] = self.wallet_buttons(ctx['tickets_with_download'])
|
||||
ctx['downloads_available'] = (
|
||||
ctx['can_download']
|
||||
and (ctx['download_buttons'] or ctx['wallet_buttons']['combined'])
|
||||
and self.order.count_positions
|
||||
and ctx['tickets_with_download']
|
||||
)
|
||||
|
||||
ctx['backend_user'] = has_event_access_permission(self.request, 'event.orders:read')
|
||||
|
||||
@@ -269,23 +277,10 @@ class TicketPageMixin:
|
||||
|
||||
@cached_property
|
||||
def download_buttons(self):
|
||||
buttons = []
|
||||
return get_download_buttons(self.request.event, show_disabled=False)
|
||||
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
if not provider.is_enabled:
|
||||
continue
|
||||
buttons.append({
|
||||
'text': provider.download_button_text or 'Download',
|
||||
'multi_text': provider.multi_download_button_text or 'Download',
|
||||
'long_text': provider.long_download_button_text or 'Download',
|
||||
'icon': provider.download_button_icon or 'fa-download',
|
||||
'identifier': provider.identifier,
|
||||
'multi': provider.multi_download_enabled,
|
||||
'javascript_required': provider.javascript_required
|
||||
})
|
||||
return buttons
|
||||
def wallet_buttons(self, positions):
|
||||
return get_wallet_buttons(self.request.event, positions, show_disabled=False)
|
||||
|
||||
|
||||
@method_decorator(xframe_options_exempt, 'dispatch')
|
||||
@@ -310,9 +305,9 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
|
||||
)
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
|
||||
ctx['can_download_multi'] = any([b['multi'] for b in self.download_buttons]) and (
|
||||
ctx['can_download_multi'] = (any([b['multi'] for b in self.download_buttons]) and (
|
||||
[p.generate_ticket for p in ctx['cart']['positions']].count(True) > 1
|
||||
)
|
||||
)) or ctx['wallet_buttons']['combined']
|
||||
ctx['invoices'] = list(self.order.invoices.all())
|
||||
ctx['can_generate_invoice'] = can_generate_invoice(self.request.event, self.order, ignore_payments=True)
|
||||
if ctx['can_generate_invoice']:
|
||||
@@ -1182,9 +1177,6 @@ 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)
|
||||
@@ -1218,13 +1210,16 @@ class OrderDownloadMixin:
|
||||
):
|
||||
return self.error(OrderError(_('Please click the link we sent you via email to download your tickets.')))
|
||||
|
||||
ct = self.get_last_ct()
|
||||
if ct:
|
||||
if ct := self.get_last_ct():
|
||||
return self.success(ct)
|
||||
|
||||
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)
|
||||
|
||||
def get_success_url(self, value):
|
||||
return self.get_self_url()
|
||||
|
||||
def get_success_message(self, value):
|
||||
return ""
|
||||
|
||||
@@ -1262,7 +1257,7 @@ class OrderDownloadMixin:
|
||||
content_type=value.type
|
||||
)
|
||||
else:
|
||||
return redirect(self.get_self_url())
|
||||
return redirect(self.get_success_url(value))
|
||||
|
||||
def get_last_ct(self):
|
||||
if 'position' in self.kwargs:
|
||||
|
||||
@@ -283,6 +283,18 @@ $(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 = {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
function i18nstring_localize (o) {
|
||||
let locale = document.body.attributes['data-pretixlocale'].value
|
||||
let short_locale = locale.split('-')[0]
|
||||
if (o[locale])
|
||||
return o[locale]
|
||||
|
||||
if (o[short_locale])
|
||||
return o[short_locale]
|
||||
|
||||
for (let k of Object.keys(o)) {
|
||||
if (k.split('-')[0] === short_locale && o[k]) {
|
||||
return o[k]
|
||||
}
|
||||
}
|
||||
|
||||
if (o['en'])
|
||||
return o['en']
|
||||
|
||||
for (let k of Object.keys(o)) {
|
||||
if (o[k]) {
|
||||
return o[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
type LocalizableString = null | string | Record<string, string>;
|
||||
|
||||
export function i18nstring_localize(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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@
|
||||
.download-desktop {
|
||||
display: block;
|
||||
width: percentage((4 / $grid-columns));
|
||||
& .btn {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
.product {
|
||||
width: percentage((4 / $grid-columns));
|
||||
@@ -205,7 +208,6 @@
|
||||
word-break: break-all;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@ def test_registry_classes():
|
||||
# check normal object instantiation still works with our decorator
|
||||
assert Cat().make_sound() == "meow"
|
||||
|
||||
# check that an error is raised when trying to retrieve by non-unique key
|
||||
with pytest.raises(Exception):
|
||||
_, _ = animal_type_registry.get(classis="mammalia")
|
||||
|
||||
|
||||
def test_registry_instances():
|
||||
animal_sound_registry = Registry({"animal": lambda s: s.animal})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scope
|
||||
|
||||
from pretix.base.models import Event, Order, Organizer, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env():
|
||||
organizer = Organizer.objects.create(name="Dummy", slug="dummy")
|
||||
prop1 = organizer.meta_properties.get_or_create(name="Test")[0]
|
||||
prop2 = organizer.meta_properties.get_or_create(name="Website")[0]
|
||||
event = Event.objects.create(
|
||||
organizer=organizer, name="Dummy", slug="dummy", date_from=now(), plugins="pretix.plugins.wallet"
|
||||
)
|
||||
event.meta_values.update_or_create(property=prop1, defaults={"value": "*Beep*"})
|
||||
event.meta_values.update_or_create(
|
||||
property=prop2, defaults={"value": "https://example.com"}
|
||||
)
|
||||
user = User.objects.create_user("dummy@dummy.dummy", "dummy")
|
||||
user.email = "dummy@dummy.dummy"
|
||||
user.save()
|
||||
with scope(organizer=organizer):
|
||||
yield event, user, organizer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def order(env):
|
||||
event, user, organizer = env
|
||||
o = Order.objects.create(
|
||||
code="FOO",
|
||||
event=event,
|
||||
email="dummy@dummy.test",
|
||||
status=Order.STATUS_PAID,
|
||||
datetime=now(),
|
||||
expires=now() + timedelta(days=10),
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
total=23,
|
||||
locale="en",
|
||||
)
|
||||
ticket = event.items.create(
|
||||
name="Ticket",
|
||||
category=None,
|
||||
default_price=23,
|
||||
admission=True,
|
||||
personalized=True,
|
||||
)
|
||||
o.positions.create(item=ticket, price=23)
|
||||
return o
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user