mirror of
https://github.com/pretix/pretix.git
synced 2026-10-08 20:24:41 +00:00
Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e816ce114b | ||
|
|
bf1383261c | ||
|
|
7550dc2dfa | ||
|
|
e4011abac0 | ||
|
|
086f394dc9 | ||
|
|
4260d32737 | ||
|
|
509416c9b0 | ||
|
|
98eefb21de | ||
|
|
165ae7b934 | ||
|
|
94c672590d | ||
|
|
7f411bebc8 | ||
|
|
0c8fd6c361 | ||
|
|
86157f88ce | ||
|
|
82e439b67f | ||
|
|
b03ae035ed | ||
|
|
f1c1fc90fa | ||
|
|
395cd2d1f1 | ||
|
|
1acd847f57 | ||
|
|
d152ed71d1 | ||
|
|
154936f678 | ||
|
|
e8b3aa3c4b | ||
|
|
6b1a8af10d | ||
|
|
bff93e4f16 | ||
|
|
357f223e7b | ||
|
|
80895a7963 | ||
|
|
f88533e0b5 | ||
|
|
a35cc46154 | ||
|
|
06b81a8564 | ||
|
|
64fc32882f | ||
|
|
96c707f3cc | ||
|
|
877b6b08c6 | ||
|
|
fac8f10881 | ||
|
|
161cbc5561 | ||
|
|
f75a9b3fdb | ||
|
|
490bece6e2 | ||
|
|
c64ee6dde1 | ||
|
|
4078d5787c | ||
|
|
92cd6f1b46 | ||
|
|
02a2ac2db6 | ||
|
|
fe9d884324 | ||
|
|
7fdab8e8de | ||
|
|
38462ff6b7 | ||
|
|
40ffb84de6 | ||
|
|
4e48bdf383 | ||
|
|
c2bb7488b9 | ||
|
|
9752bb9754 | ||
|
|
db356b70a3 | ||
|
|
757c8086c8 | ||
|
|
05f72ccd53 | ||
|
|
a4851d184e | ||
|
|
6ffbe5da5a | ||
|
|
da2ac6c386 | ||
|
|
b9fbe699ab | ||
|
|
8e3f3cc8c8 | ||
|
|
945f80b288 | ||
|
|
25799da70d | ||
|
|
6b01023d16 | ||
|
|
98644db976 | ||
|
|
814ae7f4d9 | ||
|
|
ae39a1e112 | ||
|
|
61b94f1128 | ||
|
|
dbb586ad5a | ||
|
|
295b0443fb | ||
|
|
31031225d3 | ||
|
|
d600115ea5 | ||
|
|
3d9ebb3072 | ||
|
|
c9667e77c3 | ||
|
|
b2b797f4d3 | ||
|
|
693adbc727 | ||
|
|
8d9840be8d | ||
|
|
73c0c61b6d | ||
|
|
a813269d1f | ||
|
|
d6bc1fa977 | ||
|
|
249edfa2c4 | ||
|
|
0c28151d30 | ||
|
|
5bb3b7e35e | ||
|
|
4119d04fba | ||
|
|
84858bc048 | ||
|
|
e8c091741b | ||
|
|
ebdfd21b0f | ||
|
|
ed0b3cab7f |
@@ -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'
|
||||
|
||||
+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": {
|
||||
|
||||
+2
-2
@@ -94,11 +94,11 @@ dependencies = [
|
||||
"redis==7.4.*",
|
||||
"reportlab==5.0.*",
|
||||
"requests==2.34.*",
|
||||
"sentry-sdk==2.70.*",
|
||||
"sentry-sdk==2.71.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2026072401",
|
||||
"tlds>=2026093003",
|
||||
"tqdm==4.*",
|
||||
"ua-parser==1.0.*",
|
||||
"vobject==0.9.*",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -71,8 +71,8 @@ class EmailTransmissionType(TransmissionType):
|
||||
|
||||
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
|
||||
return {
|
||||
"transmission_email_other": bool(transmission_info.get("transmission_email_address")),
|
||||
"transmission_email_address": transmission_info.get("transmission_email_address"),
|
||||
"transmission_email_other": bool((transmission_info or {}).get("transmission_email_address")),
|
||||
"transmission_email_address": (transmission_info or {}).get("transmission_email_address"),
|
||||
}
|
||||
|
||||
def form_data_to_transmission_info(self, form_data: dict) -> dict:
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import logging
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -31,18 +31,19 @@ from django_scopes import scopes_disabled
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
|
||||
OrderPosition,
|
||||
OrderPosition, CachedFile
|
||||
)
|
||||
from pretix.base.services.tasks import EventTask, ProfiledTask
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_orderposition(order_position: int, provider: str):
|
||||
def generate_orderposition(order_position: int, provider: str, cf: CachedFile | None = None):
|
||||
order_position = OrderPosition.objects.select_related('order', 'order__event').get(id=order_position)
|
||||
|
||||
with language(order_position.order.locale, order_position.order.event.settings.region):
|
||||
@@ -52,15 +53,22 @@ 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,
|
||||
extension=ext, type=ttype, file=None)
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
return ct.pk
|
||||
|
||||
if cf:
|
||||
cf.type = ttype
|
||||
cf.filename = filename
|
||||
cf.file.save(name=filename, content=ContentFile(data))
|
||||
return cf.pk
|
||||
|
||||
def generate_order(order: int, provider: str):
|
||||
if prov.is_cacheable:
|
||||
for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider):
|
||||
ct.delete()
|
||||
ct = CachedTicket.objects.create(order_position=order_position, provider=provider,
|
||||
extension=ext, type=ttype, file=None)
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
return ct.pk
|
||||
|
||||
def generate_order(order: int, provider: str, cf: CachedFile | None = None):
|
||||
order = Order.objects.select_related('event').get(id=order)
|
||||
|
||||
with language(order.locale, order.event.settings.region):
|
||||
@@ -70,56 +78,76 @@ 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,
|
||||
type=ttype, file=None)
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
return ct.pk
|
||||
|
||||
if cf:
|
||||
cf.type = ttype
|
||||
cf.filename = filename
|
||||
cf.file.save(name=filename, content=ContentFile(data))
|
||||
return cf.pk
|
||||
|
||||
if prov.is_cacheable:
|
||||
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
|
||||
ct.delete()
|
||||
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext,
|
||||
type=ttype, file=None)
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
return ct.pk
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def generate(model: str, pk: int, provider: str):
|
||||
def generate(model: str, pk: int, provider: str, cachedfile_pk: int | None = None):
|
||||
with scopes_disabled():
|
||||
if cachedfile_pk:
|
||||
cf = CachedFile.objects.get(pk=cachedfile_pk)
|
||||
else:
|
||||
cf = None
|
||||
if model == 'order':
|
||||
return generate_order(pk, provider)
|
||||
return generate_order(pk, provider, cf)
|
||||
elif model == 'orderposition':
|
||||
return generate_orderposition(pk, provider)
|
||||
return generate_orderposition(pk, provider, cf)
|
||||
|
||||
|
||||
class DummyRollbackException(Exception):
|
||||
pass
|
||||
|
||||
def get_preview_position(event):
|
||||
connection = transaction.get_connection()
|
||||
if not connection.in_atomic_block:
|
||||
raise RuntimeError("get_preview_position needs to be called in a rolledback_transaction")
|
||||
|
||||
def preview(event: int, provider: str):
|
||||
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'))
|
||||
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
|
||||
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
|
||||
email='sample@pretix.eu',
|
||||
locale=event.settings.locale,
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
expires=now(), code="PREVIEW1234", total=119)
|
||||
|
||||
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
sample = {k: str(v) for k, v in scheme['sample'].items()}
|
||||
position = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price)
|
||||
s = event.subevents.first()
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=position, subevent=s)
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=position, subevent=s)
|
||||
|
||||
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))
|
||||
return position
|
||||
|
||||
def preview(event: int, provider: str, provider_arguments: dict = {}):
|
||||
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):
|
||||
@@ -143,7 +171,7 @@ def get_tickets_for_order(order, base_position=None):
|
||||
]
|
||||
|
||||
for p in providers:
|
||||
if not p.is_enabled:
|
||||
if not p.is_enabled or not p.is_cacheable:
|
||||
continue
|
||||
|
||||
if p.multi_download_enabled and not base_position:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
@@ -220,6 +231,19 @@ class BaseTicketOutput:
|
||||
"""
|
||||
return 'fa-download'
|
||||
|
||||
@property
|
||||
def confirmation_text(self) -> str | None:
|
||||
"""
|
||||
A text the user has to confirm before a pass is generated.
|
||||
|
||||
Returning None means no confirmation screen is shown.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_cacheable(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def preview_allowed(self) -> bool:
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -66,7 +66,7 @@ class ProcessForm(forms.Form):
|
||||
widget=forms.Select(
|
||||
attrs={'data-static': 'true'}
|
||||
),
|
||||
help_text=conditional_escape(c.help_text),
|
||||
help_text=conditional_escape(c.help_text or ""),
|
||||
)
|
||||
|
||||
def get_columns(self):
|
||||
|
||||
@@ -364,7 +364,7 @@ class TeamForm(forms.ModelForm):
|
||||
for opt in pg.options
|
||||
],
|
||||
label=pg.label,
|
||||
help_text=conditional_escape(pg.help_text),
|
||||
help_text=conditional_escape(pg.help_text or ""),
|
||||
initial=initial,
|
||||
widget=forms.RadioSelect,
|
||||
)
|
||||
@@ -389,7 +389,7 @@ class TeamForm(forms.ModelForm):
|
||||
for opt in pg.options
|
||||
],
|
||||
label=pg.label,
|
||||
help_text=conditional_escape(pg.help_text),
|
||||
help_text=conditional_escape(pg.help_text or ""),
|
||||
initial=initial,
|
||||
widget=forms.RadioSelect,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{% load l10n %}
|
||||
{% load phone_format %}
|
||||
{% load getitem %}
|
||||
{% load dialog %}
|
||||
{% block title %}
|
||||
{% blocktrans trimmed with code=order.code %}
|
||||
Order details: {{ code }}
|
||||
@@ -568,7 +569,8 @@
|
||||
class="form-inline helper-display-inline{% if b.javascript_required %} requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-xs btn-default">
|
||||
class="btn btn-xs btn-default"
|
||||
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
|
||||
<span class="fa {{ b.icon }}"></span> {{ b.text }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,6 +54,7 @@ from django.db.models import (
|
||||
Count, Exists, F, IntegerField, Max, OuterRef, Prefetch, ProtectedError, Q,
|
||||
QuerySet, Subquery, Sum,
|
||||
)
|
||||
from uuid import UUID
|
||||
from django.forms import formset_factory
|
||||
from django.http import (
|
||||
FileResponse, Http404, HttpResponseNotAllowed, HttpResponseRedirect,
|
||||
@@ -582,12 +583,15 @@ class OrderDetail(OrderView):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
if provider.is_meta:
|
||||
continue
|
||||
buttons.append({
|
||||
'text': provider.download_button_text or 'Ticket',
|
||||
'icon': provider.download_button_icon or 'fa-download',
|
||||
'identifier': provider.identifier,
|
||||
'multi': provider.multi_download_enabled,
|
||||
'javascript_required': provider.javascript_required
|
||||
'javascript_required': provider.javascript_required,
|
||||
'confirmation_text': provider.confirmation_text
|
||||
})
|
||||
return buttons
|
||||
|
||||
@@ -667,7 +671,18 @@ class OrderDownload(AsyncAction, OrderView):
|
||||
permission = 'event.orders:read'
|
||||
|
||||
def get_success_url(self, value):
|
||||
return self.get_self_url()
|
||||
if isinstance(value, UUID):
|
||||
cf = CachedFile.objects.filter(pk=value).first()
|
||||
else:
|
||||
cf = None
|
||||
|
||||
if not cf or not cf.allowed_for_session(self.request):
|
||||
return self.get_self_url()
|
||||
|
||||
if cf.type == 'text/uri-list':
|
||||
return cf.file.file.read().decode()
|
||||
else:
|
||||
return reverse('cachedfile.download', kwargs={"id":str(value)})
|
||||
|
||||
def get_error_url(self):
|
||||
return self.get_order_url()
|
||||
@@ -706,12 +721,19 @@ 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 self.output.is_cacheable and (ct := self.get_last_ct()):
|
||||
return self.success(ct)
|
||||
|
||||
if not self.output.is_cacheable:
|
||||
cf = CachedFile(web_download=True, date=now(), expires=now() + timedelta(hours=2))
|
||||
cf.bind_to_session(request)
|
||||
cf.save()
|
||||
else:
|
||||
cf = None
|
||||
|
||||
return self.do('orderposition' if 'position' in kwargs else 'order',
|
||||
self.order_position.pk if 'position' in kwargs else self.order.pk,
|
||||
self.output.identifier)
|
||||
self.output.identifier, cf.pk if cf else None)
|
||||
|
||||
def get_success_message(self, value):
|
||||
return ""
|
||||
@@ -738,12 +760,15 @@ 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(
|
||||
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
|
||||
).last()
|
||||
if self.output.is_cacheable:
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
|
||||
).last()
|
||||
else:
|
||||
ct = None
|
||||
if not ct or not ct.file:
|
||||
return None
|
||||
return ct
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
|
||||
"PO-Revision-Date: 2026-09-28 11:01+0000\n"
|
||||
"Last-Translator: Tim <plicnetwork@gmail.com>\n"
|
||||
"PO-Revision-Date: 2026-10-03 23:00+0000\n"
|
||||
"Last-Translator: Oksigenia <github@oksigenia.cc>\n"
|
||||
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"es/>\n"
|
||||
"Language: es\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
"X-Generator: Weblate 2026.10\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "English"
|
||||
@@ -1793,16 +1793,12 @@ msgid "Transmission type"
|
||||
msgstr "Tipo de transmisión"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
#, fuzzy
|
||||
#| msgid "Transmission type"
|
||||
msgid "Transmission status"
|
||||
msgstr "Tipo de transmisión"
|
||||
msgstr "Estado de la transmisión"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
#, fuzzy
|
||||
#| msgid "Transmission type"
|
||||
msgid "Transmission date"
|
||||
msgstr "Tipo de transmisión"
|
||||
msgstr "Fecha de emisión"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
msgid "Cancellation"
|
||||
@@ -3289,7 +3285,7 @@ msgstr ""
|
||||
#: pretix/base/forms/validators.py pretix/control/views/event.py
|
||||
#, python-format
|
||||
msgid "Invalid placeholder: {%(value)s}"
|
||||
msgstr "Marcador de posición no válido: {%(value)ss}"
|
||||
msgstr "Marcador de posición no válido: {%(value)s}"
|
||||
|
||||
#: pretix/base/forms/widgets.py
|
||||
#, python-format
|
||||
@@ -4458,10 +4454,8 @@ msgid "Initialization date"
|
||||
msgstr "Fecha de inicialización"
|
||||
|
||||
#: pretix/base/models/devices.py
|
||||
#, fuzzy
|
||||
#| msgid "Save to profile"
|
||||
msgid "Security profile"
|
||||
msgstr "Guardar en perfil"
|
||||
msgstr "Perfil de seguridad"
|
||||
|
||||
#: pretix/base/models/discount.py
|
||||
msgctxt "subevent"
|
||||
@@ -5904,10 +5898,8 @@ msgid "Maximum value"
|
||||
msgstr "Valor máximo"
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
#, fuzzy
|
||||
#| msgid "Maximum length"
|
||||
msgid "Minimum length"
|
||||
msgstr "Longitud máxima"
|
||||
msgstr "Longitud mínima"
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
msgid "Maximum length"
|
||||
@@ -5978,10 +5970,8 @@ msgid "The maximum value must not be lower than the minimum value."
|
||||
msgstr "El valor máximo no debe ser inferior al mínimo."
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
#, fuzzy
|
||||
#| msgid "The maximum value must not be lower than the minimum value."
|
||||
msgid "The maximum length must not be shorter than the minimum length."
|
||||
msgstr "El valor máximo no debe ser inferior al mínimo."
|
||||
msgstr "La longitud máxima no debe ser inferior a la longitud mínima."
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
msgid ""
|
||||
@@ -10581,10 +10571,8 @@ msgstr ""
|
||||
"después de la `Última fecha de pagos' configurada anteriormente."
|
||||
|
||||
#: pretix/base/settings.py
|
||||
#, fuzzy
|
||||
#| msgid "Generate invoices for Sales channels"
|
||||
msgid "Allow postponed payment choice for sales channels"
|
||||
msgstr "Generar facturas para canales de ventas"
|
||||
msgstr "Permitir la opción de pago aplazado en los canales de venta"
|
||||
|
||||
#: pretix/base/settings.py
|
||||
msgid ""
|
||||
@@ -10592,6 +10580,9 @@ msgid ""
|
||||
"their order without selecting a payment method. This is useful whenever "
|
||||
"orders are not created by the same person who is making the payment."
|
||||
msgstr ""
|
||||
"Si en un canal de venta se permite el pago aplazado, los clientes pueden "
|
||||
"completar su pedido sin seleccionar una forma de pago. Esto resulta útil "
|
||||
"cuando los pedidos no los realiza la misma persona que efectúa el pago."
|
||||
|
||||
#: pretix/base/settings.py
|
||||
msgid "Show start date"
|
||||
@@ -16860,7 +16851,7 @@ msgstr "tercero"
|
||||
#: pretix/control/forms/rrule.py
|
||||
msgctxt "rrule"
|
||||
msgid "fourth"
|
||||
msgstr ""
|
||||
msgstr "cuarto"
|
||||
|
||||
#: pretix/control/forms/rrule.py
|
||||
msgctxt "rrule"
|
||||
@@ -17993,28 +17984,20 @@ msgid "Queued emails have been aborted."
|
||||
msgstr "Los correos electrónicos en cola se han cancelado."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The property has been created."
|
||||
msgid "An organizer meta property has been created."
|
||||
msgstr "La propiedad ha sido creada."
|
||||
msgstr "Se ha creado una metapropiedad de organizador."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The selected property has been deleted."
|
||||
msgid "An organizer meta property has been deleted."
|
||||
msgstr "La propiedad seleccionada ha sido eliminada."
|
||||
msgstr "Se ha eliminado una metapropiedad del organizador."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The organizer has been changed."
|
||||
msgid "An organizer meta property has been changed."
|
||||
msgstr "El pedido se ha cambiado."
|
||||
msgstr "Se ha modificado una metapropiedad del organizador."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The product has been reordered."
|
||||
msgid "An organizer meta property has been reordered."
|
||||
msgstr "El producto se ha pedido de nuevo."
|
||||
msgstr "Se ha cambiado el orden de una metapropiedad del organizador."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
msgid "Gift card acceptance for another organizer has been added."
|
||||
@@ -18228,6 +18211,8 @@ msgstr "Se ha generado un código de emergencia de doble factor."
|
||||
#: pretix/control/logdisplay.py
|
||||
msgid "Drift and throttle values for two-factor devices have been reset."
|
||||
msgstr ""
|
||||
"Se han restablecido los valores de deriva y aceleración para los "
|
||||
"dispositivos de dos factores."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, python-brace-format
|
||||
@@ -19180,10 +19165,8 @@ msgid "Cancel"
|
||||
msgstr "Cancelar"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
|
||||
#, fuzzy
|
||||
#| msgid "Authorized apps"
|
||||
msgid "Authorize"
|
||||
msgstr "Aplicaciones autorizadas"
|
||||
msgstr "Autorizar"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
|
||||
msgid "Error:"
|
||||
@@ -22071,14 +22054,10 @@ msgstr ""
|
||||
"hacer referencia a este evento."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/events/create_basics.html
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "We strongly recommend against using short forms of more then 16 "
|
||||
#| "characters."
|
||||
msgid ""
|
||||
"We strongly recommend against using short forms of more than 16 characters."
|
||||
msgstr ""
|
||||
"Recomendamos encarecidamente no utilizar formularios cortos de más de 16 "
|
||||
"Recomendamos encarecidamente no utilizar abreviaturas de más de 16 "
|
||||
"caracteres."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/events/create_basics.html
|
||||
@@ -27957,27 +27936,23 @@ msgstr "Autenticación backend"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
msgid "Reset drift and throttle"
|
||||
msgstr ""
|
||||
msgstr "Restablecer la desviación y el control"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
#, fuzzy
|
||||
#| msgid "Disable two-factor authentication"
|
||||
msgid "Available two-factor authentication methods"
|
||||
msgstr "Desactivar la autenticación de dos factores"
|
||||
msgstr "Métodos de autenticación de dos factores disponibles"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Expired since %(date)s"
|
||||
#, python-format
|
||||
msgid "1 failed attempt since %(date)s"
|
||||
msgid_plural "%(cnt)s failed attempts since %(date)s"
|
||||
msgstr[0] "Vencido desde el %(date)s"
|
||||
msgstr[1] "Vencido desde el %(date)s"
|
||||
msgstr[0] "1 intento fallido desde %(date)s"
|
||||
msgstr[1] "%(cnt)s intentos fallidos desde el %(date)s"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Valid until %(date)s"
|
||||
#, python-format
|
||||
msgid "Currently locked until %(date)s"
|
||||
msgstr "Válido hasta el %(date)s"
|
||||
msgstr "Actualmente bloqueado hasta el %(date)s"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
msgid "Team memberships"
|
||||
@@ -28519,20 +28494,18 @@ msgid "Invalid code, please try again."
|
||||
msgstr "Código inválido, por favor inténtelo de nuevo."
|
||||
|
||||
#: pretix/control/views/auth.py
|
||||
#, fuzzy, python-brace-format
|
||||
#| msgid "Invalid code, please try again."
|
||||
#, python-brace-format
|
||||
msgid "Invalid code. Please try again after waiting {value} minute."
|
||||
msgid_plural "Invalid code. Please try again after waiting {value} minutes."
|
||||
msgstr[0] "Código inválido, por favor inténtelo de nuevo."
|
||||
msgstr[1] "Código inválido, por favor inténtelo de nuevo."
|
||||
msgstr[0] "Código no válido. Inténtalo de nuevo tras esperar {value} minutos."
|
||||
msgstr[1] "ódigo no válido. Inténtalo de nuevo tras esperar {value} minutos."
|
||||
|
||||
#: pretix/control/views/auth.py
|
||||
#, fuzzy, python-brace-format
|
||||
#| msgid "Invalid code, please try again."
|
||||
#, python-brace-format
|
||||
msgid "Invalid code. Please try again after waiting {value} second."
|
||||
msgid_plural "Invalid code. Please try again after waiting {value} seconds."
|
||||
msgstr[0] "Código inválido, por favor inténtelo de nuevo."
|
||||
msgstr[1] "Código inválido, por favor inténtelo de nuevo."
|
||||
msgstr[0] "Código no válido. Inténtalo de nuevo tras esperar {value} segundo."
|
||||
msgstr[1] "Código no válido. Inténtalo de nuevo tras esperar {value} segundos."
|
||||
|
||||
#: pretix/control/views/checkin.py
|
||||
msgid "The selected check-ins have been reverted."
|
||||
@@ -29405,10 +29378,8 @@ msgid "You entered an order in an event with a different currency."
|
||||
msgstr "Ha introducido un pedido en un evento con una moneda diferente."
|
||||
|
||||
#: pretix/control/views/orders.py
|
||||
#, fuzzy
|
||||
#| msgid "You entered an order in an event with a different currency."
|
||||
msgid "You entered an order in an event that you do not have access to."
|
||||
msgstr "Ha introducido un pedido en un evento con una moneda diferente."
|
||||
msgstr "Ha introducido un pedido en un evento al que no tienes acceso."
|
||||
|
||||
#: pretix/control/views/orders.py
|
||||
msgid ""
|
||||
@@ -30296,10 +30267,9 @@ msgstr ""
|
||||
"código de emergencia anterior para este usuario permanecerá activo."
|
||||
|
||||
#: pretix/control/views/users.py
|
||||
#, fuzzy
|
||||
#| msgid "The password has been reset."
|
||||
msgid "The drift values for TOTP devices have been reset."
|
||||
msgstr "La contraseña ha sido reiniciada."
|
||||
msgstr ""
|
||||
"Se han restablecido los valores de desviación para los dispositivos TOTP."
|
||||
|
||||
#: pretix/control/views/users.py
|
||||
msgid "The new user has been created."
|
||||
@@ -30314,14 +30284,12 @@ msgid "Bypass quota"
|
||||
msgstr "Cuota de bypass"
|
||||
|
||||
#: pretix/control/views/vouchers.py
|
||||
#, fuzzy
|
||||
#| msgid "Widget"
|
||||
msgid "Budget"
|
||||
msgstr "Widget"
|
||||
msgstr "Presupuesto"
|
||||
|
||||
#: pretix/control/views/vouchers.py
|
||||
msgid "Budget used"
|
||||
msgstr ""
|
||||
msgstr "Presupuesto utilizado"
|
||||
|
||||
#: pretix/control/views/vouchers.py
|
||||
msgid "The requested voucher does not exist."
|
||||
@@ -35022,29 +34990,29 @@ msgid ""
|
||||
"Not sure yet? You can complete your order first and then select a payment "
|
||||
"method later."
|
||||
msgstr ""
|
||||
"¿Aún no estás seguro? Puedes completar primero tu pedido y seleccionar la "
|
||||
"forma de pago más tarde."
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
msgid ""
|
||||
"To do so, please first remove the payment methods you already selected above."
|
||||
msgstr ""
|
||||
"Para ello, elimina primero los métodos de pago que ya hayas seleccionado "
|
||||
"anteriormente."
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Your event needs to be over to use this feature."
|
||||
#, python-format
|
||||
msgid "Your payment needs to be completed within %(minutes)s minutes."
|
||||
msgstr "Su evento debe haber terminado para utilizar esta función."
|
||||
msgstr "Su pago debe completarse en un plazo de %(minutes)s minutos."
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Your event needs to be over to use this feature."
|
||||
#, python-format
|
||||
msgid "Your payment needs to be completed by %(deadline)s."
|
||||
msgstr "Su evento debe haber terminado para utilizar esta función."
|
||||
msgstr "Su pago debe completarse antes de %(deadline)s."
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
#, fuzzy
|
||||
#| msgid "Proceed with reset"
|
||||
msgid "Proceed without selection"
|
||||
msgstr "Continuar con el restablecimiento"
|
||||
msgstr "Continuar sin seleccionar nada"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
|
||||
msgid "Before we continue, we need you to answer some questions."
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
|
||||
"PO-Revision-Date: 2026-06-29 17:00+0000\n"
|
||||
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
|
||||
"PO-Revision-Date: 2026-10-03 23:00+0000\n"
|
||||
"Last-Translator: Oksigenia <github@oksigenia.cc>\n"
|
||||
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
"js/es/>\n"
|
||||
"Language: es\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"X-Generator: Weblate 2026.10\n"
|
||||
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
|
||||
msgid "Marked as paid"
|
||||
@@ -780,7 +780,7 @@ msgstr "El organizador retiene %(currency)s %(amount)s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js
|
||||
msgid "You get %(currency)s %(amount)s back"
|
||||
msgstr "Se le devolverá %(moneda)s %(cantidad)s"
|
||||
msgstr "Se le devolverá %(currency)s %(amount)s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js
|
||||
msgid "Please enter the amount the organizer can keep."
|
||||
|
||||
@@ -4,10 +4,10 @@ msgstr ""
|
||||
"Project-Id-Version: 1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
|
||||
"PO-Revision-Date: 2026-09-30 07:25+0000\n"
|
||||
"PO-Revision-Date: 2026-10-01 09:28+0000\n"
|
||||
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
|
||||
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/fr/"
|
||||
">\n"
|
||||
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"fr/>\n"
|
||||
"Language: fr\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -1793,16 +1793,12 @@ msgid "Transmission type"
|
||||
msgstr "Type de transmission"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
#, fuzzy
|
||||
#| msgid "Transmission type"
|
||||
msgid "Transmission status"
|
||||
msgstr "Type de transmission"
|
||||
msgstr "État de la transmission"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
#, fuzzy
|
||||
#| msgid "Transmission type"
|
||||
msgid "Transmission date"
|
||||
msgstr "Type de transmission"
|
||||
msgstr "Date de transmission"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
msgid "Cancellation"
|
||||
@@ -4468,10 +4464,8 @@ msgid "Initialization date"
|
||||
msgstr "Date d'initialisation"
|
||||
|
||||
#: pretix/base/models/devices.py
|
||||
#, fuzzy
|
||||
#| msgid "Save to profile"
|
||||
msgid "Security profile"
|
||||
msgstr "Enregistrer dans le profil"
|
||||
msgstr "Profil de sécurité"
|
||||
|
||||
#: pretix/base/models/discount.py
|
||||
msgctxt "subevent"
|
||||
@@ -5925,10 +5919,8 @@ msgid "Maximum value"
|
||||
msgstr "Valeur maximale"
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
#, fuzzy
|
||||
#| msgid "Maximum length"
|
||||
msgid "Minimum length"
|
||||
msgstr "Longueur maximale"
|
||||
msgstr "Longueur minimale"
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
msgid "Maximum length"
|
||||
@@ -6000,10 +5992,9 @@ msgid "The maximum value must not be lower than the minimum value."
|
||||
msgstr "La valeur maximale ne doit pas être inférieure à la valeur minimale."
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
#, fuzzy
|
||||
#| msgid "The maximum value must not be lower than the minimum value."
|
||||
msgid "The maximum length must not be shorter than the minimum length."
|
||||
msgstr "La valeur maximale ne doit pas être inférieure à la valeur minimale."
|
||||
msgstr ""
|
||||
"La longueur maximale ne doit pas être inférieure à la longueur minimale."
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
msgid ""
|
||||
@@ -10647,10 +10638,8 @@ msgstr ""
|
||||
"accepté après la 'dernière date de paiement' configurée ci-dessus."
|
||||
|
||||
#: pretix/base/settings.py
|
||||
#, fuzzy
|
||||
#| msgid "Generate invoices for Sales channels"
|
||||
msgid "Allow postponed payment choice for sales channels"
|
||||
msgstr "Générer des factures pour les canaux de vente"
|
||||
msgstr "Autoriser l'option de paiement différé pour les canaux de vente"
|
||||
|
||||
#: pretix/base/settings.py
|
||||
msgid ""
|
||||
@@ -10658,6 +10647,10 @@ msgid ""
|
||||
"their order without selecting a payment method. This is useful whenever "
|
||||
"orders are not created by the same person who is making the payment."
|
||||
msgstr ""
|
||||
"Si le paiement différé est autorisé sur un canal de vente, les clients "
|
||||
"peuvent finaliser leur commande sans sélectionner de mode de paiement. Cette "
|
||||
"fonctionnalité est utile lorsque la commande n'est pas passée par la même "
|
||||
"personne que celle qui effectue le paiement."
|
||||
|
||||
#: pretix/base/settings.py
|
||||
msgid "Show start date"
|
||||
@@ -17017,7 +17010,7 @@ msgstr "troisième"
|
||||
#: pretix/control/forms/rrule.py
|
||||
msgctxt "rrule"
|
||||
msgid "fourth"
|
||||
msgstr ""
|
||||
msgstr "quatrième"
|
||||
|
||||
#: pretix/control/forms/rrule.py
|
||||
msgctxt "rrule"
|
||||
@@ -18137,28 +18130,20 @@ msgid "Queued emails have been aborted."
|
||||
msgstr "Les e-mails en attente ont été annulés."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The property has been created."
|
||||
msgid "An organizer meta property has been created."
|
||||
msgstr "La propriété a été créée."
|
||||
msgstr "Une méta-propriété d'organisateur a été créée."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The selected property has been deleted."
|
||||
msgid "An organizer meta property has been deleted."
|
||||
msgstr "La propriété sélectionnée a été supprimée."
|
||||
msgstr "Une méta-propriété d'organisateur a été supprimée."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The organizer has been changed."
|
||||
msgid "An organizer meta property has been changed."
|
||||
msgstr "L’organisateur a été modifié."
|
||||
msgstr "Une méta-propriété d'organisateur a été modifiée."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The product has been reordered."
|
||||
msgid "An organizer meta property has been reordered."
|
||||
msgstr "Le produit a été commandé à nouveau."
|
||||
msgstr "Une méta-propriété d'organisateur a été réorganisée."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
msgid "Gift card acceptance for another organizer has been added."
|
||||
@@ -18374,6 +18359,8 @@ msgstr "Un code d'urgence à deux facteurs a été généré."
|
||||
#: pretix/control/logdisplay.py
|
||||
msgid "Drift and throttle values for two-factor devices have been reset."
|
||||
msgstr ""
|
||||
"Les valeurs de déviation et de puissance pour les appareils à deux facteurs "
|
||||
"ont été réinitialisées."
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, python-brace-format
|
||||
@@ -19322,10 +19309,8 @@ msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
|
||||
#, fuzzy
|
||||
#| msgid "Authorized apps"
|
||||
msgid "Authorize"
|
||||
msgstr "App. autorisées"
|
||||
msgstr "Autoriser"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
|
||||
msgid "Error:"
|
||||
@@ -22231,15 +22216,11 @@ msgstr ""
|
||||
"comme abréviations pour faire référence à cet événement."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/events/create_basics.html
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "We strongly recommend against using short forms of more then 16 "
|
||||
#| "characters."
|
||||
msgid ""
|
||||
"We strongly recommend against using short forms of more than 16 characters."
|
||||
msgstr ""
|
||||
"Nous vous déconseillons fortement d'utiliser des formulaires courts de plus "
|
||||
"de 16 caractères."
|
||||
"Nous déconseillons vivement d'utiliser des abréviations de plus de 16 "
|
||||
"caractères."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/events/create_basics.html
|
||||
msgid "Display settings"
|
||||
@@ -28171,27 +28152,23 @@ msgstr "Backend d’authentification"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
msgid "Reset drift and throttle"
|
||||
msgstr ""
|
||||
msgstr "Réinitialisation de la déviation et de la puissance"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
#, fuzzy
|
||||
#| msgid "Disable two-factor authentication"
|
||||
msgid "Available two-factor authentication methods"
|
||||
msgstr "Désactiver l'authentification à deux facteurs"
|
||||
msgstr "Méthodes d'authentification à deux facteurs disponibles"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Expired since %(date)s"
|
||||
#, python-format
|
||||
msgid "1 failed attempt since %(date)s"
|
||||
msgid_plural "%(cnt)s failed attempts since %(date)s"
|
||||
msgstr[0] "Expiré depuis le %(date)s"
|
||||
msgstr[1] "Expiré depuis le %(date)s"
|
||||
msgstr[0] "1 tentative infructueuse depuis %(date)s"
|
||||
msgstr[1] "%(cnt)s tentatives infructueuses depuis le %(date)s"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Valid until %(date)s"
|
||||
#, python-format
|
||||
msgid "Currently locked until %(date)s"
|
||||
msgstr "Valable jusqu'au %(date)s"
|
||||
msgstr "Actuellement verrouillé jusqu'au %(date)s"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
msgid "Team memberships"
|
||||
@@ -28737,20 +28714,19 @@ msgid "Invalid code, please try again."
|
||||
msgstr "Code invalide, veuillez réessayer."
|
||||
|
||||
#: pretix/control/views/auth.py
|
||||
#, fuzzy, python-brace-format
|
||||
#| msgid "Invalid code, please try again."
|
||||
#, python-brace-format
|
||||
msgid "Invalid code. Please try again after waiting {value} minute."
|
||||
msgid_plural "Invalid code. Please try again after waiting {value} minutes."
|
||||
msgstr[0] "Code invalide, veuillez réessayer."
|
||||
msgstr[1] "Code invalide, veuillez réessayer."
|
||||
msgstr[0] "Code invalide. Veuillez réessayer après avoir patienté {value} minute."
|
||||
msgstr[1] "Code invalide. Veuillez réessayer après avoir patienté {value} minutes."
|
||||
|
||||
#: pretix/control/views/auth.py
|
||||
#, fuzzy, python-brace-format
|
||||
#| msgid "Invalid code, please try again."
|
||||
#, python-brace-format
|
||||
msgid "Invalid code. Please try again after waiting {value} second."
|
||||
msgid_plural "Invalid code. Please try again after waiting {value} seconds."
|
||||
msgstr[0] "Code invalide, veuillez réessayer."
|
||||
msgstr[1] "Code invalide, veuillez réessayer."
|
||||
msgstr[0] "Code invalide. Veuillez réessayer après avoir patienté {value} seconde."
|
||||
msgstr[1] ""
|
||||
"Code invalide. Veuillez réessayer après avoir patienté {value} secondes."
|
||||
|
||||
#: pretix/control/views/checkin.py
|
||||
msgid "The selected check-ins have been reverted."
|
||||
@@ -29632,11 +29608,9 @@ msgstr ""
|
||||
"Vous avez passé une commande dans un événement dont la devise est différente."
|
||||
|
||||
#: pretix/control/views/orders.py
|
||||
#, fuzzy
|
||||
#| msgid "You entered an order in an event with a different currency."
|
||||
msgid "You entered an order in an event that you do not have access to."
|
||||
msgstr ""
|
||||
"Vous avez passé une commande dans un événement dont la devise est différente."
|
||||
"Vous avez saisi une commande dans un événement auquel vous n'avez pas accès."
|
||||
|
||||
#: pretix/control/views/orders.py
|
||||
msgid ""
|
||||
@@ -30535,10 +30509,8 @@ msgstr ""
|
||||
"d'urgence précédents de cet utilisateur sont toujours actifs."
|
||||
|
||||
#: pretix/control/views/users.py
|
||||
#, fuzzy
|
||||
#| msgid "The password has been reset."
|
||||
msgid "The drift values for TOTP devices have been reset."
|
||||
msgstr "Le mot de passe a été réinitialisé."
|
||||
msgstr "Les valeurs de dérive des appareils TOTP ont été réinitialisées."
|
||||
|
||||
#: pretix/control/views/users.py
|
||||
msgid "The new user has been created."
|
||||
@@ -30553,14 +30525,12 @@ msgid "Bypass quota"
|
||||
msgstr "Outrepasser le quota"
|
||||
|
||||
#: pretix/control/views/vouchers.py
|
||||
#, fuzzy
|
||||
#| msgid "Widget"
|
||||
msgid "Budget"
|
||||
msgstr "Widget"
|
||||
msgstr "Budget"
|
||||
|
||||
#: pretix/control/views/vouchers.py
|
||||
msgid "Budget used"
|
||||
msgstr ""
|
||||
msgstr "Budget utilisé"
|
||||
|
||||
#: pretix/control/views/vouchers.py
|
||||
msgid "The requested voucher does not exist."
|
||||
@@ -35309,29 +35279,29 @@ msgid ""
|
||||
"Not sure yet? You can complete your order first and then select a payment "
|
||||
"method later."
|
||||
msgstr ""
|
||||
"Vous hésitez encore ? Vous pouvez d'abord finaliser votre commande, puis "
|
||||
"choisir un mode de paiement ultérieurement."
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
msgid ""
|
||||
"To do so, please first remove the payment methods you already selected above."
|
||||
msgstr ""
|
||||
"Pour ce faire, veuillez d'abord supprimer les moyens de paiement que vous "
|
||||
"avez déjà sélectionnés ci-dessus."
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "The payment has to be completed before %(date)s."
|
||||
#, python-format
|
||||
msgid "Your payment needs to be completed within %(minutes)s minutes."
|
||||
msgstr "Le paiement doit être effectué avant le %(date)s."
|
||||
msgstr "otre paiement doit être effectué dans les %(minutes)s minutes."
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "The payment has to be completed before %(date)s."
|
||||
#, python-format
|
||||
msgid "Your payment needs to be completed by %(deadline)s."
|
||||
msgstr "Le paiement doit être effectué avant le %(date)s."
|
||||
msgstr "Votre paiement doit être effectué avant le %(deadline)s."
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
#, fuzzy
|
||||
#| msgid "Proceed with reset"
|
||||
msgid "Proceed without selection"
|
||||
msgstr "Procéder à la réinitialisation"
|
||||
msgstr "Continuer sans sélectionner"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
|
||||
msgid "Before we continue, we need you to answer some questions."
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
|
||||
"PO-Revision-Date: 2026-08-01 21:00+0000\n"
|
||||
"PO-Revision-Date: 2026-09-30 17:00+0000\n"
|
||||
"Last-Translator: szurofkamarciidfbe08444ef04788 <szurofkamarcii@gmail.com>\n"
|
||||
"Language-Team: Hungarian <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"hu/>\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.7.1\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "English"
|
||||
@@ -93,7 +93,7 @@ msgstr "Héber"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Hungarian"
|
||||
msgstr ""
|
||||
msgstr "Magyar"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Indonesian"
|
||||
@@ -426,10 +426,8 @@ msgid "You cannot exchange a medium for a medium."
|
||||
msgstr "Nem cserélhet adathordozót adathordozóra."
|
||||
|
||||
#: pretix/api/views/checkin.py
|
||||
#, fuzzy
|
||||
#| msgid "Product does not support medium exchange."
|
||||
msgid "You cannot simulate a medium exchange."
|
||||
msgstr "A termék nem támogatja az adathordozó-cserét."
|
||||
msgstr "Az adathordozó szimuláció nem engedélyezett."
|
||||
|
||||
#: pretix/api/views/oauth.py pretix/control/logdisplay.py
|
||||
#, python-brace-format
|
||||
@@ -1779,16 +1777,12 @@ msgid "Transmission type"
|
||||
msgstr "Továbbítás típusa"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
#, fuzzy
|
||||
#| msgid "Transmission type"
|
||||
msgid "Transmission status"
|
||||
msgstr "Továbbítás típusa"
|
||||
msgstr "Továbbítás állapot"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
#, fuzzy
|
||||
#| msgid "Transmission type"
|
||||
msgid "Transmission date"
|
||||
msgstr "Továbbítás típusa"
|
||||
msgstr "Továbbítás dátum"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
msgid "Cancellation"
|
||||
@@ -3078,9 +3072,6 @@ msgid "Street"
|
||||
msgstr "Utca"
|
||||
|
||||
#: pretix/base/forms/questions.py
|
||||
#, fuzzy
|
||||
#| msgctxt "address"
|
||||
#| msgid "State"
|
||||
msgid "State"
|
||||
msgstr "Állam/tartomány"
|
||||
|
||||
@@ -4429,10 +4420,8 @@ msgid "Initialization date"
|
||||
msgstr "Inicializálás dátuma"
|
||||
|
||||
#: pretix/base/models/devices.py
|
||||
#, fuzzy
|
||||
#| msgid "Save to profile"
|
||||
msgid "Security profile"
|
||||
msgstr "Mentés a profilhoz"
|
||||
msgstr "Biztonsági profil"
|
||||
|
||||
#: pretix/base/models/discount.py
|
||||
msgctxt "subevent"
|
||||
@@ -5864,10 +5853,8 @@ msgid "Maximum value"
|
||||
msgstr "Maximális érték"
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
#, fuzzy
|
||||
#| msgid "Maximum length"
|
||||
msgid "Minimum length"
|
||||
msgstr "Maximális hossz"
|
||||
msgstr "Minimum hossz"
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
msgid "Maximum length"
|
||||
@@ -5938,10 +5925,8 @@ msgid "The maximum value must not be lower than the minimum value."
|
||||
msgstr "A maximális érték nem lehet kisebb a minimális értéknél."
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
#, fuzzy
|
||||
#| msgid "The maximum value must not be lower than the minimum value."
|
||||
msgid "The maximum length must not be shorter than the minimum length."
|
||||
msgstr "A maximális érték nem lehet kisebb a minimális értéknél."
|
||||
msgstr "A maximális hossz nem lehet rövidebb a minimális hossznál."
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
msgid ""
|
||||
@@ -7489,9 +7474,6 @@ msgid "The payment for this invoice has already been received."
|
||||
msgstr "A számla már ki lett fizetve."
|
||||
|
||||
#: pretix/base/payment.py
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "This payment is already being processed and can not be canceled any more."
|
||||
msgid ""
|
||||
"This payment is already being processed and cannot be canceled any more."
|
||||
msgstr "Ez a fizetés már feldolgozás alatt áll, és többé nem törölhető."
|
||||
@@ -8267,16 +8249,12 @@ msgid "Presale end"
|
||||
msgstr "Elővétel vége"
|
||||
|
||||
#: pretix/base/reldate.py
|
||||
#, fuzzy
|
||||
#| msgid "Order email"
|
||||
msgid "Order creation"
|
||||
msgstr "Rendelés e-mail címe"
|
||||
msgstr "Rendelés létrehozás"
|
||||
|
||||
#: pretix/base/reldate.py
|
||||
#, fuzzy
|
||||
#| msgid "Order expired"
|
||||
msgid "Order expiry"
|
||||
msgstr "Rendelés lejárt"
|
||||
msgstr "Rendelés lejárat"
|
||||
|
||||
#: pretix/base/reldate.py
|
||||
msgid "before"
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
|
||||
"PO-Revision-Date: 2026-09-07 18:00+0000\n"
|
||||
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
|
||||
"PO-Revision-Date: 2026-10-05 14:00+0000\n"
|
||||
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
|
||||
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"ja/>\n"
|
||||
"Language: ja\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Weblate 2026.9\n"
|
||||
"X-Generator: Weblate 2026.10\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "English"
|
||||
@@ -1776,16 +1776,12 @@ msgid "Transmission type"
|
||||
msgstr "送信タイプ"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
#, fuzzy
|
||||
#| msgid "Transmission type"
|
||||
msgid "Transmission status"
|
||||
msgstr "送信タイプ"
|
||||
msgstr "送信ステータス"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
#, fuzzy
|
||||
#| msgid "Transmission type"
|
||||
msgid "Transmission date"
|
||||
msgstr "送信タイプ"
|
||||
msgstr "送信日"
|
||||
|
||||
#: pretix/base/exporters/invoices.py
|
||||
msgid "Cancellation"
|
||||
@@ -4400,10 +4396,8 @@ msgid "Initialization date"
|
||||
msgstr "初期化日"
|
||||
|
||||
#: pretix/base/models/devices.py
|
||||
#, fuzzy
|
||||
#| msgid "Save to profile"
|
||||
msgid "Security profile"
|
||||
msgstr "プロフィールに保存します"
|
||||
msgstr "セキュリティ・プロフィール"
|
||||
|
||||
#: pretix/base/models/discount.py
|
||||
msgctxt "subevent"
|
||||
@@ -5779,10 +5773,8 @@ msgid "Maximum value"
|
||||
msgstr "最大値"
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
#, fuzzy
|
||||
#| msgid "Maximum length"
|
||||
msgid "Minimum length"
|
||||
msgstr "最大長"
|
||||
msgstr "最小長"
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
msgid "Maximum length"
|
||||
@@ -5853,10 +5845,8 @@ msgid "The maximum value must not be lower than the minimum value."
|
||||
msgstr "最大値は最小値より低くできません。"
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
#, fuzzy
|
||||
#| msgid "The maximum value must not be lower than the minimum value."
|
||||
msgid "The maximum length must not be shorter than the minimum length."
|
||||
msgstr "最大値は最小値より低くできません。"
|
||||
msgstr "最大長は最小長より短くできません。"
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
msgid ""
|
||||
@@ -10269,10 +10259,8 @@ msgstr ""
|
||||
"す。上記で設定された\"支払いの最終日\"を過ぎてからは支払いを受け付けません。"
|
||||
|
||||
#: pretix/base/settings.py
|
||||
#, fuzzy
|
||||
#| msgid "Generate invoices for Sales channels"
|
||||
msgid "Allow postponed payment choice for sales channels"
|
||||
msgstr "請求書を生成する販売チャネル"
|
||||
msgstr "販売チャネルの支払延期選択を許可する"
|
||||
|
||||
#: pretix/base/settings.py
|
||||
msgid ""
|
||||
@@ -10280,6 +10268,9 @@ msgid ""
|
||||
"their order without selecting a payment method. This is useful whenever "
|
||||
"orders are not created by the same person who is making the payment."
|
||||
msgstr ""
|
||||
"販売チャネルで支払いの延期が許可されている場合、顧客は支払い方法を選択せずに"
|
||||
"注文を完了できます。これは、支払いを行う同じ人物によって注文が作成されていな"
|
||||
"い場合に便利です。"
|
||||
|
||||
#: pretix/base/settings.py
|
||||
msgid "Show start date"
|
||||
@@ -16362,7 +16353,7 @@ msgstr "第三"
|
||||
#: pretix/control/forms/rrule.py
|
||||
msgctxt "rrule"
|
||||
msgid "fourth"
|
||||
msgstr ""
|
||||
msgstr "4番目"
|
||||
|
||||
#: pretix/control/forms/rrule.py
|
||||
msgctxt "rrule"
|
||||
@@ -17442,28 +17433,20 @@ msgid "Queued emails have been aborted."
|
||||
msgstr "キューに入っていた電子メールが、破棄されました。"
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The property has been created."
|
||||
msgid "An organizer meta property has been created."
|
||||
msgstr "プロパティが作成されました。"
|
||||
msgstr "主催者メタプロパティが作成されました。"
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The selected property has been deleted."
|
||||
msgid "An organizer meta property has been deleted."
|
||||
msgstr "選択されたプロパティは削除されました。"
|
||||
msgstr "オーガナイザーのメタプロパティが削除されました。"
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The organizer has been changed."
|
||||
msgid "An organizer meta property has been changed."
|
||||
msgstr "主催者が変更されました。"
|
||||
msgstr "主催者のメタプロパティが変更されました。"
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, fuzzy
|
||||
#| msgid "The product has been reordered."
|
||||
msgid "An organizer meta property has been reordered."
|
||||
msgstr "製品の並び順が変更されました。"
|
||||
msgstr "オーガナイザーのメタプロパティが再配置されました。"
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
msgid "Gift card acceptance for another organizer has been added."
|
||||
@@ -17673,7 +17656,7 @@ msgstr "二要素の緊急コードが生成されました。"
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
msgid "Drift and throttle values for two-factor devices have been reset."
|
||||
msgstr ""
|
||||
msgstr "二要素装置のドリフト値とスロットル値がリセットされました。"
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
#, python-brace-format
|
||||
@@ -18611,10 +18594,8 @@ msgid "Cancel"
|
||||
msgstr "キャンセル"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
|
||||
#, fuzzy
|
||||
#| msgid "Authorized apps"
|
||||
msgid "Authorize"
|
||||
msgstr "承認されたアプリ"
|
||||
msgstr "承認する"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
|
||||
msgid "Error:"
|
||||
@@ -21418,10 +21399,6 @@ msgstr ""
|
||||
"銀行振込の参照としていくつかの場所で使用します。"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/events/create_basics.html
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "We strongly recommend against using short forms of more then 16 "
|
||||
#| "characters."
|
||||
msgid ""
|
||||
"We strongly recommend against using short forms of more than 16 characters."
|
||||
msgstr "16文字を超える短縮形の使用は強くお勧めしません。"
|
||||
@@ -27136,26 +27113,22 @@ msgstr "認証バックエンド"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
msgid "Reset drift and throttle"
|
||||
msgstr ""
|
||||
msgstr "ドリフトとスロットルをリセットする"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
#, fuzzy
|
||||
#| msgid "Disable two-factor authentication"
|
||||
msgid "Available two-factor authentication methods"
|
||||
msgstr "二要素認証を無効にする"
|
||||
msgstr "選択可能な二要素認証"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Expired since %(date)s"
|
||||
#, python-format
|
||||
msgid "1 failed attempt since %(date)s"
|
||||
msgid_plural "%(cnt)s failed attempts since %(date)s"
|
||||
msgstr[0] "%(date)s以降失効"
|
||||
msgstr[0] "%(date)s以降%(cnt)s回失敗"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Valid until %(date)s"
|
||||
#, python-format
|
||||
msgid "Currently locked until %(date)s"
|
||||
msgstr "%(date)sまで有効"
|
||||
msgstr "現時点で%(date)sまでロックされています"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/users/form.html
|
||||
msgid "Team memberships"
|
||||
@@ -27676,18 +27649,16 @@ msgid "Invalid code, please try again."
|
||||
msgstr "無効なコードです。もう一度お試しください。"
|
||||
|
||||
#: pretix/control/views/auth.py
|
||||
#, fuzzy, python-brace-format
|
||||
#| msgid "Invalid code, please try again."
|
||||
#, python-brace-format
|
||||
msgid "Invalid code. Please try again after waiting {value} minute."
|
||||
msgid_plural "Invalid code. Please try again after waiting {value} minutes."
|
||||
msgstr[0] "無効なコードです。もう一度お試しください。"
|
||||
msgstr[0] "無効なコードです。{value}分後再度お試しください。"
|
||||
|
||||
#: pretix/control/views/auth.py
|
||||
#, fuzzy, python-brace-format
|
||||
#| msgid "Invalid code, please try again."
|
||||
#, python-brace-format
|
||||
msgid "Invalid code. Please try again after waiting {value} second."
|
||||
msgid_plural "Invalid code. Please try again after waiting {value} seconds."
|
||||
msgstr[0] "無効なコードです。もう一度お試しください。"
|
||||
msgstr[0] "無効なコードです。{value}秒後再度お試しください。"
|
||||
|
||||
#: pretix/control/views/checkin.py
|
||||
msgid "The selected check-ins have been reverted."
|
||||
@@ -28533,10 +28504,8 @@ msgid "You entered an order in an event with a different currency."
|
||||
msgstr "イベントで異なる通貨で注文を入力しました。"
|
||||
|
||||
#: pretix/control/views/orders.py
|
||||
#, fuzzy
|
||||
#| msgid "You entered an order in an event with a different currency."
|
||||
msgid "You entered an order in an event that you do not have access to."
|
||||
msgstr "イベントで異なる通貨で注文を入力しました。"
|
||||
msgstr "アクセスできないイベントに注文が入力されました。"
|
||||
|
||||
#: pretix/control/views/orders.py
|
||||
msgid ""
|
||||
@@ -29394,10 +29363,8 @@ msgstr ""
|
||||
"ください。このユーザーの過去の緊急トークンは引き続き有効です。"
|
||||
|
||||
#: pretix/control/views/users.py
|
||||
#, fuzzy
|
||||
#| msgid "The password has been reset."
|
||||
msgid "The drift values for TOTP devices have been reset."
|
||||
msgstr "パスワードがリセットされました。"
|
||||
msgstr "TOTP デバイスのドリフト値がリセットされました。"
|
||||
|
||||
#: pretix/control/views/users.py
|
||||
msgid "The new user has been created."
|
||||
@@ -29412,14 +29379,12 @@ msgid "Bypass quota"
|
||||
msgstr "クォータをバイパスする"
|
||||
|
||||
#: pretix/control/views/vouchers.py
|
||||
#, fuzzy
|
||||
#| msgid "Widget"
|
||||
msgid "Budget"
|
||||
msgstr "ウィジェット"
|
||||
msgstr "予算"
|
||||
|
||||
#: pretix/control/views/vouchers.py
|
||||
msgid "Budget used"
|
||||
msgstr ""
|
||||
msgstr "消化済み予算"
|
||||
|
||||
#: pretix/control/views/vouchers.py
|
||||
msgid "The requested voucher does not exist."
|
||||
@@ -33979,29 +33944,27 @@ msgid ""
|
||||
"Not sure yet? You can complete your order first and then select a payment "
|
||||
"method later."
|
||||
msgstr ""
|
||||
"まだ確かではありませんか?ご注文を先に完了し、その後でお支払い方法を選択でき"
|
||||
"ます。"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
msgid ""
|
||||
"To do so, please first remove the payment methods you already selected above."
|
||||
msgstr ""
|
||||
msgstr "そのためには、まず上記で既に選択された支払い方法を削除してください。"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Your event needs to be over to use this feature."
|
||||
#, python-format
|
||||
msgid "Your payment needs to be completed within %(minutes)s minutes."
|
||||
msgstr "この機能を使用するには、イベントが終了している必要があります。"
|
||||
msgstr "支払いは、%(minutes)s分以内に完了する必要があります。"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Your event needs to be over to use this feature."
|
||||
#, python-format
|
||||
msgid "Your payment needs to be completed by %(deadline)s."
|
||||
msgstr "この機能を使用するには、イベントが終了している必要があります。"
|
||||
msgstr "支払いは、%(deadline)sまでに完了させる必要があります。"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
#, fuzzy
|
||||
#| msgid "Proceed with reset"
|
||||
msgid "Proceed without selection"
|
||||
msgstr "リセットを実行"
|
||||
msgstr "選択なしで進む"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
|
||||
msgid "Before we continue, we need you to answer some questions."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,9 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
|
||||
"PO-Revision-Date: 2025-10-17 16:55+0000\n"
|
||||
"Last-Translator: fd <fd@denkena-consulting.com>\n"
|
||||
"PO-Revision-Date: 2026-10-02 05:00+0000\n"
|
||||
"Last-Translator: Translate pretix user 594 "
|
||||
"<ivagvr@users.noreply.translate.pretix.eu>\n"
|
||||
"Language-Team: Russian <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
"js/ru/>\n"
|
||||
"Language: ru\n"
|
||||
@@ -18,7 +19,7 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
|
||||
"X-Generator: Weblate 5.13.3\n"
|
||||
"X-Generator: Weblate 2026.10\n"
|
||||
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
|
||||
msgid "Marked as paid"
|
||||
@@ -78,10 +79,8 @@ msgid "SOFORT"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
|
||||
#, fuzzy
|
||||
#| msgid "Yes"
|
||||
msgid "eps"
|
||||
msgstr "Да"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
|
||||
msgid "MyBank"
|
||||
@@ -147,7 +146,7 @@ msgstr "Подтверждается ваш платёж…"
|
||||
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
|
||||
msgid "Payment method unavailable"
|
||||
msgstr ""
|
||||
msgstr "Способ оплаты недоступен"
|
||||
|
||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js
|
||||
msgid "Placed orders"
|
||||
@@ -200,21 +199,16 @@ msgid "Switch check-in list"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
#, fuzzy
|
||||
#| msgid "Check-in QR"
|
||||
msgid "Search results"
|
||||
msgstr "QR-код для регистрации"
|
||||
msgstr "Результаты поиска"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
#, fuzzy
|
||||
#| msgctxt "widget"
|
||||
#| msgid "Close ticket shop"
|
||||
msgid "No tickets found"
|
||||
msgstr "Закрыть билетную кассу"
|
||||
msgstr "Нет доступных билетов"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Result"
|
||||
msgstr ""
|
||||
msgstr "Результат"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "This ticket requires special attention"
|
||||
@@ -226,7 +220,7 @@ msgstr ""
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Entry"
|
||||
msgstr ""
|
||||
msgstr "Вход"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Exit"
|
||||
@@ -275,7 +269,7 @@ msgstr ""
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Ticket not paid"
|
||||
msgstr ""
|
||||
msgstr "Билет не оплачен"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "This ticket is not yet paid. Do you want to continue anyways?"
|
||||
@@ -295,21 +289,19 @@ msgstr ""
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Ticket already used"
|
||||
msgstr ""
|
||||
msgstr "Повторный проход по билету"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Information required"
|
||||
msgstr ""
|
||||
msgstr "Необходима информация"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
#, fuzzy
|
||||
#| msgid "Unknown error."
|
||||
msgid "Unknown ticket"
|
||||
msgstr "Неизвестная ошибка."
|
||||
msgstr "Неизвестный билет"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Ticket type not allowed here"
|
||||
msgstr ""
|
||||
msgstr "Здесь нельзя пройти по этому билету"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Entry not allowed"
|
||||
@@ -317,11 +309,11 @@ msgstr ""
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Ticket code revoked/changed"
|
||||
msgstr ""
|
||||
msgstr "Код билета аннулирован и/или изменен"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Ticket blocked"
|
||||
msgstr ""
|
||||
msgstr "Билет заблокирован"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Ticket not valid at this time"
|
||||
@@ -337,7 +329,7 @@ msgstr ""
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
msgid "Order not approved"
|
||||
msgstr ""
|
||||
msgstr "Заказ не подтвержден"
|
||||
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
#, fuzzy
|
||||
@@ -377,32 +369,20 @@ msgstr "Закрыть"
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js
|
||||
#: pretix/static/pretixpresale/js/ui/main.js
|
||||
#, fuzzy
|
||||
#| msgid "Cart expired"
|
||||
msgid "required"
|
||||
msgstr "Срок действия корзины истёк"
|
||||
msgstr "обязательно"
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "Your request has been queued on the server and will now be processed. "
|
||||
#| "Depending on the size of your event, this might take up to a few minutes."
|
||||
msgid ""
|
||||
"Your request is currently being processed. Depending on the size of your "
|
||||
"event, this might take up to a few minutes."
|
||||
msgstr ""
|
||||
"Ваш запрос поставлен в очередь на сервере и будет обработан. В зависимости "
|
||||
"от масштаба вашего мероприятия это может занять до нескольких минут."
|
||||
"Ваш запрос принят в обработку. В зависимости от масштаба вашего мероприятия "
|
||||
"это может занять до нескольких минут."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "Your request has been queued on the server and will now be processed. "
|
||||
#| "Depending on the size of your event, this might take up to a few minutes."
|
||||
msgid "Your request has been queued on the server and will soon be processed."
|
||||
msgstr ""
|
||||
"Ваш запрос поставлен в очередь на сервере и будет обработан. В зависимости "
|
||||
"от масштаба вашего мероприятия это может занять до нескольких минут."
|
||||
msgstr "Ваш запрос поставлен в очередь на сервере и будет обработан."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js
|
||||
msgid ""
|
||||
@@ -473,7 +453,7 @@ msgstr "Чтобы скопировать, нажмите Ctrl-C."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
msgstr "Редактировать"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
|
||||
msgid "Visualize"
|
||||
@@ -532,7 +512,7 @@ msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
|
||||
msgid "Duplicate"
|
||||
msgstr ""
|
||||
msgstr "Дубликат"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
|
||||
msgctxt "entry_status"
|
||||
@@ -1215,15 +1195,15 @@ msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "Tuesday"
|
||||
msgstr ""
|
||||
msgstr "Вторник"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "Wednesday"
|
||||
msgstr ""
|
||||
msgstr "Среда"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "Thursday"
|
||||
msgstr ""
|
||||
msgstr "Четверг"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "Friday"
|
||||
@@ -1231,11 +1211,11 @@ msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "Saturday"
|
||||
msgstr ""
|
||||
msgstr "Суббота"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "Sunday"
|
||||
msgstr ""
|
||||
msgstr "Воскресенье"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "January"
|
||||
@@ -1251,23 +1231,23 @@ msgstr "март"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "April"
|
||||
msgstr "апрель"
|
||||
msgstr "Апрель"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "May"
|
||||
msgstr "май"
|
||||
msgstr "Май"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "June"
|
||||
msgstr "июнь"
|
||||
msgstr "Июнь"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "July"
|
||||
msgstr "июль"
|
||||
msgstr "Июль"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "August"
|
||||
msgstr "август"
|
||||
msgstr "Август"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "September"
|
||||
@@ -1275,7 +1255,7 @@ msgstr "сентябрь"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "October"
|
||||
msgstr "октябрь"
|
||||
msgstr "Октябрь"
|
||||
|
||||
#: pretix/static/pretixpresale/widget/src/i18n.ts
|
||||
msgid "November"
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
|
||||
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
|
||||
"Last-Translator: Linnea Thelander <linnea@coeo.events>\n"
|
||||
"PO-Revision-Date: 2026-09-30 17:00+0000\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"sv/>\n"
|
||||
"Language: sv\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.9\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "English"
|
||||
@@ -19239,7 +19239,6 @@ msgstr ""
|
||||
#: pretix/presale/templates/pretixpresale/event/order_pay.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order_pay_change.html
|
||||
#: pretix/presale/templates/pretixpresale/event/position_change.html
|
||||
#, fuzzy
|
||||
msgid "Continue"
|
||||
msgstr "Fortsätt"
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
|
||||
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
|
||||
"Last-Translator: Linnea Thelander <linnea@coeo.events>\n"
|
||||
"PO-Revision-Date: 2026-09-30 17:00+0000\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
"js/sv/>\n"
|
||||
"Language: sv\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.9\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
|
||||
msgid "Marked as paid"
|
||||
@@ -133,7 +133,6 @@ msgstr "Mercado Pago"
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js
|
||||
#, fuzzy
|
||||
msgid "Continue"
|
||||
msgstr "Fortsätt"
|
||||
|
||||
|
||||
+1513
-1233
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
|
||||
"PO-Revision-Date: 2026-09-29 09:51+0000\n"
|
||||
"PO-Revision-Date: 2026-10-02 05:00+0000\n"
|
||||
"Last-Translator: Nate Horst <nate@agcthailand.org>\n"
|
||||
"Language-Team: Thai <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
"th/>\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
"X-Generator: Weblate 2026.10\n"
|
||||
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
|
||||
msgid "Marked as paid"
|
||||
@@ -426,7 +426,7 @@ msgstr "แก้ไข"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
|
||||
msgid "Visualize"
|
||||
msgstr ""
|
||||
msgstr "แสดงภาพผัง"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
|
||||
msgid ""
|
||||
@@ -434,10 +434,12 @@ msgid ""
|
||||
"or variations are not contained in any of your rule parts so people with "
|
||||
"these tickets will not get in:"
|
||||
msgstr ""
|
||||
"กฎของคุณมีการกรองตามสินค้าหรือรูปแบบย่อยเสมอ แต่สินค้าหรือรูปแบบย่อยต่อไปนี้ไม่ได้ถูกรวมอยู่ในส่วนใ"
|
||||
"ดของกฎ คุณจึงส่งผลให้ผู้ที่ถือตั๋วเหล่านี้จะไม่สามารถผ่านเข้างานได้:"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
|
||||
msgid "Please double-check if this was intentional."
|
||||
msgstr ""
|
||||
msgstr "โปรดตรวจสอบอีกครั้งว่าเป็นการดำเนินการโดยตั้งใจหรือไม่"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
|
||||
msgid "All of the conditions below (AND)"
|
||||
|
||||
@@ -161,7 +161,12 @@ class OrderMailForm(BaseMailForm):
|
||||
),
|
||||
label=pgettext_lazy('sendmail_form', 'Restrict to products'),
|
||||
required=True,
|
||||
queryset=Item.objects.none()
|
||||
queryset=Item.objects.none(),
|
||||
help_text=pgettext_lazy(
|
||||
'sendmail_form',
|
||||
'There may be multiple mails sent out to the same mail address if one order contains multiple attendee '
|
||||
'products for it, if you restrict to products while also restricting mails to attendees only. '
|
||||
'This is intended, as every one of those get linked to their own separate order page restricted to only that product.')
|
||||
)
|
||||
filter_checkins = forms.BooleanField(
|
||||
label=_('Filter check-in status'),
|
||||
@@ -374,6 +379,11 @@ class RuleForm(FormPlaceholderMixin, I18nModelForm):
|
||||
del self.fields['subevent']
|
||||
|
||||
self.fields['limit_products'].queryset = Item.objects.filter(event=self.event)
|
||||
self.fields['limit_products'].help_text = pgettext_lazy(
|
||||
'sendmail_form',
|
||||
'There may be multiple mails sent out to the same mail address if one order contains multiple attendee '
|
||||
'products for it, if you restrict to products while also restricting mails to attendees only. '
|
||||
'This is intended, as every one of those get linked to their own separate order page restricted to only that product.')
|
||||
|
||||
self.fields['schedule_type'] = forms.ChoiceField(
|
||||
label=_('Type of schedule time'),
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import logging
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from dateutil.tz import datetime_exists
|
||||
@@ -39,6 +40,8 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.base import LoggingMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScheduledMail(models.Model):
|
||||
STATE_SCHEDULED = 'scheduled'
|
||||
@@ -115,7 +118,7 @@ class ScheduledMail(models.Model):
|
||||
|
||||
filter_orders_by_op = False
|
||||
op_qs = OrderPosition.objects.filter(
|
||||
order__event=self.event,
|
||||
order__event=e,
|
||||
canceled=False,
|
||||
)
|
||||
|
||||
@@ -167,14 +170,58 @@ class ScheduledMail(models.Model):
|
||||
for o in orders:
|
||||
with language(o.locale, e.settings.region):
|
||||
positions = list(o.positions.all())
|
||||
o_sent = False
|
||||
send_to_order = send_to_orders
|
||||
|
||||
try:
|
||||
ia = o.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = InvoiceAddress(order=o)
|
||||
|
||||
if send_to_orders and o.email:
|
||||
if send_to_attendees:
|
||||
parent_op = None
|
||||
sent_to_positions = set()
|
||||
for p in positions:
|
||||
if p.addon_to_id is None:
|
||||
# this op might have matching add-ons, so save for later
|
||||
parent_op = p
|
||||
elif not parent_op or p.addon_to_id != parent_op.id:
|
||||
# this op is an add-on, but not to the current parent_op
|
||||
# something got mixed up as add-ons should always come directly after their parent
|
||||
logger.warning(f"Add-ons are mixed up for position #{p.positionid} in order {o.full_code}")
|
||||
continue
|
||||
|
||||
if p.id not in position_ids:
|
||||
# not a matching op, just there for parent_op
|
||||
continue
|
||||
|
||||
if p.addon_to_id and (
|
||||
not p.attendee_email or p.attendee_email == parent_op.attendee_email
|
||||
):
|
||||
# if op is addon and either has no mail or the same as parent => send to parent
|
||||
p = parent_op
|
||||
|
||||
if p.pk in sent_to_positions:
|
||||
# this position already got an email
|
||||
continue
|
||||
|
||||
if not p.attendee_email or p.attendee_email == o.email:
|
||||
# no email or same as order => send to order
|
||||
send_to_order = True
|
||||
continue
|
||||
|
||||
email_ctx = get_email_context(
|
||||
event=e,
|
||||
order=o,
|
||||
invoice_address=ia,
|
||||
position=p,
|
||||
event_or_subevent=self.subevent or e,
|
||||
)
|
||||
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
|
||||
attach_ical=self.rule.attach_ical,
|
||||
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
|
||||
sent_to_positions.add(p.pk)
|
||||
|
||||
if send_to_order and o.email:
|
||||
email_ctx = get_email_context(
|
||||
event=e,
|
||||
order=o,
|
||||
@@ -184,38 +231,6 @@ class ScheduledMail(models.Model):
|
||||
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
|
||||
attach_ical=self.rule.attach_ical,
|
||||
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
|
||||
o_sent = True
|
||||
|
||||
if send_to_attendees:
|
||||
if not self.rule.all_products:
|
||||
positions = [p for p in positions if p.item_id in limit_products]
|
||||
if self.subevent_id:
|
||||
positions = [p for p in positions if p.subevent_id == self.subevent_id]
|
||||
|
||||
for p in positions:
|
||||
if p.id in position_ids:
|
||||
if p.attendee_email and (p.attendee_email != o.email or not o_sent):
|
||||
email_ctx = get_email_context(
|
||||
event=e,
|
||||
order=o,
|
||||
invoice_address=ia,
|
||||
position=p,
|
||||
event_or_subevent=self.subevent or e,
|
||||
)
|
||||
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
|
||||
attach_ical=self.rule.attach_ical,
|
||||
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
|
||||
elif not o_sent and o.email:
|
||||
email_ctx = get_email_context(
|
||||
event=e,
|
||||
order=o,
|
||||
invoice_address=ia,
|
||||
event_or_subevent=self.subevent or e,
|
||||
)
|
||||
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
|
||||
attach_ical=self.rule.attach_ical,
|
||||
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
|
||||
o_sent = True
|
||||
|
||||
self.last_successful_order_id = o.pk
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
@@ -43,6 +44,8 @@ from pretix.base.services.mail import mail
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.celery_app import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _chunks(lst, n):
|
||||
"""
|
||||
@@ -70,6 +73,8 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = InvoiceAddress(order=o)
|
||||
|
||||
parent_op = None
|
||||
sent_to_positions = set()
|
||||
if recipients in ('both', 'attendees'):
|
||||
for p in o.positions.annotate(
|
||||
any_checkins=Exists(
|
||||
@@ -85,10 +90,16 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
|
||||
)
|
||||
),
|
||||
).prefetch_related('addons', 'subevent'):
|
||||
if p.addon_to_id is not None:
|
||||
|
||||
if p.addon_to_id is None:
|
||||
parent_op = p
|
||||
elif not parent_op or p.addon_to_id != parent_op.id:
|
||||
# this op is an add-on, but not to the current parent_op
|
||||
# something got mixed up as add-ons should always come directly after their parent
|
||||
logger.warning(f"Add-ons are mixed up for position #{p.positionid} in order {o.full_code}")
|
||||
continue
|
||||
|
||||
if p.item_id not in items and not any(a.item_id in items for a in p.addons.all()):
|
||||
if p.item_id not in items:
|
||||
continue
|
||||
|
||||
if filter_checkins:
|
||||
@@ -99,13 +110,11 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
|
||||
if not allowed:
|
||||
continue
|
||||
|
||||
if not p.attendee_email:
|
||||
if recipients == 'attendees':
|
||||
send_to_order = True
|
||||
continue
|
||||
|
||||
if p.attendee_email == o.email and send_to_order:
|
||||
continue
|
||||
if p.addon_to_id and (
|
||||
not p.attendee_email or p.attendee_email == parent_op.attendee_email
|
||||
):
|
||||
# if op is addon and either has no mail or the same as parent => send to parent
|
||||
p = parent_op
|
||||
|
||||
if subevent and p.subevent_id != subevent:
|
||||
continue
|
||||
@@ -116,6 +125,18 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
|
||||
if subevents_to and p.subevent.date_from >= subevents_to:
|
||||
continue
|
||||
|
||||
if not p.attendee_email:
|
||||
send_to_order = True
|
||||
continue
|
||||
|
||||
if p.pk in sent_to_positions:
|
||||
# this position already got an email
|
||||
continue
|
||||
|
||||
if p.attendee_email == o.email:
|
||||
send_to_order = True
|
||||
continue
|
||||
|
||||
with language(o.locale, event.settings.region):
|
||||
email_context = get_email_context(event=event, order=o, invoice_address=ia, position=p)
|
||||
outgoing_mail = mail(
|
||||
@@ -137,6 +158,7 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
|
||||
user=user,
|
||||
data=outgoing_mail.log_data(),
|
||||
)
|
||||
sent_to_positions.add(p.pk)
|
||||
|
||||
if send_to_order and o.email:
|
||||
with language(o.locale, event.settings.region):
|
||||
|
||||
@@ -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,129 @@
|
||||
import os
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers, viewsets
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
|
||||
from .models import WalletLayout, WalletPlatformLayout
|
||||
from .styles import AVAILABLE_PLATFORMS, AVAILABLE_STYLES_DICT
|
||||
|
||||
|
||||
class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
|
||||
platform = serializers.ChoiceField(
|
||||
choices=[p.identifier for p in AVAILABLE_PLATFORMS]
|
||||
)
|
||||
style = serializers.CharField(allow_null=True, required=False)
|
||||
file_settings = serializers.JSONField(default=dict, required=False)
|
||||
|
||||
class Meta:
|
||||
model = WalletPlatformLayout
|
||||
fields = ("platform", "style", "layout", "file_settings")
|
||||
|
||||
def validate_layout(self, value):
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError(_("Layout must be a dict"))
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
platform = data.get("platform")
|
||||
style = data.get("style")
|
||||
layout = data.get("layout")
|
||||
if platform and style and layout:
|
||||
platform_styles = AVAILABLE_STYLES_DICT[platform]
|
||||
|
||||
if data["style"] not in platform_styles:
|
||||
raise ValidationError(_("Invalid style"))
|
||||
style = platform_styles[data["style"]]
|
||||
|
||||
obj = style(event=self.context["event"], layout=data["layout"])
|
||||
obj.validate()
|
||||
data["file_settings"] = obj.extract_file_settings(
|
||||
self.context["request"], data.get("file_settings", {})
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
ret["file_settings"] = {}
|
||||
for file_setting in instance.file_settings.all():
|
||||
try:
|
||||
url = file_setting.file.url
|
||||
except AttributeError:
|
||||
continue
|
||||
request = self.context["request"]
|
||||
ret["file_settings"][file_setting.key] = {
|
||||
"url": request.build_absolute_uri(url),
|
||||
"name": os.path.basename(file_setting.file.name).split('.', 1)[-1]
|
||||
}
|
||||
return ret
|
||||
|
||||
|
||||
class WalletLayoutSerializer(I18nAwareModelSerializer):
|
||||
platform_layouts = WalletPlatformLayoutSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = WalletLayout
|
||||
fields = ("id", "name", "platform_layouts")
|
||||
read_only_fields = ("id",)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs, event=self.context["event"])
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
platform_layouts = validated_data.pop("platform_layouts")
|
||||
for layout in platform_layouts:
|
||||
if layout["style"]:
|
||||
file_settings = layout.pop("file_settings", {})
|
||||
obj, _ = instance.platform_layouts.update_or_create(
|
||||
platform=layout["platform"], defaults=layout
|
||||
)
|
||||
for key, file in file_settings.items():
|
||||
if not file:
|
||||
obj.file_settings.filter(key=key).delete()
|
||||
|
||||
elif file != "keep":
|
||||
file_setting, _ = obj.file_settings.get_or_create(
|
||||
key=key
|
||||
)
|
||||
file_setting.file.save(os.path.basename(file.name), file)
|
||||
|
||||
instance.platform_layouts.exclude(
|
||||
platform__in={
|
||||
layout["platform"]
|
||||
for layout in platform_layouts
|
||||
if layout["style"] is not None
|
||||
}
|
||||
).delete()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WalletLayoutViewSet(viewsets.ModelViewSet):
|
||||
model = WalletLayout
|
||||
queryset = WalletLayout.objects.none()
|
||||
serializer_class = WalletLayoutSerializer
|
||||
permission = "event.settings.general:write"
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.wallet_layouts.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx["event"] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
super().perform_update(serializer)
|
||||
serializer.instance.log_action(
|
||||
action="pretix.plugins.wallet.layout.changed",
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files import File
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.control.forms import ClearableBasenameFileInput
|
||||
|
||||
from .models import WalletLayout, WalletLayoutItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_rsa_privkey(value: File):
|
||||
value = value.read().strip()
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode()
|
||||
if not value:
|
||||
return
|
||||
if not re.match(
|
||||
r"^-----BEGIN( (RSA |ENCRYPTED )?PRIVATE KEY-----).*-----END\1$",
|
||||
value,
|
||||
re.DOTALL,
|
||||
):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"This does not look like an RSA private key in PEM format (it misses the correct begin or end signifiers)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CertificateFileField(forms.FileField):
|
||||
widget = ClearableBasenameFileInput
|
||||
|
||||
def clean(self, value, *args, **kwargs):
|
||||
value = super().clean(value, *args, **kwargs)
|
||||
if isinstance(value, UploadedFile):
|
||||
value.open("rb")
|
||||
value.seek(0)
|
||||
content = value.read()
|
||||
if (
|
||||
content.startswith(b"-----BEGIN CERTIFICATE-----")
|
||||
and b"-----BEGIN CERTIFICATE-----" in content
|
||||
):
|
||||
return SimpleUploadedFile("cert.pem", content, "text/plain")
|
||||
|
||||
openssl_cmd = [
|
||||
"openssl",
|
||||
"x509",
|
||||
"-inform",
|
||||
"DER",
|
||||
"-outform",
|
||||
"PEM",
|
||||
]
|
||||
process = subprocess.Popen(
|
||||
openssl_cmd,
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE,
|
||||
)
|
||||
process.stdin.write(content)
|
||||
pem, error = process.communicate()
|
||||
if process.returncode != 0:
|
||||
logger.info("Trying to convert a DER to PEM failed: {}".format(error))
|
||||
raise ValidationError(
|
||||
_(
|
||||
"This does not look like a X509 certificate in either PEM or DER format"
|
||||
),
|
||||
)
|
||||
|
||||
return SimpleUploadedFile("cert.pem", pem, "text/plain")
|
||||
return value
|
||||
|
||||
|
||||
class PNGImageField(forms.FileField):
|
||||
widget = ClearableBasenameFileInput
|
||||
|
||||
def clean(self, value, *args, **kwargs):
|
||||
value = super().clean(value, *args, **kwargs)
|
||||
if isinstance(value, UploadedFile):
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
return value
|
||||
|
||||
value.open("rb")
|
||||
value.seek(0)
|
||||
try:
|
||||
with (
|
||||
Image.open(value, formats=settings.PILLOW_FORMATS_IMAGE) as im,
|
||||
tempfile.NamedTemporaryFile("rb", suffix=".png") as tmpfile,
|
||||
):
|
||||
im.save(tmpfile.name)
|
||||
tmpfile.seek(0)
|
||||
return SimpleUploadedFile(
|
||||
"picture.png", tmpfile.read(), "image png"
|
||||
)
|
||||
except IOError:
|
||||
logger.exception("Could not convert image to PNG.")
|
||||
raise ValidationError(
|
||||
_("The file you uploaded could not be converted to PNG format.")
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class WalletLayoutItemForm(forms.ModelForm):
|
||||
is_layouts = True
|
||||
layout = forms.ModelChoiceField(
|
||||
queryset=WalletLayout.objects.none(),
|
||||
label=_("Wallet Layout"),
|
||||
empty_label=_("(Event default)"),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = WalletLayoutItem
|
||||
fields = ("layout",)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs.pop("event")
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["layout"].queryset = event.wallet_layouts.all()
|
||||
|
||||
def save(self, commit=True):
|
||||
if self.cleaned_data["layout"] is None:
|
||||
if self.instance.pk:
|
||||
self.instance.delete()
|
||||
else:
|
||||
self.instance.layout = self.cleaned_data["layout"]
|
||||
return super().save(commit=commit)
|
||||
@@ -0,0 +1,99 @@
|
||||
# Generated by Django 5.2.13 on 2026-05-19 15:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0297_outgoingmail"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WalletLayout",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=190)),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="wallet_layouts",
|
||||
to="pretixbase.event",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WalletLayoutItem",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"item",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="walletlayout_assignments",
|
||||
to="pretixbase.item",
|
||||
),
|
||||
),
|
||||
(
|
||||
"layout",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="item_assignments",
|
||||
to="wallet.walletlayout",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("item", "layout")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WalletPlatformLayout",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("platform", models.CharField(max_length=10)),
|
||||
("style", models.CharField(max_length=255)),
|
||||
("layout", models.JSONField(default=dict)),
|
||||
(
|
||||
"parent",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="platform_layouts",
|
||||
to="wallet.walletlayout",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("parent", "platform")},
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
]
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-09 18:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("wallet", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="walletlayoutitem",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="walletlayout",
|
||||
name="default",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="walletlayoutitem",
|
||||
name="item",
|
||||
field=models.OneToOneField(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="walletlayout",
|
||||
to="pretixbase.item",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="walletlayout",
|
||||
constraint=models.UniqueConstraint(
|
||||
models.F("event"),
|
||||
condition=models.Q(("default", True)),
|
||||
name="one_default_wallet_per_event",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.13 on 2026-08-11 15:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("wallet", "0002_alter_walletlayoutitem_unique_together_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WalletLayoutFileSetting",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("key", models.CharField()),
|
||||
("file", models.FileField(upload_to="")),
|
||||
(
|
||||
"layout",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="file_settings",
|
||||
to="wallet.walletplatformlayout",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,127 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Q, constraints
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
|
||||
class WalletLayout(LoggedModel):
|
||||
event = models.ForeignKey(
|
||||
"pretixbase.Event", on_delete=models.CASCADE, related_name="wallet_layouts"
|
||||
)
|
||||
name = models.CharField(max_length=190, verbose_name=_("Name"))
|
||||
default = models.BooleanField(
|
||||
verbose_name=_("Default"),
|
||||
default=False,
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer="event__organizer")
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
constraints.UniqueConstraint(
|
||||
"event", condition=Q(default=True), name="one_default_wallet_per_event"
|
||||
)
|
||||
]
|
||||
ordering = ("-default", "name",)
|
||||
|
||||
|
||||
class WalletPlatformLayout(LoggedModel):
|
||||
parent = models.ForeignKey(
|
||||
WalletLayout, on_delete=models.CASCADE, related_name="platform_layouts"
|
||||
)
|
||||
|
||||
platform = models.CharField(max_length=10)
|
||||
style = models.CharField(max_length=255)
|
||||
layout = models.JSONField(default=dict)
|
||||
|
||||
objects = ScopedManager(organizer="parent__event__organizer")
|
||||
|
||||
class Meta:
|
||||
unique_together = (("parent", "platform"),)
|
||||
|
||||
@property
|
||||
def pass_layout(self):
|
||||
from pretix.plugins.wallet.styles import get_style
|
||||
|
||||
style = get_style(self.platform, self.style)
|
||||
if style:
|
||||
file_settings = {fs.key: fs.file for fs in self.file_settings.all()}
|
||||
return style(
|
||||
event=self.parent.event, layout=self.layout, file_settings=file_settings
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"Style {self.platform}.{self.style} not found")
|
||||
|
||||
|
||||
class WalletLayoutItem(models.Model):
|
||||
item = models.OneToOneField(
|
||||
"pretixbase.Item",
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="walletlayout",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
layout = models.ForeignKey(
|
||||
WalletLayout,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="item_assignments",
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
if self.layout and self.item.event != self.layout.event:
|
||||
raise ValidationError("cannot bind layout to item of different event")
|
||||
|
||||
|
||||
class WalletLayoutFileSetting(models.Model):
|
||||
layout = models.ForeignKey(
|
||||
WalletPlatformLayout, on_delete=models.CASCADE, related_name="file_settings"
|
||||
)
|
||||
key = models.CharField()
|
||||
file = models.FileField()
|
||||
|
||||
|
||||
# smth like this for apple, lets see what the best architecture for google will be
|
||||
# class AppleWalletPass(models.Model):
|
||||
# platform_layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.PROTECT)
|
||||
# order_position = models.ForeignKey(OrderPosition, on_delete=models.PROTECT)
|
||||
# content = models.BinaryField()
|
||||
# updated_at = models.DateTimeField(null=True, auto_now=True)
|
||||
# prob smth like this
|
||||
|
||||
class GoogleWalletType(models.TextChoices):
|
||||
CLASS = "CL", _("Class")
|
||||
OBJECT = "OB", _("Object")
|
||||
|
||||
class GoogleWalletInstance(models.Model):
|
||||
event = models.ForeignKey("pretixbase.Event", on_delete=models.CASCADE)
|
||||
type = models.CharField(choices=GoogleWalletType)
|
||||
identifier = models.CharField()
|
||||
data = models.JSONField()
|
||||
|
||||
class Meta:
|
||||
unique_together = (("type", "identifier"),)
|
||||
@@ -0,0 +1,609 @@
|
||||
from collections.abc import Callable
|
||||
from functools import cached_property
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.files import File
|
||||
from django.dispatch import receiver
|
||||
from django.templatetags.static import static
|
||||
from django.utils.formats import date_format
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
|
||||
from .signals import register_wallet_placeholders
|
||||
|
||||
|
||||
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) -> LazyI18nString:
|
||||
"""The human readable name of this placeholder"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def control_label(self) -> LazyI18nString:
|
||||
"""
|
||||
The human readable name of this placeholder shown in the backend.
|
||||
|
||||
Defaults to `label`
|
||||
"""
|
||||
return self.label
|
||||
|
||||
def render(self, **context):
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, **context):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class BaseWalletTextPlaceholder(BaseWalletPlaceholder):
|
||||
@property
|
||||
def content_type(self) -> str:
|
||||
return "text"
|
||||
|
||||
def render(self, **context) -> LazyI18nString | str | None:
|
||||
"""
|
||||
This method is called to generate the text that is being shown on the pass.
|
||||
You will be passed the keyword arguments specified in ``required_context``.
|
||||
You are expected to return a plain-text string.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, **context) -> LazyI18nString | str:
|
||||
"""
|
||||
This method is called to generate a text to be used in previews.
|
||||
|
||||
You will be passed sample instances of the arguments specified in ``required_context``.
|
||||
If those instances contain all data needed, you do not need to implement this.
|
||||
"""
|
||||
sample = self.render(**context)
|
||||
if sample is None:
|
||||
raise RuntimeError("`render` returned None when rendering a sample")
|
||||
return sample
|
||||
|
||||
|
||||
class BaseWalletImagePlaceholder(BaseWalletPlaceholder):
|
||||
@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: LazyI18nString,
|
||||
args: set[str],
|
||||
func: Callable[..., LazyI18nString | str | None],
|
||||
sample: (
|
||||
None | LazyI18nString | str | Callable[..., str | LazyI18nString]
|
||||
) = None,
|
||||
):
|
||||
self._identifier = identifier
|
||||
self._label = label
|
||||
self._args = args
|
||||
self.render = func
|
||||
self._sample = sample
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return self._label
|
||||
|
||||
@property
|
||||
def required_context(self) -> set[str]:
|
||||
return self._args
|
||||
|
||||
def render_sample(self, **context) -> LazyI18nString | str:
|
||||
if isinstance(self._sample, Callable):
|
||||
return self._sample(**context)
|
||||
elif self._sample:
|
||||
return self._sample
|
||||
else:
|
||||
return super().render_sample(**context)
|
||||
|
||||
|
||||
class FunctionalWalletImagePlaceholder(BaseWalletImagePlaceholder):
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
label: LazyI18nString,
|
||||
args: set[str],
|
||||
func: Callable[..., File | None],
|
||||
sample: None | str | Callable[..., str] = None,
|
||||
):
|
||||
self._identifier = identifier
|
||||
self._label = label
|
||||
self._args = args
|
||||
self.render = func
|
||||
self._sample = sample
|
||||
|
||||
@property
|
||||
def required_context(self) -> set[str]:
|
||||
return self._args
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return self._label
|
||||
|
||||
def render_sample(self, **context) -> str | None:
|
||||
if isinstance(self._sample, Callable):
|
||||
return self._sample(**context)
|
||||
return self._sample
|
||||
|
||||
|
||||
class MissingContextException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class 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))
|
||||
|
||||
|
||||
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 (
|
||||
order_position.addon_to.seat
|
||||
if order_position.addon_to_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 (
|
||||
subevent_or_event.date_admission.astimezone(subevent_or_event.timezone)
|
||||
if subevent_or_event.date_admission
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def date_from(subevent_or_event):
|
||||
return subevent_or_event.get_date_from_display(
|
||||
subevent_or_event.timezone, short=True
|
||||
)
|
||||
|
||||
|
||||
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, **kwargs):
|
||||
return [
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"event",
|
||||
LazyI18nString.from_gettext("Event"),
|
||||
{"event"},
|
||||
lambda event: event.name,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"event_slug",
|
||||
LazyI18nString.from_gettext("Event Slug"),
|
||||
{"event"},
|
||||
lambda event: event.slug,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"admission",
|
||||
LazyI18nString.from_gettext("Admission"),
|
||||
{"subevent_or_event"},
|
||||
admission,
|
||||
lambda subevent_or_event: subevent_or_event.get_date_from_display(
|
||||
subevent_or_event.timezone, short=True
|
||||
),
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"date_from",
|
||||
LazyI18nString.from_gettext("Begin"),
|
||||
{"subevent_or_event"},
|
||||
date_from,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"admission_or_date_from",
|
||||
LazyI18nString.from_gettext("Admission or Date From"),
|
||||
{"subevent_or_event"},
|
||||
admission_or_date_from,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"order",
|
||||
LazyI18nString.from_gettext("Order Code"),
|
||||
{"order"},
|
||||
lambda order: order.code,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"total",
|
||||
LazyI18nString.from_gettext("Order Total"),
|
||||
{"event", "order"},
|
||||
lambda event, order: money_filter(order.total, event.currency),
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"order_email",
|
||||
LazyI18nString.from_gettext("Order Email"),
|
||||
{"order"},
|
||||
lambda order: order.email,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"price",
|
||||
LazyI18nString.from_gettext("Item Price"),
|
||||
{"event", "order_position"},
|
||||
lambda event, order_position: money_filter(
|
||||
order_position.price, event.currency
|
||||
),
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"secret",
|
||||
LazyI18nString.from_gettext("Order Secret (QR-Code-Content)"),
|
||||
{"order_position"},
|
||||
lambda order_position: order_position.secret,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"item",
|
||||
LazyI18nString.from_gettext("Product"),
|
||||
{"item"},
|
||||
lambda item: item.name,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"item_description",
|
||||
LazyI18nString.from_gettext("Product description"),
|
||||
{"item"},
|
||||
lambda item: item.description,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"item_with_variation",
|
||||
LazyI18nString.from_gettext("Product and variation"),
|
||||
{"order_position"},
|
||||
lambda order_position: (
|
||||
f"{order_position.item.name} - {order_position.variation}"
|
||||
if order_position.variation
|
||||
else order_position.item.name
|
||||
),
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"seat",
|
||||
LazyI18nString.from_gettext("Seat: Full name"),
|
||||
{"event", "seat"},
|
||||
lambda event, seat: (
|
||||
seat
|
||||
if seat
|
||||
else (
|
||||
LazyI18nString.from_gettext("General admission")
|
||||
if event.seating_plan_id is not None
|
||||
else ""
|
||||
)
|
||||
),
|
||||
LazyI18nString.from_gettext("Ground floor, Row 3, Seat 4"),
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"seat_zone",
|
||||
LazyI18nString.from_gettext("Seat: zone"),
|
||||
{"event", "seat"},
|
||||
lambda event, seat: (
|
||||
seat.zone_name
|
||||
if seat
|
||||
else (
|
||||
LazyI18nString.from_gettext("General admission")
|
||||
if event.seating_plan_id is not None
|
||||
else ""
|
||||
)
|
||||
),
|
||||
LazyI18nString.from_gettext("Ground floor"),
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"attendee_name",
|
||||
LazyI18nString.from_gettext("Attendee name"),
|
||||
{"order_position"},
|
||||
lambda order_position: order_position.attendee_name,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"program_start",
|
||||
LazyI18nString.from_gettext("Programm times: Start"),
|
||||
{"item", "subevent_or_event"},
|
||||
lambda item, subevent_or_event: (
|
||||
date_format(
|
||||
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",
|
||||
LazyI18nString.from_gettext("Programm times: End"),
|
||||
{"item", "subevent_or_event"},
|
||||
lambda item, subevent_or_event: (
|
||||
date_format(
|
||||
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",
|
||||
LazyI18nString.from_gettext("Organizer"),
|
||||
{"organizer"},
|
||||
lambda organizer: organizer.name,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"organizer_contact",
|
||||
LazyI18nString.from_gettext("Organizer Contact"),
|
||||
{"event"},
|
||||
lambda event: event.settings.contact_mail,
|
||||
"contact@example.com",
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"order_code",
|
||||
LazyI18nString.from_gettext("Order Code"),
|
||||
{"order"},
|
||||
lambda order: order.code,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"purchase_date",
|
||||
LazyI18nString.from_gettext("Purchase Date"),
|
||||
{"order", "subevent_or_event"},
|
||||
lambda order, subevent_or_event: date_format(
|
||||
order.datetime.astimezone(subevent_or_event.timezone),
|
||||
"SHORT_DATETIME_FORMAT",
|
||||
),
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"website",
|
||||
LazyI18nString.from_gettext("Website"),
|
||||
{"order_position", "order"},
|
||||
website,
|
||||
),
|
||||
# IMAGES
|
||||
FunctionalWalletImagePlaceholder(
|
||||
"poweredby",
|
||||
LazyI18nString.from_gettext("Logo"),
|
||||
set(),
|
||||
lambda: get_static_file("pretixplugins/wallet/logo.png"),
|
||||
static("pretixplugins/wallet/logo.png"),
|
||||
),
|
||||
FunctionalWalletImagePlaceholder(
|
||||
"poweredby_icon",
|
||||
LazyI18nString.from_gettext("Icon"),
|
||||
set(),
|
||||
lambda: get_static_file("pretixplugins/wallet/icon.png"),
|
||||
static("pretixplugins/wallet/icon.png"),
|
||||
),
|
||||
FunctionalWalletImagePlaceholder(
|
||||
"example_no_preview",
|
||||
LazyI18nString.from_gettext("Image with no preview"),
|
||||
set(),
|
||||
lambda: get_static_file("pretix_passbook/icon.png"),
|
||||
),
|
||||
# TODO: Image upload
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import (
|
||||
register_global_settings, register_ticket_outputs,
|
||||
)
|
||||
from pretix.control.signals import item_forms
|
||||
|
||||
from .forms import WalletLayoutItemForm
|
||||
from .models import WalletLayoutItem
|
||||
from .ticketoutput import OUTPUTS
|
||||
|
||||
|
||||
def connect_signals():
|
||||
for output in OUTPUTS:
|
||||
# DIY functools.partial to make get_defining_app happy
|
||||
def get_register_func(o):
|
||||
def register(sender, **kwargs):
|
||||
return o
|
||||
|
||||
return register
|
||||
|
||||
register_ticket_outputs.connect(
|
||||
get_register_func(output), dispatch_uid=f"wallet_output_{output.identifier}"
|
||||
)
|
||||
if hasattr(output, "get_global_settings"):
|
||||
register_global_settings.connect(
|
||||
output.get_global_settings,
|
||||
dispatch_uid=f"wallet_global_settings_{output.identifier}",
|
||||
)
|
||||
|
||||
|
||||
connect_signals()
|
||||
|
||||
|
||||
@receiver(item_forms, dispatch_uid="pretix_wallet_item_forms")
|
||||
def control_item_forms(sender, request, item, **kwargs):
|
||||
forms = []
|
||||
try:
|
||||
inst = WalletLayoutItem.objects.get(item=item)
|
||||
except WalletLayoutItem.DoesNotExist:
|
||||
inst = WalletLayoutItem(item=item)
|
||||
forms.append(
|
||||
WalletLayoutItemForm(
|
||||
instance=inst,
|
||||
event=sender,
|
||||
data=(request.POST if request.method == "POST" else None),
|
||||
prefix="wallet",
|
||||
)
|
||||
)
|
||||
return forms
|
||||
@@ -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,113 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watchEffect } from "vue";
|
||||
import StyleSettings from "./style-settings.vue";
|
||||
import Select from "./input/select.vue";
|
||||
import Input from "./input/input.vue";
|
||||
import PassPreview from "./preview/pass-preview.vue";
|
||||
import { StoreKey } from "../walletStore";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
function openForm(url: string, data: Record<string, string|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("../../preview/", {
|
||||
csrfmiddlewaretoken: store.csrfToken,
|
||||
id: store.layoutId,
|
||||
...(await store.serializeCurrentPlatformLayout())
|
||||
});
|
||||
}
|
||||
|
||||
const platformChoices = computed(() => {
|
||||
return [
|
||||
[null, "Do not generate pass"],
|
||||
...Object.values(store.platform.styles).map((x) => [x.identifier, x.name]),
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
// TODO: add :key for all `v-for`s
|
||||
// TODO: i18n textfields
|
||||
// TODO: proper spinner
|
||||
|
||||
template(v-if="!store.loaded") {{ gettext("Loading...") }}
|
||||
form(v-else @submit.prevent="store.save")
|
||||
.form-group
|
||||
Input(label="Name" v-model="store.name")
|
||||
nav
|
||||
ul.nav.nav-tabs
|
||||
li(v-for="platform in store.platforms" :class="{'active': store.platform.identifier === platform.identifier}")
|
||||
a(role="tab" @click="store.setPlatform(platform.identifier)") {{ platform.name }}
|
||||
.tabbed-form.tab-content
|
||||
.tab-pane.active.row
|
||||
.col-md-6.col-lg-8
|
||||
Select.form-group(label="Style" :modelValue="store.style?.identifier || null" @update:modelValue="store.setStyle" :choices="platformChoices")
|
||||
button(type="button" @click="store.setStyle(store.style.identifier)") Reset style
|
||||
StyleSettings(v-if="!!store.style")
|
||||
.col-md-6.col-lg-4
|
||||
.panel.panel-default
|
||||
.panel-heading Preview
|
||||
.panel-body
|
||||
div(v-if="!!store.style?.preview_layout")
|
||||
span.text-muted {{ gettext("The preview below is only a rough representation of what the pass might look like. Please check the generated pass.") }}
|
||||
div(style="display: grid; gap: 1em; grid-template-columns: repeat(auto-fit, minmax(auto, 360px));")
|
||||
PassPreview(v-for="layout in store.style.preview_layout" :layout="layout")
|
||||
div(v-else) Preview not supported
|
||||
//- pre(v-if="store.currentPlatformLayout.style")
|
||||
//- code {{ store.currentPlatformStyles[store.currentPlatformLayout.style] }}
|
||||
//- pre
|
||||
//- code {{ store.walletLayout }}
|
||||
.form-group.submit-group
|
||||
button.btn.btn-lg.btn-default(type="button" @click="openPreview") Preview
|
||||
button.btn.btn-primary.btn-save(type="submit") Submit
|
||||
|
||||
</template>
|
||||
|
||||
<style lang="css">
|
||||
.walletsettings-panel .panel-heading {
|
||||
.checkbox {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
input[type="checkbox"] {
|
||||
margin-top: 0;
|
||||
margin-right: 5px;
|
||||
margin-left: 0px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
label {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.walletsettings-panel .panel-body {
|
||||
> :is(h1, h2, h3, h4, h5, h6):first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</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,76 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useId, watchEffect } from "vue";
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
const emit = defineEmits<{change: [File]}>()
|
||||
const props = defineProps<{
|
||||
label?: I18nString;
|
||||
errors?: string[];
|
||||
help_text?: string;
|
||||
filename?: string;
|
||||
current_url?: string;
|
||||
default?: string;
|
||||
}>();
|
||||
const id = useId();
|
||||
|
||||
const selectedFile = ref(null);
|
||||
function onChange(e) {
|
||||
selectedFile.value = (e.target as HTMLInputElement).files[0] || null
|
||||
emit("change", selectedFile.value)
|
||||
}
|
||||
|
||||
// Reset input field if a url is provided
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
watchEffect(() => {
|
||||
if (props.current_url && inputRef.value?.value) {
|
||||
inputRef.value.value = '';
|
||||
}
|
||||
})
|
||||
|
||||
const clearFile = () => {
|
||||
if (!inputRef.value) {
|
||||
return
|
||||
}
|
||||
var event = new Event('change');
|
||||
inputRef.value.value = '';
|
||||
inputRef.value.dispatchEvent(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.form-group.row
|
||||
label.control-label.col-md-3(:for="id", v-if="!!label") {{ label }}
|
||||
br(v-if="!$attrs.required")
|
||||
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
|
||||
|
||||
div.col-md-9
|
||||
template(v-if="!!current_url")
|
||||
| {{ gettext("Currently") + ': ' }}
|
||||
a(:href="current_url" data-lightbox="input") {{ filename }}
|
||||
| {{ " " }}
|
||||
button.btn.btn-sm(@click.prevent="() => {console.log('clear'); emit('change', null)}") {{ gettext("Clear") }}
|
||||
br
|
||||
| {{ gettext("Change") + ': ' }}
|
||||
input(:id="id" @change="onChange" v-bind="$attrs" :required="$attrs.required && !current_url" type="file" style="display: inline" ref="inputRef")
|
||||
| {{ " " }}
|
||||
button.btn.btn-sm(v-if="selectedFile" @click.prevent="clearFile") {{ gettext("Clear") }}
|
||||
.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="props.errors" v-for="error in props.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?: I18nString;
|
||||
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}")
|
||||
.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 { i18nstringLocalize } from "../helpers.js";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
const ngettext = (window as any).ngettext;
|
||||
const interpolate = (window as any).interpolate;
|
||||
|
||||
const props = defineProps<{
|
||||
fieldgroup: PlaceholderFieldGroupDefinition;
|
||||
overflows: FieldGroupDefinition[];
|
||||
}>();
|
||||
const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({
|
||||
required: true,
|
||||
});
|
||||
|
||||
const overflowOptions = computed((): Array<[string | null, string]> => {
|
||||
if (props.overflows.length) {
|
||||
return [
|
||||
...props.overflows.map((x): [string, string] => [x.identifier, x.name]),
|
||||
[null, "Do not overflow"],
|
||||
];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
function addVariable() {
|
||||
fieldConfig.value.entries.push({ type: "placeholder", label: "" });
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!fieldConfig.value) {
|
||||
fieldConfig.value = {
|
||||
overflow: null,
|
||||
entries: JSON.parse(JSON.stringify(props.fieldgroup.default_entries)),
|
||||
};
|
||||
}
|
||||
if (fieldConfig.value && !fieldConfig.value.entries) {
|
||||
fieldConfig.value.entries = JSON.parse(
|
||||
JSON.stringify(props.fieldgroup.default_entries),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const placeholderChoices = computed(() => {
|
||||
const availableContext = new Set(props.fieldgroup.context_args);
|
||||
const choices = Object.entries(store.variables.text)
|
||||
.filter(([_, { required_context }]) =>
|
||||
new Set(required_context).isSubsetOf(availableContext),
|
||||
)
|
||||
.map(([k, v]): [string, string] => [k, i18nstringLocalize(v.label)]);
|
||||
choices.push(["other", gettext("Other…")]);
|
||||
return choices;
|
||||
});
|
||||
|
||||
const overflowText = computed(() => {
|
||||
if (!props.fieldgroup.max_entries) {
|
||||
return ""
|
||||
}
|
||||
const formats = ngettext(
|
||||
'Maximum one field in this fieldgroup can be shown on the pass. The remaining fields will be shown in the selected fieldgroup.',
|
||||
'Maximum %s fields in this fieldgroup can be shown on the pass. The remaining fields will be shown in the selected fieldgroup.',
|
||||
props.fieldgroup.max_entries
|
||||
);
|
||||
return interpolate(formats, [props.fieldgroup.max_entries])
|
||||
})
|
||||
|
||||
const placeholders = computed(() => {
|
||||
return fieldConfig.value.entries.map((entry) => {
|
||||
const placeholder =
|
||||
entry.type === "placeholder"
|
||||
? store.variables[props.fieldgroup.content_type][entry.content]
|
||||
: null;
|
||||
const placeholders = {};
|
||||
let base;
|
||||
if (i18nstringLocalize(entry.label)) {
|
||||
base = entry.label
|
||||
} else if (placeholder) {
|
||||
base = placeholder.label
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
for (const locale of Object.keys(store.locales)) {
|
||||
placeholders[locale] = i18nstringLocalize(base, locale)
|
||||
}
|
||||
return placeholders
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.panel.panel-default.walletsettings-panel
|
||||
.panel-heading
|
||||
h3.panel-title {{ fieldgroup.name }}
|
||||
.panel-body(v-if="fieldConfig")
|
||||
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
|
||||
h4 {{ gettext("Content") }}
|
||||
.form-group()
|
||||
table.table.table-hover
|
||||
thead
|
||||
tr
|
||||
th.col-md-5(v-if="fieldgroup.display == 'with_label'") {{ gettext('Label') }}
|
||||
th(:class="'col-md-' + (fieldgroup.display == 'with_label' ? '6' : '11')") {{ gettext('Content') }}
|
||||
th.col-xs-1
|
||||
tbody
|
||||
tr(v-for="n, i in fieldConfig.entries.length" :key="i")
|
||||
td(v-if="fieldgroup.display == 'with_label'")
|
||||
.i18n-form-group
|
||||
I18nInput(v-model="fieldConfig.entries[n - 1].label" :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.variables.image).map(([k, v]) => [k, i18nstringLocalize(v.label)])"
|
||||
)
|
||||
td.text-right
|
||||
button.btn.btn-danger.form-control-static(type="button" @click="fieldConfig.entries.splice(n - 1, 1)")
|
||||
i.fa.fa-trash
|
||||
span.sr-only {{ gettext('Delete') }}
|
||||
|
||||
button.btn.btn-default(type="button" @click="addVariable")
|
||||
i.fa.fa-plus
|
||||
| {{ gettext("Add field") }}
|
||||
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow" :help_text="overflowText")
|
||||
</template>
|
||||
+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>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import { i18nstringLocalize } from "../../helpers";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
label?: I18nString;
|
||||
content: I18nString;
|
||||
content_type: FieldContentType;
|
||||
display: FieldGroupDisplay;
|
||||
display_class?: string | string[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-item
|
||||
div.fieldgroup-item-qrcode(v-if="display == 'code'") {{ label }}
|
||||
|
||||
template(v-else)
|
||||
div.fieldgroup-label.nowrap(v-if="display == 'with_label'") {{ i18nstringLocalize(label) }}
|
||||
div.nowrap.content(v-if="content_type == 'text'" :class="display_class") {{ i18nstringLocalize(content) }}
|
||||
img.fieldgroup-item-image(v-else-if="content_type == 'image' && content" :src="i18nstringLocalize(content)" :class="display_class")
|
||||
</template>
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import PlaceholderFieldgroupPreview from "./placeholder-fieldgroup-preview.vue";
|
||||
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
config: PreviewFieldgroup;
|
||||
}>();
|
||||
|
||||
const style_def = computed(() => {
|
||||
return Object.fromEntries(
|
||||
store.styles[
|
||||
store.layout.style
|
||||
].fieldgroups.map((x) => [x.identifier, x]),
|
||||
)[props.config.fieldgroup];
|
||||
});
|
||||
|
||||
function focusGroup() {
|
||||
// TODO: highlight group? or change ui concept completely
|
||||
const elem = document.getElementById('fieldgroup-' + props.config.fieldgroup)
|
||||
elem && elem.scrollIntoView()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def" @click="focusGroup")
|
||||
PredefinedFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def" @click="focusGroup")
|
||||
</template>
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from "vue";
|
||||
import { StoreKey } from "../../walletStore.js";
|
||||
import RowPreview from "./row-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const { layout } = defineProps<{ layout: Array<PreviewLayout> }>();
|
||||
|
||||
const layoutStyle = computed(() => {
|
||||
if (layout.style) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(layout.style)
|
||||
.map(([k, v]) => {
|
||||
if (v.setting) {
|
||||
const settingDef = store.styles[store.layout.style].settings.find(
|
||||
(x) => x.identifier == v.setting,
|
||||
);
|
||||
const val = store.settings[v.setting];
|
||||
if (!settingDef) {
|
||||
v = null;
|
||||
} else if (
|
||||
settingDef.type == "image" &&
|
||||
val &&
|
||||
"file" in val &&
|
||||
val.file
|
||||
) {
|
||||
v = `url(${URL.createObjectURL(val.file)})`; // TODO: encode
|
||||
} else if (settingDef.type == "image" && val && val.url) {
|
||||
v = `url(${val.url})`;
|
||||
} else if (settingDef.type == "image" && settingDef.attrs.default) {
|
||||
v = `url(${settingDef.attrs.default})`;
|
||||
} else {
|
||||
v = val;
|
||||
}
|
||||
} else if (v.value) {
|
||||
v = v.value;
|
||||
}
|
||||
return [k, v];
|
||||
})
|
||||
.filter(([k, v]) => !!v),
|
||||
);
|
||||
}
|
||||
});
|
||||
console.log("layoutStyle", layoutStyle.value);
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div(style="container-type: inline-size;")
|
||||
div.pass-container(:style="layoutStyle")
|
||||
div.pass-content
|
||||
RowPreview(v-for="row of layout.rows" :config="row")
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.pass-container {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
vertical-align: top;
|
||||
display: inline-block;
|
||||
|
||||
border: solid 1px #949494;
|
||||
border-radius: 1em;
|
||||
padding: 1em;
|
||||
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
background-image: var(--background-image);
|
||||
background-color: var(--background-color);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.pass-container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pass-content {
|
||||
overflow: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="css">
|
||||
.pass-container {
|
||||
.fieldgroup-container {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
min-height: 2lh;
|
||||
}
|
||||
.fieldgroup-label {
|
||||
font-weight: bold;
|
||||
font-size: 0.8em;
|
||||
color: var(--label-color);
|
||||
}
|
||||
.fieldgroup-item {
|
||||
flex: 0 1 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.fieldgroup-item-image {
|
||||
object-fit: contain;
|
||||
}
|
||||
.fieldgroup-item-qrcode {
|
||||
font-weight: bold;
|
||||
background-color: lightgray;
|
||||
color: black;
|
||||
width: 50%;
|
||||
aspect-ratio: 1;
|
||||
margin: auto;
|
||||
|
||||
/* center content */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 1em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.nowrap {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.large {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.tight {
|
||||
line-height: 1;
|
||||
}
|
||||
.img-inline {
|
||||
max-height: 3lh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
config: PreviewFieldgroup;
|
||||
style_def: PlaceholderFieldGroupDefinition;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
|
||||
FieldgroupItemPreview(v-for="{ label, content } of store.renderedFieldGroups[config.fieldgroup]" :label="label" :content="content" :content_type="style_def.content_type" :display_class="config.display" :display="style_def.display")
|
||||
</template>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
style_def: PredefinedFieldGroupDefinition;
|
||||
config: PredefinedFieldgroupPreview;
|
||||
}>();
|
||||
|
||||
const isActive = computed(
|
||||
() =>
|
||||
store.layout.fieldgroups[props.style_def.identifier]?.active,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-container(v-if="isActive" :style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
|
||||
FieldgroupItemPreview(v-for="{ label, content } of config.sample" :label="label" :content="content" content_type="text" display="with_label")
|
||||
</template>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import FieldgroupItemPreview from './fieldgroup-item-preview.vue';
|
||||
import FieldgroupPreview from './fieldgroup-preview.vue';
|
||||
import SettingPreview from './setting-preview.vue';
|
||||
const props = defineProps<{
|
||||
config: PreviewRow;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.preview-row(v-if="'children' in config" :style="{ flexDirection: config.direction || 'row' }" :class="config.display")
|
||||
RowPreview(v-for="child of config.children" :config="child" )
|
||||
FieldgroupPreview(v-else-if="'fieldgroup' in config" :config="config")
|
||||
FieldgroupItemPreview(v-else-if="'value' in config" :label="config.label" :content="config.value" content_type="text" :display="!!config.label ? 'with_label' : 'plain'" :display_class="config.display")
|
||||
SettingPreview(v-else-if="'setting' in config" :config="config")
|
||||
|
||||
</template>
|
||||
<style lang="css" scoped>
|
||||
.preview-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
display: flex
|
||||
}
|
||||
.tight {
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, toRaw, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
|
||||
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const { config } = defineProps<{
|
||||
config: SettingPreview;
|
||||
}>();
|
||||
|
||||
const settingDef = computed(() => {
|
||||
return Object.fromEntries(
|
||||
store.styles[store.layout.style].settings.map((x) => [x.identifier, x]),
|
||||
)[config.setting];
|
||||
});
|
||||
const settingValue = computed(() => {
|
||||
const val = store.settings[config.setting];
|
||||
if (settingDef.value.type == "image" && val && "file" in val && val.file) {
|
||||
return URL.createObjectURL(val.file);
|
||||
} else if (
|
||||
settingDef.value.type == "image" &&
|
||||
settingDef.value.default
|
||||
) {
|
||||
return settingDef.value.default;
|
||||
} else {
|
||||
return val;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
|
||||
FieldgroupItemPreview(:content="settingValue" :content_type="settingDef.type" :display_class="config.display" display="plain")
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import Input from "./input/input.vue";
|
||||
import FileInput from "./input/file-input.vue";
|
||||
import { inject } from "vue";
|
||||
import { StoreKey } from "../walletStore";
|
||||
|
||||
const props = defineProps<{
|
||||
field?: Setting;
|
||||
}>();
|
||||
const store = inject(StoreKey)!;
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.form-group
|
||||
Input(v-if='field.type == "text" || 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" @change="(v) => store.setSetting(field.identifier, v)"
|
||||
:filename="store.settings[field.identifier]?.name" :current_url="store.settings[field.identifier]?.url" :default="field.default")
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from "vue";
|
||||
import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
|
||||
import PredefinedFieldSettings from "./predefined-field-settings.vue";
|
||||
import SettingsField from "./settings-field.vue";
|
||||
import { StoreKey } from "../walletStore.js";
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
h2.h3 {{ gettext("Settings") }}
|
||||
SettingsField(v-for="field of store.style.settings" :field="field" :key="field.identifier")
|
||||
h2.h3 {{ gettext("Field Groups") }}
|
||||
div(v-for="(fieldgroup, fieldgroupId) in store.style.fieldgroups" :id="'fieldgroup-' + fieldgroup.identifier")
|
||||
PlaceholderFieldSettings(
|
||||
v-if="fieldgroup.type == 'placeholder'"
|
||||
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
|
||||
:fieldgroup="fieldgroup"
|
||||
:overflows="store.style.fieldgroups.slice(fieldgroupId + 1) \
|
||||
.filter(x => x.type == 'placeholder' && x.content_type === fieldgroup.content_type)"
|
||||
)
|
||||
PredefinedFieldSettings(v-else-if="fieldgroup.type == 'predefined'"
|
||||
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
|
||||
:fieldgroup="fieldgroup")
|
||||
</template>
|
||||
@@ -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,33 @@
|
||||
export function i18nstringLocalize(s: I18nString, 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,165 @@
|
||||
type Platform = {
|
||||
identifier: string;
|
||||
name: string;
|
||||
styles: Styles;
|
||||
};
|
||||
|
||||
type Style = {
|
||||
identifier: string;
|
||||
name: string;
|
||||
fieldgroups: FieldGroupDefinition[];
|
||||
settings: Setting[];
|
||||
};
|
||||
|
||||
type Variable = {
|
||||
label: string;
|
||||
sample: string;
|
||||
required_context: string[];
|
||||
};
|
||||
|
||||
type Styles = Record<string, Style>;
|
||||
type Variables = Record<string, Variable>;
|
||||
type VariableConfig = Record<string, Variables>;
|
||||
type Platforms = Platform[];
|
||||
|
||||
//
|
||||
|
||||
type BaseFieldGroupDefinition = {
|
||||
type: string;
|
||||
identifier: string;
|
||||
name: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type FieldGroupDefinition =
|
||||
| PlaceholderFieldGroupDefinition
|
||||
| PredefinedFieldGroupDefinition;
|
||||
|
||||
type FieldGroupDisplay = "plain" | "with_label" | "code";
|
||||
|
||||
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
|
||||
type: "placeholder";
|
||||
content_type: FieldContentType;
|
||||
default_entries: FieldEntry[];
|
||||
display: FieldGroupDisplay;
|
||||
min_entries: number | null;
|
||||
max_entries: number | null;
|
||||
context_args: string[];
|
||||
};
|
||||
|
||||
type PredefinedFieldGroupDefinition = BaseFieldGroupDefinition & {
|
||||
type: "predefined";
|
||||
};
|
||||
|
||||
type I18nString = null | string | Record<string, string>;
|
||||
|
||||
type FieldContentType = "text" | "image";
|
||||
|
||||
type PlaceholderFieldEntry = {
|
||||
type: "placeholder";
|
||||
label?: I18nString;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type CustomFieldEntry = {
|
||||
type: "custom";
|
||||
label?: I18nString;
|
||||
content?: I18nString;
|
||||
};
|
||||
|
||||
type FieldEntry = PlaceholderFieldEntry | CustomFieldEntry;
|
||||
|
||||
type Setting = {
|
||||
identifier: string;
|
||||
label: string;
|
||||
type: "text" | "image" | "color" | "float";
|
||||
required: boolean;
|
||||
help_text: string;
|
||||
attrs: Record<string, any>
|
||||
};
|
||||
|
||||
type PlaceholderFieldGroupConfig = {
|
||||
entries: Array<FieldEntry>;
|
||||
overflow: string | null;
|
||||
};
|
||||
|
||||
type PredefinedFieldGroupConfig = {
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type FieldGroupConfig =
|
||||
| PlaceholderFieldGroupConfig
|
||||
| PredefinedFieldGroupConfig;
|
||||
|
||||
type LayoutData = {
|
||||
fieldgroups?: Record<string, FieldGroupConfig>;
|
||||
settings?: Record<string, any>;
|
||||
};
|
||||
|
||||
type PlatformLayout = {
|
||||
platform: string;
|
||||
style: string | null;
|
||||
layout: LayoutData;
|
||||
};
|
||||
|
||||
type WalletLayout = {
|
||||
name?: string;
|
||||
platform_layouts: PlatformLayout[];
|
||||
};
|
||||
type WalletStore = {
|
||||
platforms: Platforms;
|
||||
variables: VariableConfig;
|
||||
locales: Record<string, string>;
|
||||
csrfToken: String;
|
||||
walletLayout: WalletLayout | null;
|
||||
};
|
||||
|
||||
type PreviewLayout = Array<PreviewRow>;
|
||||
type PreviewRow =
|
||||
| {
|
||||
children: Array<PreviewRow>;
|
||||
direction?: "row" | "column";
|
||||
display?: Array<string>;
|
||||
}
|
||||
| PreviewFieldgroup
|
||||
| FixedPreview
|
||||
| SettingPreview;
|
||||
|
||||
type PreviewProps = {
|
||||
relSize?: number;
|
||||
direction?: "row" | "column";
|
||||
display?: Array<string>;
|
||||
};
|
||||
type SettingPreview = {
|
||||
setting: string;
|
||||
} & PreviewProps;
|
||||
|
||||
type PreviewFieldgroup =
|
||||
| PredefinedFieldgroupPreview
|
||||
| PlaceholderFieldGroupPreview;
|
||||
type FixedPreview = { value: I18nString; label?: I18nString } & PreviewProps;
|
||||
|
||||
type PlaceholderFieldGroupPreview = {
|
||||
fieldgroup: string;
|
||||
} & PreviewProps;
|
||||
|
||||
type PredefinedFieldgroupPreview = {
|
||||
fieldgroup: string;
|
||||
sample: PreviewSample[];
|
||||
} & PreviewProps;
|
||||
|
||||
type PreviewSample = {
|
||||
content: I18nString;
|
||||
label: I18nString;
|
||||
};
|
||||
|
||||
type NewPlatformLayout = {
|
||||
style: string;
|
||||
fieldgroups: {};
|
||||
settings: {};
|
||||
file_settings: Record<string, WalletFile>;
|
||||
};
|
||||
type ServerSideFile = { url: string; name: string };
|
||||
type ClientSideFile = { file: File | null; identifier?: string };
|
||||
type WalletFile = ServerSideFile | ClientSideFile;
|
||||
@@ -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,28 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./components/app.vue";
|
||||
import { createWalletStore, StoreKey } from "./walletStore";
|
||||
const mountEl = document.querySelector<HTMLElement>("#editor")!;
|
||||
const store = createWalletStore({
|
||||
platforms: JSON.parse(
|
||||
document.querySelector("#platforms")?.textContent ?? "{}",
|
||||
),
|
||||
variables: JSON.parse(
|
||||
document.querySelector("#variables")?.textContent ?? "{}",
|
||||
),
|
||||
locales: JSON.parse(document.querySelector("#locales")?.textContent ?? "{}"),
|
||||
csrfToken: document.querySelector<HTMLInputElement>(
|
||||
"input[name=csrfmiddlewaretoken]",
|
||||
)?.value!!,
|
||||
layoutId: mountEl.dataset.layoutId!
|
||||
});
|
||||
store.load();
|
||||
const app = createApp(App);
|
||||
app.provide(StoreKey, store);
|
||||
app.mount(mountEl);
|
||||
|
||||
app.config.errorHandler = (error, _vm, info) => {
|
||||
// vue fatals on errors by default, which is a weird choice
|
||||
// https://github.com/vuejs/core/issues/3525
|
||||
// https://github.com/vuejs/router/discussions/2435
|
||||
console.error("[VUE]", info, error);
|
||||
};
|
||||
@@ -0,0 +1,326 @@
|
||||
import { i18nstringLocalize } from "./helpers.js";
|
||||
import { createStore } from "./lib/store";
|
||||
import { toRaw, type InjectionKey } from "vue";
|
||||
|
||||
export type WidgetStore = ReturnType<typeof createWalletStore>;
|
||||
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
|
||||
|
||||
function getDefaultFieldgroupState(
|
||||
fieldgroup: FieldGroupDefinition,
|
||||
): FieldGroupConfig {
|
||||
if (fieldgroup.type == "predefined") {
|
||||
return { active: fieldgroup.required };
|
||||
} else if (fieldgroup.type == "placeholder") {
|
||||
return {
|
||||
overflow: null,
|
||||
entries: JSON.parse(JSON.stringify(fieldgroup.default_entries)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseExistingFieldgroupState(
|
||||
fieldgroup: FieldGroupDefinition,
|
||||
existing?: FieldGroupConfig,
|
||||
): FieldGroupConfig {
|
||||
if (!existing) {
|
||||
return getDefaultFieldgroupState(fieldgroup);
|
||||
}
|
||||
if (fieldgroup.type == "predefined") {
|
||||
return {
|
||||
active: "active" in existing ? existing.active : fieldgroup.required,
|
||||
};
|
||||
} else if (fieldgroup.type == "placeholder") {
|
||||
return {
|
||||
overflow: "overflow" in existing ? existing.overflow : null,
|
||||
// TODO: check that placeholders are possible
|
||||
entries: structuredClone(
|
||||
toRaw(
|
||||
"entries" in existing ? existing.entries : fieldgroup.default_entries,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createWalletStore(config: {
|
||||
platforms: Platforms;
|
||||
variables: VariableConfig;
|
||||
locales: Record<string, string>;
|
||||
csrfToken: string;
|
||||
layoutId: string;
|
||||
}) {
|
||||
return createStore({
|
||||
state: () => ({
|
||||
...config,
|
||||
loaded: false,
|
||||
activePlatform: config.platforms[0].identifier,
|
||||
name: null as string | null,
|
||||
platformLayouts: {} as Record<string, NewPlatformLayout>,
|
||||
}),
|
||||
getters: {
|
||||
platform(): Platform {
|
||||
return this.getPlatform(this.activePlatform);
|
||||
},
|
||||
layout() {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
return this.platformLayouts[this.activePlatform] || null;
|
||||
},
|
||||
style(): Style {
|
||||
if (this.layout) {
|
||||
return this.platform.styles[this.layout.style];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
styles() {
|
||||
return this.platform.styles;
|
||||
},
|
||||
settings() {
|
||||
const settings = {};
|
||||
for (const setting of this.style.settings) {
|
||||
if (setting.type == "text" || setting.type == 'color' || setting.type == 'float') {
|
||||
settings[setting.identifier] =
|
||||
this.layout.settings[setting.identifier];
|
||||
} else if (setting.type == "image") {
|
||||
settings[setting.identifier] =
|
||||
this.layout.file_settings[setting.identifier];
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
},
|
||||
renderedFieldGroups() {
|
||||
const content = {};
|
||||
const group_defs = this.style.fieldgroups;
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
content[fieldgroup.identifier] = [];
|
||||
const layout_group = this.layout.fieldgroups[
|
||||
fieldgroup.identifier
|
||||
] as any as PlaceholderFieldGroupConfig;
|
||||
for (const entry of layout_group.entries) {
|
||||
const placeholder =
|
||||
entry.type === "placeholder"
|
||||
? this.variables[fieldgroup.content_type][entry.content]
|
||||
: null;
|
||||
|
||||
let label = i18nstringLocalize(entry.label);
|
||||
if (placeholder && !label) {
|
||||
label = i18nstringLocalize(placeholder.label);
|
||||
}
|
||||
|
||||
let value = null;
|
||||
if (entry.type == "custom") {
|
||||
value = i18nstringLocalize(entry.content);
|
||||
} else if (entry.type == "placeholder") {
|
||||
value =
|
||||
placeholder?.sample ||
|
||||
`(unknown placeholder: ${entry.content})`;
|
||||
}
|
||||
content[fieldgroup.identifier].push({
|
||||
entry,
|
||||
label,
|
||||
content: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
const layout_group: PlaceholderFieldGroupConfig =
|
||||
this.layout.fieldgroups[fieldgroup.identifier];
|
||||
if (
|
||||
fieldgroup.max_entries &&
|
||||
content[fieldgroup.identifier].length > fieldgroup.max_entries
|
||||
) {
|
||||
const overflow = content[fieldgroup.identifier].slice(
|
||||
fieldgroup.max_entries,
|
||||
);
|
||||
content[fieldgroup.identifier] = content[
|
||||
fieldgroup.identifier
|
||||
].slice(0, fieldgroup.max_entries);
|
||||
if (layout_group.overflow) {
|
||||
content[layout_group.overflow].splice(0, 0, ...overflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
getPlatform(identifier: string): Platform {
|
||||
for (const platform of this.platforms) {
|
||||
if (platform.identifier === identifier) {
|
||||
return platform;
|
||||
}
|
||||
}
|
||||
},
|
||||
load() {
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
this.parseServerLayout(x);
|
||||
this.loaded = true;
|
||||
})
|
||||
.catch(alert);
|
||||
},
|
||||
parseServerLayout(serverLayout) {
|
||||
this.name = serverLayout.name;
|
||||
for (const layout of serverLayout.platform_layouts) {
|
||||
const clientLayout = {
|
||||
style: layout.style,
|
||||
fieldgroups: {},
|
||||
settings: layout.layout.settings,
|
||||
file_settings: layout.file_settings,
|
||||
};
|
||||
const styleDefinition = this.getPlatform(layout.platform).styles[
|
||||
layout.style
|
||||
];
|
||||
console.log(styleDefinition);
|
||||
for (const fieldgroup of styleDefinition.fieldgroups) {
|
||||
clientLayout.fieldgroups[fieldgroup.identifier] =
|
||||
parseExistingFieldgroupState(
|
||||
fieldgroup,
|
||||
layout.layout.fieldgroups[fieldgroup.identifier],
|
||||
);
|
||||
}
|
||||
this.platformLayouts[layout.platform] = clientLayout;
|
||||
}
|
||||
},
|
||||
setPlatform(platform: string) {
|
||||
this.activePlatform = platform;
|
||||
},
|
||||
setStyle(style: string | null) {
|
||||
if (style === null) {
|
||||
delete this.platformLayouts[this.activePlatform];
|
||||
} else if (Object.keys(this.platform.styles).includes(style)) {
|
||||
const newLayout = {
|
||||
style,
|
||||
fieldgroups: {},
|
||||
settings: {},
|
||||
file_settings: {},
|
||||
};
|
||||
for (const fieldgroup of this.platform.styles[style].fieldgroups) {
|
||||
newLayout.fieldgroups[fieldgroup.identifier] =
|
||||
getDefaultFieldgroupState(fieldgroup);
|
||||
}
|
||||
this.platformLayouts[this.activePlatform] = newLayout;
|
||||
// TODO: keep old fieldgroups & settings if matching
|
||||
}
|
||||
},
|
||||
getSetting(identifier: string): Setting {
|
||||
for (const setting of this.style.settings) {
|
||||
if (setting.identifier === identifier) {
|
||||
return setting;
|
||||
}
|
||||
}
|
||||
},
|
||||
setSetting(identifier: string, value: string | File) {
|
||||
const setting = this.getSetting(identifier);
|
||||
if (!setting) return;
|
||||
|
||||
if (
|
||||
setting.type === "image" &&
|
||||
(value instanceof File || value === null)
|
||||
) {
|
||||
this.layout.file_settings[setting.identifier] = {
|
||||
file: value as File | null,
|
||||
};
|
||||
} else if (
|
||||
(setting.type == "text" || 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;
|
||||
}
|
||||
},
|
||||
async uploadFile(file: ClientSideFile) {
|
||||
return await fetch("/api/v1/upload", {
|
||||
method: "POST",
|
||||
body: file.file,
|
||||
headers: {
|
||||
"content-disposition": `attachment; filename="${encodeURI(file.file.name)}"`,
|
||||
"content-type": file.file.type || "application/octet-stream",
|
||||
"X-CSRFToken": this.csrfToken,
|
||||
},
|
||||
})
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
file.identifier = x.id;
|
||||
return x.id;
|
||||
})
|
||||
.catch(alert);
|
||||
},
|
||||
async serializePlatformLayout(platform, layout: NewPlatformLayout) {
|
||||
const uploadedFiles = Object.fromEntries(
|
||||
(
|
||||
await Promise.all(
|
||||
Object.entries(layout.file_settings).map(async ([k, v]) => {
|
||||
if ("url" in v) {
|
||||
return;
|
||||
} else if ("identifier" in v) {
|
||||
return [k, v.identifier];
|
||||
} else if ("file" in v && v.file instanceof File) {
|
||||
return [k, await this.uploadFile(v)];
|
||||
} else if ("file" in v && v.file == null) {
|
||||
return [k, null];
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((x) => !!x),
|
||||
);
|
||||
return {
|
||||
platform,
|
||||
style: layout.style,
|
||||
file_settings: uploadedFiles,
|
||||
layout: {
|
||||
fieldgroups: layout.fieldgroups,
|
||||
settings: layout.settings,
|
||||
},
|
||||
};
|
||||
},
|
||||
async 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) => x.json())
|
||||
.catch((x) => alert(x))
|
||||
.then((x) => {
|
||||
this.parseServerLayout(x);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { i18nstringLocalize } from "./helpers.js";
|
||||
import { createStore } from "./lib/store.ts";
|
||||
import { toRaw, type InjectionKey } from "vue";
|
||||
|
||||
export type WidgetStore = ReturnType<typeof createWalletStore>;
|
||||
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
|
||||
|
||||
export function createWalletStore(config: {
|
||||
platforms: Platforms;
|
||||
variables: VariableConfig;
|
||||
locales: Record<string, string>;
|
||||
csrfToken: string;
|
||||
layoutId: string;
|
||||
}) {
|
||||
return createStore({
|
||||
state: () => ({
|
||||
...config,
|
||||
walletLayout: null as WalletLayout | null,
|
||||
currentPlatform: config.platforms[0].identifier,
|
||||
loaded: false,
|
||||
files: {} as Record<string, File>
|
||||
}),
|
||||
getters: {
|
||||
currentPlatformStyles() {
|
||||
for (const platform of this.platforms) {
|
||||
if (platform.identifier === this.currentPlatform) {
|
||||
return platform.styles;
|
||||
}
|
||||
}
|
||||
throw "Unknown platform";
|
||||
},
|
||||
currentPlatformLayout(): PlatformLayout {
|
||||
if (!this.walletLayout) {
|
||||
throw "currentPlatformLayout access before store was loaded";
|
||||
}
|
||||
for (const layout of this.walletLayout.platform_layouts) {
|
||||
if (layout.platform === this.currentPlatform) {
|
||||
if (!("fieldgroups" in layout.layout)) {
|
||||
layout.layout.fieldgroups = {};
|
||||
}
|
||||
if (!("settings" in layout.layout)) {
|
||||
layout.layout.settings = {};
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
const newLayout = {
|
||||
platform: this.currentPlatform,
|
||||
style: null,
|
||||
layout: { fieldgroups: {}, settings: {} },
|
||||
};
|
||||
this.walletLayout.platform_layouts.push(newLayout);
|
||||
return newLayout;
|
||||
},
|
||||
|
||||
currentLayoutFieldContent() {
|
||||
const content = {};
|
||||
const group_defs =
|
||||
this.currentPlatformStyles[this.currentPlatformLayout.style]
|
||||
.fieldgroups;
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
content[fieldgroup.identifier] = [];
|
||||
const layout_group = this.currentPlatformLayout.layout.fieldgroups[
|
||||
fieldgroup.identifier
|
||||
] as any as PlaceholderFieldGroupConfig;
|
||||
for (const entry of layout_group.entries) {
|
||||
const placeholder =
|
||||
entry.type === "placeholder"
|
||||
? this.variables[fieldgroup.content_type][entry.content]
|
||||
: null;
|
||||
|
||||
let label = i18nstringLocalize(entry.label);
|
||||
if (placeholder && !label) {
|
||||
label = i18nstringLocalize(placeholder.label);
|
||||
}
|
||||
|
||||
let value = null;
|
||||
if (entry.type == "custom") {
|
||||
value = i18nstringLocalize(entry.content);
|
||||
} else if (entry.type == "placeholder") {
|
||||
value =
|
||||
placeholder?.sample ||
|
||||
`(unknown placeholder: ${entry.content})`;
|
||||
}
|
||||
content[fieldgroup.identifier].push({
|
||||
entry,
|
||||
label,
|
||||
content: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
const layout_group: PlaceholderFieldGroupConfig =
|
||||
this.currentPlatformLayout.layout.fieldgroups[
|
||||
fieldgroup.identifier
|
||||
];
|
||||
if (
|
||||
fieldgroup.max_entries &&
|
||||
content[fieldgroup.identifier].length > fieldgroup.max_entries
|
||||
) {
|
||||
const overflow = content[fieldgroup.identifier].slice(
|
||||
fieldgroup.max_entries,
|
||||
);
|
||||
content[fieldgroup.identifier] = content[
|
||||
fieldgroup.identifier
|
||||
].slice(0, fieldgroup.max_entries);
|
||||
if (layout_group.overflow) {
|
||||
content[layout_group.overflow].splice(0, 0, ...overflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
currentLayoutSettings() {
|
||||
return {...this.currentPlatformLayout.file_settings, ...this.currentPlatformLayout.layout.settings}
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
load() {
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
this.walletLayout = x;
|
||||
this.loaded = true;
|
||||
});
|
||||
},
|
||||
async uploadFile(file: File) {
|
||||
return await fetch("/api/v1/upload", {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: {
|
||||
"content-disposition": `attachment; filename="${encodeURI(file.name)}"`,
|
||||
"content-type": file.type || "application/octet-stream",
|
||||
"X-CSRFToken": this.csrfToken,
|
||||
},
|
||||
})
|
||||
.then((x) => x.json())
|
||||
.then((x) => x.id);
|
||||
},
|
||||
async saveLayout() {
|
||||
const layoutToSave = structuredClone(toRaw(this.walletLayout))
|
||||
// TODO: error handling, parallelization
|
||||
for (const platformLayout of layoutToSave.platform_layouts) {
|
||||
const platformStyle = this.platforms.filter(
|
||||
(x) => x.identifier == platformLayout.platform,
|
||||
)[0].styles[platformLayout.style];
|
||||
for (const setting of platformStyle.settings) {
|
||||
console.log(setting, platformLayout.layout?.settings[setting.identifier])
|
||||
if (
|
||||
setting.type === "image" &&
|
||||
platformLayout.layout?.settings[setting.identifier] instanceof
|
||||
File
|
||||
) {
|
||||
platformLayout.layout.settings[setting.identifier] = await
|
||||
this.uploadFile(
|
||||
platformLayout.layout.settings[setting.identifier],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-CSRFToken": this.csrfToken,
|
||||
},
|
||||
body: JSON.stringify(layoutToSave),
|
||||
},
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.catch((x) => alert(x))
|
||||
.then((x) => {
|
||||
this.walletLayout = x;
|
||||
});
|
||||
},
|
||||
setCurrentPlatformStyle(style: string | null) {
|
||||
if (style === null) {
|
||||
this.currentPlatformLayout.style = null;
|
||||
this.currentPlatformLayout.layout.fieldgroups = {};
|
||||
} else if (Object.keys(this.currentPlatformStyles).includes(style)) {
|
||||
const oldStyle =
|
||||
this.currentPlatformLayout.style !== null
|
||||
? this.currentPlatformStyles[this.currentPlatformLayout.style]
|
||||
: { fieldgroups: [] };
|
||||
const newStyle = this.currentPlatformStyles[style];
|
||||
|
||||
const oldFieldGroups = Object.fromEntries(
|
||||
oldStyle.fieldgroups.map((x) => [x.identifier, x]),
|
||||
);
|
||||
const newFieldGroups = Object.fromEntries(
|
||||
newStyle.fieldgroups.map((x) => [x.identifier, x]),
|
||||
);
|
||||
const keysToKeep = new Set(
|
||||
Object.keys(this.currentPlatformLayout.layout.fieldgroups).filter(
|
||||
(x) =>
|
||||
oldFieldGroups[x]?.type === "placeholder" &&
|
||||
newFieldGroups[x]?.type === "placeholder" &&
|
||||
oldFieldGroups[x]?.content_type ===
|
||||
newFieldGroups[x]?.content_type,
|
||||
),
|
||||
);
|
||||
const keysToDefault = new Set(Object.keys(newFieldGroups)).difference(
|
||||
keysToKeep,
|
||||
);
|
||||
const keysToRemove = new Set(
|
||||
Object.keys(this.currentPlatformLayout.layout.fieldgroups),
|
||||
)
|
||||
.difference(keysToKeep)
|
||||
.difference(keysToDefault);
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
delete this.currentPlatformLayout.layout.fieldgroups[key];
|
||||
}
|
||||
|
||||
for (const key of keysToDefault) {
|
||||
if (newFieldGroups[key].type == "placeholder") {
|
||||
this.currentPlatformLayout.layout.fieldgroups[key] = {
|
||||
overflow: null,
|
||||
entries: JSON.parse(
|
||||
JSON.stringify(newFieldGroups[key].default_entries),
|
||||
),
|
||||
active:
|
||||
newFieldGroups[key].required ||
|
||||
newFieldGroups[key].default_entries.length > 0,
|
||||
};
|
||||
} else {
|
||||
this.currentPlatformLayout.layout.fieldgroups[key] = {
|
||||
active: newFieldGroups[key].required,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPlatformLayout.style = style;
|
||||
}
|
||||
},
|
||||
setSetting(identifier: string, value: string | File | null) {
|
||||
this.currentPlatformLayout.layout.settings[identifier] = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
from pretix.plugins.wallet.styles.base import PassStyle
|
||||
|
||||
from .apple import ApplePlatform, AppleWalletEventTicket
|
||||
from .google import GooglePlatform, GoogleWalletDefaultEventTicket
|
||||
|
||||
AVAILABLE_PLATFORMS = [ApplePlatform, GooglePlatform]
|
||||
|
||||
AVAILABLE_STYLES: dict[str, list[type[PassStyle]]] = {
|
||||
"apple": [AppleWalletEventTicket],
|
||||
"google": [GoogleWalletDefaultEventTicket],
|
||||
}
|
||||
|
||||
AVAILABLE_STYLES_DICT = {
|
||||
plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items()
|
||||
}
|
||||
|
||||
|
||||
def get_style(platform: str, identifier: str) -> type[PassStyle] | None:
|
||||
return AVAILABLE_STYLES_DICT.get(platform, {}).get(identifier)
|
||||
|
||||
|
||||
__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassStyle"]
|
||||
@@ -0,0 +1,561 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Any
|
||||
|
||||
import cryptography
|
||||
import cryptography.hazmat.primitives.hashes
|
||||
import cryptography.hazmat.primitives.serialization.pkcs7
|
||||
import cryptography.x509
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.forms import ValidationError
|
||||
from django.templatetags.static import static
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.translation import gettext as _, override
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
from pretix.plugins.wallet.styles.base import (
|
||||
ColorSettingsField,
|
||||
FieldGroup,
|
||||
FieldGroupDisplay,
|
||||
FloatSettingsField,
|
||||
ImageSettingsField,
|
||||
PassStyle,
|
||||
PlaceholderFieldEntry,
|
||||
TextFieldGroup,
|
||||
WalletPlatform,
|
||||
)
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class ApplePlatform(WalletPlatform):
|
||||
identifier = "apple"
|
||||
name = _("Apple")
|
||||
|
||||
|
||||
class FormattedLazyI18nString:
|
||||
def __init__(self, base_str: LazyI18nString, **format_args: str):
|
||||
self.base_str = base_str
|
||||
self.format_args = format_args
|
||||
|
||||
def localize(self, language):
|
||||
return self.base_str.localize(language).format(**self.format_args)
|
||||
|
||||
|
||||
def lazyi18nstring_from_gettext(text: str, locales: set[str]) -> LazyI18nString:
|
||||
data = {}
|
||||
for locale in locales:
|
||||
with override(locale):
|
||||
data[locale] = _(text)
|
||||
return LazyI18nString(data)
|
||||
|
||||
|
||||
class StringResource:
|
||||
entries: dict[str, LazyI18nString | FormattedLazyI18nString]
|
||||
locales: set[str]
|
||||
|
||||
def __init__(self, locales):
|
||||
self.entries = {}
|
||||
self.locales = set(locales)
|
||||
|
||||
def add_entry(self, key: str, value: LazyI18nString | FormattedLazyI18nString):
|
||||
if key in self.entries:
|
||||
raise ValueError(f"{key} already exists in this StringResource")
|
||||
self.entries[key] = value
|
||||
|
||||
def escape(self, string):
|
||||
return string.translate(
|
||||
str.maketrans({'"': '\\"', "\r": "\\r", "\n": "\\n", "\\": "\\\\"})
|
||||
)
|
||||
|
||||
def generate_resource(self, language):
|
||||
output = ""
|
||||
for key, entry in self.entries.items():
|
||||
output += (
|
||||
f'"{self.escape(key)}" = "{self.escape(entry.localize(language))}";\n'
|
||||
)
|
||||
return output.strip()
|
||||
|
||||
def generate(self):
|
||||
return {language: self.generate_resource(language) for language in self.locales}
|
||||
|
||||
|
||||
class SignedZipFile:
|
||||
"""Generates a zip-file with manifest and signature as apple expects a pkpass file to be"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ca_certificate: str | bytes,
|
||||
certificate: str | bytes,
|
||||
key: str | bytes,
|
||||
password,
|
||||
):
|
||||
self.ca_certificate = cryptography.x509.load_pem_x509_certificate(
|
||||
force_bytes(ca_certificate)
|
||||
)
|
||||
self.certificate = cryptography.x509.load_pem_x509_certificate(
|
||||
force_bytes(certificate)
|
||||
)
|
||||
self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
|
||||
force_bytes(key), force_bytes(password) if password else None
|
||||
)
|
||||
self.password = password
|
||||
|
||||
self.file = io.BytesIO()
|
||||
self.zip_file = zipfile.ZipFile(self.file, "w")
|
||||
self.manifest = {}
|
||||
|
||||
def sign(self, data: bytes):
|
||||
return (
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7SignatureBuilder()
|
||||
.set_data(data)
|
||||
.add_signer(
|
||||
self.certificate,
|
||||
self.key,
|
||||
cryptography.hazmat.primitives.hashes.SHA256(),
|
||||
)
|
||||
.add_certificate(self.ca_certificate)
|
||||
.sign(
|
||||
cryptography.hazmat.primitives.serialization.Encoding.DER,
|
||||
[
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.Binary,
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.DetachedSignature,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def finish(self):
|
||||
manifest = json.dumps(self.manifest).encode()
|
||||
signature = self.sign(manifest)
|
||||
self.add_file("manifest.json", manifest)
|
||||
self.add_file("signature", signature)
|
||||
self.zip_file.close()
|
||||
return self.file.getvalue()
|
||||
|
||||
def add_file(self, filename: str, content: str | bytes):
|
||||
if isinstance(content, str):
|
||||
content = content.encode()
|
||||
|
||||
with self.zip_file.open(filename, "w") as f:
|
||||
f.write(content)
|
||||
self.manifest[filename] = hashlib.sha1(content).hexdigest()
|
||||
|
||||
|
||||
def convert_to_png(file, max_size=None):
|
||||
# TODO: move validation to upload
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
except ImportError:
|
||||
return file
|
||||
|
||||
file.open("rb")
|
||||
file.seek(0)
|
||||
try:
|
||||
with (
|
||||
Image.open(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:
|
||||
logger.exception("Could not convert image to PNG.")
|
||||
raise ValidationError(
|
||||
_("The file you uploaded could not be converted to PNG format.")
|
||||
)
|
||||
|
||||
|
||||
class AppleWalletStyle(PassStyle):
|
||||
@property
|
||||
def settings(self):
|
||||
return [
|
||||
ImageSettingsField(
|
||||
identifier="logo",
|
||||
label=_("Logo"),
|
||||
help_text="Will be displayed on the top left corner of the pass",
|
||||
default="pretixplugins/wallet/logo.png",
|
||||
),
|
||||
ImageSettingsField(
|
||||
identifier="icon",
|
||||
label=_("Icon"),
|
||||
help_text="Will be displayed as the file icon",
|
||||
default="pretixplugins/wallet/icon.png",
|
||||
),
|
||||
ImageSettingsField(identifier="background", label=_("Background Image")),
|
||||
ColorSettingsField(identifier="bg_color", label=_("Background Color")),
|
||||
ColorSettingsField(identifier="fg_color", label=_("Foreground Color")),
|
||||
ColorSettingsField(identifier="label_color", label=_("Label Color")),
|
||||
FloatSettingsField(identifier="lat", label=_("Latitude"), min=-90, max=90),
|
||||
FloatSettingsField(
|
||||
identifier="long", label=_("Longitude"), min=-180, max=180
|
||||
),
|
||||
]
|
||||
|
||||
def pass_content(self, fields, strings):
|
||||
raise NotImplementedError()
|
||||
|
||||
def generate_pass_json(self, fields, op, strings):
|
||||
event = op.subevent or op.order.event
|
||||
tz = event.timezone
|
||||
|
||||
ticket = str(op.item.name)
|
||||
if op.variation:
|
||||
ticket += " - " + str(op.variation)
|
||||
|
||||
description = FormattedLazyI18nString(
|
||||
LazyI18nString.from_gettext("Ticket for {event} ({product})"),
|
||||
event=self.event.name,
|
||||
product=ticket,
|
||||
)
|
||||
strings.add_entry("description", description)
|
||||
|
||||
serialNumber = "%s-%s-%s-%d" % (
|
||||
self.event.organizer.slug,
|
||||
self.event.slug,
|
||||
op.order.code,
|
||||
op.pk,
|
||||
)
|
||||
|
||||
pass_json = {
|
||||
"formatVersion": 1,
|
||||
"description": "description",
|
||||
"organizationName": self.event.organizer.name,
|
||||
"passTypeIdentifier": self.event.settings.wallet_apple_pass_type_id,
|
||||
"teamIdentifier": self.event.settings.wallet_apple_team_id,
|
||||
"serialNumber": serialNumber,
|
||||
**self.pass_content(fields, strings),
|
||||
}
|
||||
|
||||
if bg_color := self.layout["settings"].get("bg_color"):
|
||||
pass_json["backgroundColor"] = (
|
||||
bg_color # TODO: specified as a CSS-style RGB triple, such as rgb(100, 10, 110).
|
||||
)
|
||||
if fg_color := self.layout["settings"].get("fg_color"):
|
||||
pass_json["foregroundColor"] = fg_color # TODO: specified as a CSS-style RGB triple, such as rgb(100, 10, 110).
|
||||
if label_color := self.layout["settings"].get("label_color"):
|
||||
pass_json["labelColor"] = label_color # TODO: specified as a CSS-style RGB triple, such as rgb(100, 10, 110).
|
||||
|
||||
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" in self.layout["settings"] and "long" in self.layout["settings"]:
|
||||
pass_json["locations"] = {
|
||||
"latitude": float(self.layout["settings"]["lat"]),
|
||||
"longitude": float(self.layout["settings"]["long"]),
|
||||
}
|
||||
elif event.geo_lat and event.geo_lon:
|
||||
pass_json["locations"] = {
|
||||
"latitude": float(event.get_lat),
|
||||
"longitude": float(event.get_lon),
|
||||
}
|
||||
|
||||
print(pass_json)
|
||||
return pass_json
|
||||
|
||||
def generate(self, op: OrderPosition):
|
||||
order = op.order
|
||||
filename = "{}-{}.pkpass".format(order.event.slug, order.code)
|
||||
|
||||
fields = self.get_pass_fields(op)
|
||||
|
||||
pkpass = SignedZipFile(
|
||||
self.event.settings.wallet_apple_ca_certificate.read(),
|
||||
self.event.settings.wallet_apple_certificate.read(),
|
||||
self.event.settings.wallet_apple_key.read(),
|
||||
self.event.settings.wallet_apple_key_password,
|
||||
)
|
||||
strings = StringResource(locales=self.event.settings.locales)
|
||||
|
||||
pass_json = self.generate_pass_json(fields, op, strings)
|
||||
|
||||
if file := self.file_settings.get("logo"):
|
||||
logo = convert_to_png(
|
||||
file, (480, 150)
|
||||
) # TODO: check max_size against apple HIG
|
||||
else:
|
||||
logo = open(finders.find("pretixplugins/wallet/logo.png"), "rb")
|
||||
|
||||
if file := self.file_settings.get("icon"):
|
||||
icon = convert_to_png(
|
||||
file, max_size=(87, 87)
|
||||
) # TODO: check max_size against apple HIG
|
||||
else:
|
||||
icon = open(finders.find("pretixplugins/wallet/icon.png"), "rb")
|
||||
|
||||
pkpass.add_file("icon.png", icon.read())
|
||||
pkpass.add_file("logo.png", logo.read())
|
||||
|
||||
if file := self.file_settings.get("background"):
|
||||
background = convert_to_png(
|
||||
file, max_size=(540, 660)
|
||||
) # TODO: check max_size against apple HIG
|
||||
pkpass.add_file("background.png", background.read())
|
||||
|
||||
for lang, content in strings.generate().items():
|
||||
pkpass.add_file(f"{lang}.lproj/pass.strings", content)
|
||||
pkpass.add_file("pass.json", json.dumps(pass_json))
|
||||
result = pkpass.finish()
|
||||
return filename, "application/vnd.apple.pkpass", result
|
||||
|
||||
|
||||
class AppleWalletEventTicket(AppleWalletStyle):
|
||||
identifier = "event_1"
|
||||
name = _("Event Ticket Layout 1")
|
||||
|
||||
@property
|
||||
def fieldgroups(self) -> list[FieldGroup]:
|
||||
return [
|
||||
TextFieldGroup(
|
||||
identifier="logo_text",
|
||||
name=_("Logo text"),
|
||||
max_entries=1,
|
||||
display=FieldGroupDisplay.PLAIN,
|
||||
context_args={"order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="header",
|
||||
name=_("Header"),
|
||||
max_entries=3,
|
||||
context_args={"order_position"},
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
content="admission_or_date_from",
|
||||
label=lazyi18nstring_from_gettext(
|
||||
"Admission", self.event.settings.locales
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="secondary",
|
||||
name=_("Secondary"),
|
||||
max_entries=4,
|
||||
context_args={"order_position"},
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
content="item_with_variation",
|
||||
label=lazyi18nstring_from_gettext(
|
||||
"Product", self.event.settings.locales
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
# 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=lazyi18nstring_from_gettext(
|
||||
"Seat", locales=self.event.settings.locales
|
||||
),
|
||||
),
|
||||
PlaceholderFieldEntry(
|
||||
content="attendee_name",
|
||||
),
|
||||
PlaceholderFieldEntry(
|
||||
content="program_start",
|
||||
label=lazyi18nstring_from_gettext(
|
||||
"From", locales=self.event.settings.locales
|
||||
),
|
||||
),
|
||||
PlaceholderFieldEntry(
|
||||
content="program_end",
|
||||
label=lazyi18nstring_from_gettext(
|
||||
"To", locales=self.event.settings.locales
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="code",
|
||||
name=_("QR-Code"),
|
||||
max_entries=1,
|
||||
display=FieldGroupDisplay.CODE,
|
||||
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=lazyi18nstring_from_gettext(
|
||||
"Ordered by", self.event.settings.locales
|
||||
),
|
||||
),
|
||||
PlaceholderFieldEntry(
|
||||
content="organizer",
|
||||
),
|
||||
PlaceholderFieldEntry(
|
||||
content="organizer_contact",
|
||||
),
|
||||
PlaceholderFieldEntry(
|
||||
content="order_code",
|
||||
),
|
||||
PlaceholderFieldEntry(
|
||||
content="purchase_date",
|
||||
),
|
||||
PlaceholderFieldEntry(
|
||||
content="website",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
def preview_layout(self):
|
||||
return [
|
||||
{
|
||||
"style": {
|
||||
"--background-color": {"setting": "bg_color"},
|
||||
"color": {"setting": "fg_color"},
|
||||
"--label-color": {"setting": "label_color"},
|
||||
"--background-image": {"setting": "background"},
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"children": [
|
||||
{"setting": "logo", "display": ["img-inline"]},
|
||||
{
|
||||
"fieldgroup": "logo_text",
|
||||
"relSize": 3,
|
||||
"display": ["bold", "large", "centered"],
|
||||
},
|
||||
{
|
||||
"fieldgroup": "header",
|
||||
"relSize": 2,
|
||||
"display": ["large", "tight"],
|
||||
},
|
||||
]
|
||||
},
|
||||
{"fieldgroup": "primary", "display": "large"},
|
||||
{"fieldgroup": "secondary"},
|
||||
{"fieldgroup": "auxiliary"},
|
||||
{"fieldgroup": "code"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"rows": [{"fieldgroup": "back", "direction": "column"}],
|
||||
},
|
||||
]
|
||||
|
||||
def convert_fields(self, strings, fields, prefix):
|
||||
converted = []
|
||||
for i, f in enumerate(fields):
|
||||
converted_field = {**f, "key": f"{prefix}-{i}"}
|
||||
if "label" in converted_field and isinstance(
|
||||
converted_field["label"], LazyI18nString
|
||||
):
|
||||
strings.add_entry(f"{prefix}-{i}-label", converted_field["label"])
|
||||
converted_field["label"] = f"{prefix}-{i}-label"
|
||||
|
||||
if isinstance(converted_field["value"], LazyI18nString):
|
||||
strings.add_entry(f"{prefix}-{i}-value", converted_field["value"])
|
||||
converted_field["value"] = f"{prefix}-{i}-value"
|
||||
converted.append(converted_field)
|
||||
return converted
|
||||
|
||||
def pass_content(self, fields, strings):
|
||||
content: dict[str, Any] = {
|
||||
"eventTicket": {
|
||||
"primaryFields": self.convert_fields(
|
||||
strings, fields["primary"], "primary"
|
||||
),
|
||||
"secondaryFields": self.convert_fields(
|
||||
strings, fields["secondary"], "secondary"
|
||||
),
|
||||
"auxiliaryFields": self.convert_fields(
|
||||
strings, fields["auxiliary"], "auxiliary"
|
||||
),
|
||||
"backFields": self.convert_fields(strings, fields["back"], "back"),
|
||||
"headerFields": self.convert_fields(
|
||||
strings, fields["header"], "header"
|
||||
),
|
||||
},
|
||||
}
|
||||
if fields["logo_text"]:
|
||||
content["logoText"] = self.convert_fields(
|
||||
strings, fields["logo_text"], "logo_text"
|
||||
)[0]["value"]
|
||||
|
||||
if fields["code"]:
|
||||
content["barcodes"] = [
|
||||
{
|
||||
"format": "PKBarcodeFormatQR",
|
||||
"message": str(fields["code"][0]["value"]),
|
||||
"messageEncoding": "utf-8",
|
||||
"altText": str(fields["code"][0]["value"]),
|
||||
}
|
||||
]
|
||||
return content
|
||||
@@ -0,0 +1,623 @@
|
||||
import enum
|
||||
from typing import Any, Literal, 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 ..placeholders import (
|
||||
WalletPlaceholderRenderer,
|
||||
get_available_context,
|
||||
get_wallet_placeholder_renderer,
|
||||
get_wallet_placeholders,
|
||||
)
|
||||
from django.templatetags.static import static
|
||||
from urllib.parse import urljoin
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
|
||||
|
||||
class WalletPlatform:
|
||||
identifier: str
|
||||
name: str
|
||||
|
||||
|
||||
class LayoutContext(TypedDict):
|
||||
placeholders: dict[str, dict]
|
||||
placeholder_renderer: WalletPlaceholderRenderer
|
||||
|
||||
|
||||
class FieldGroupType(enum.Enum):
|
||||
PLACEHOLDER = "placeholder"
|
||||
PREDEFINED = "predefined"
|
||||
|
||||
|
||||
class FieldGroupDisplay(enum.Enum):
|
||||
PLAIN = "plain"
|
||||
WITH_LABEL = "with_label"
|
||||
CODE = "code"
|
||||
|
||||
|
||||
class FieldGroup:
|
||||
type: FieldGroupType
|
||||
identifier: str
|
||||
name: str
|
||||
description: str
|
||||
required: bool = False
|
||||
|
||||
def __init__(self, identifier: str, name: str, description=None, required=False):
|
||||
self.identifier = identifier
|
||||
self.name = name
|
||||
self.required = required
|
||||
self.description = description or ""
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: LayoutContext,
|
||||
) -> dict:
|
||||
raise NotImplementedError()
|
||||
|
||||
def asdict(self, context: LayoutContext):
|
||||
return {
|
||||
"type": self.type.value,
|
||||
"identifier": self.identifier,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"required": self.required,
|
||||
}
|
||||
|
||||
|
||||
class FieldContentType(enum.Enum):
|
||||
IMAGE = "image"
|
||||
TEXT = "text"
|
||||
|
||||
|
||||
class FieldEntryType(enum.Enum):
|
||||
CUSTOM = "custom"
|
||||
PLACEHOLDER = "placeholder"
|
||||
|
||||
|
||||
class FieldEntry[T]:
|
||||
type: FieldEntryType
|
||||
label: LazyI18nString | None
|
||||
content: T
|
||||
|
||||
def __init__(
|
||||
self, type: FieldEntryType, content: T, label: LazyI18nString | None = None
|
||||
):
|
||||
self.type = type
|
||||
self.label = label
|
||||
self.content = content
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {
|
||||
"type": self.type.value,
|
||||
"content": self.content,
|
||||
"label": self.label.data if self.label is not None else None,
|
||||
}
|
||||
|
||||
|
||||
class PlaceholderFieldEntry(FieldEntry[str]):
|
||||
type = FieldEntryType.PLACEHOLDER
|
||||
label: LazyI18nString | None
|
||||
content: str
|
||||
|
||||
def __init__(self, content: str, label: LazyI18nString | None = None):
|
||||
self.label = label
|
||||
self.content = content
|
||||
|
||||
|
||||
class CustomFieldEntry(FieldEntry[LazyI18nString]):
|
||||
type: FieldEntryType
|
||||
label: LazyI18nString | None
|
||||
content: LazyI18nString
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {
|
||||
"type": self.type.value,
|
||||
"content": self.content.data,
|
||||
"label": self.label.data if self.label else None,
|
||||
}
|
||||
|
||||
|
||||
class PredefinedFieldGroup(FieldGroup):
|
||||
type = FieldGroupType.PREDEFINED
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: LayoutContext,
|
||||
):
|
||||
return {"type": "object", "properties": {"active": {"type": "boolean"}}}
|
||||
|
||||
|
||||
class PlaceholderFieldGroup(FieldGroup):
|
||||
type = FieldGroupType.PLACEHOLDER
|
||||
content_type: FieldContentType
|
||||
default_entries: list[FieldEntry]
|
||||
display: FieldGroupDisplay
|
||||
min_entries: int | None
|
||||
max_entries: int | None
|
||||
context_args: set[
|
||||
str
|
||||
] # what context arguments are available when rendering this fieldgroup
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
name: str,
|
||||
content_type: FieldContentType,
|
||||
description: str = "",
|
||||
required=False,
|
||||
default_entries=None,
|
||||
min_entries=None,
|
||||
max_entries=None,
|
||||
display=FieldGroupDisplay.WITH_LABEL,
|
||||
context_args: set[str] | None = None,
|
||||
):
|
||||
super().__init__(identifier, name, description, required)
|
||||
self.content_type = content_type
|
||||
self.default_entries = default_entries or []
|
||||
self.min_entries = min_entries
|
||||
self.max_entries = max_entries
|
||||
self.display = display
|
||||
self.context_args = context_args or set()
|
||||
|
||||
if self.required and (self.min_entries is None or self.min_entries < 1):
|
||||
self.min_entries = 1
|
||||
|
||||
def asdict(self, context: LayoutContext):
|
||||
return {
|
||||
**super().asdict(context),
|
||||
"content_type": self.content_type.value,
|
||||
"default_entries": [x.asdict() for x in self.default_entries],
|
||||
"display": self.display.value,
|
||||
"min_entries": self.min_entries,
|
||||
"max_entries": self.max_entries,
|
||||
"context_args": list(
|
||||
get_available_context(
|
||||
self.context_args, context["placeholder_renderer"].transformations
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: LayoutContext,
|
||||
):
|
||||
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 {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": self.entries_schema(placeholders=available_placeholders),
|
||||
"overflow": {
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
f.identifier
|
||||
for f in remaining_fields
|
||||
if isinstance(f, PlaceholderFieldGroup)
|
||||
and f.content_type == self.content_type
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
"required": ["entries"],
|
||||
}
|
||||
|
||||
def entries_schema(self, placeholders: list[str]):
|
||||
baseprops = {}
|
||||
if self.display == FieldGroupDisplay.WITH_LABEL:
|
||||
baseprops["label"] = {"$ref": "#/$defs/I18nString"}
|
||||
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"anyOf": [
|
||||
{
|
||||
"properties": {
|
||||
**baseprops,
|
||||
"type": {"const": "placeholder"},
|
||||
"content": {"enum": placeholders},
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
**baseprops,
|
||||
"type": {"const": "custom"},
|
||||
"content": {"$ref": "#/$defs/I18nString"},
|
||||
}
|
||||
},
|
||||
],
|
||||
"required": ["type", "content"],
|
||||
},
|
||||
}
|
||||
if self.display == FieldGroupDisplay.WITH_LABEL:
|
||||
schema["items"]["required"].append("label")
|
||||
if self.min_entries is not None:
|
||||
schema["minItems"] = self.min_entries
|
||||
# max_entries is not enforced here, as the layout can have more fields than that (null-fields are removed, rest is overspilled)
|
||||
return schema
|
||||
|
||||
|
||||
class TextFieldGroup(PlaceholderFieldGroup):
|
||||
content_type = FieldContentType.TEXT
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(content_type=self.content_type, **kwargs)
|
||||
|
||||
|
||||
class ImageFieldGroup(PlaceholderFieldGroup):
|
||||
content_type = FieldContentType.IMAGE
|
||||
display = FieldGroupDisplay.PLAIN
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(content_type=self.content_type, display=self.display, **kwargs)
|
||||
|
||||
|
||||
SettingType = Literal["image", "text", "float", "color"]
|
||||
|
||||
|
||||
class SettingsField:
|
||||
identifier: str
|
||||
label: str
|
||||
type: SettingType
|
||||
help_text: str | None
|
||||
required: bool
|
||||
default: str | None
|
||||
|
||||
def asdict(self):
|
||||
return {
|
||||
"identifier": self.identifier,
|
||||
"label": self.label,
|
||||
"type": self.type,
|
||||
"help_text": self.help_text,
|
||||
"required": self.required,
|
||||
"default": self.default,
|
||||
}
|
||||
|
||||
def layout_schema(self) -> dict[str, Any] | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class TextSettingsField(SettingsField):
|
||||
default: str | None
|
||||
|
||||
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):
|
||||
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 ImageSettingsField(SettingsField):
|
||||
_default: str | File | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
label: str,
|
||||
help_text: str | None = None,
|
||||
default: str | File | None = None,
|
||||
):
|
||||
self.identifier = identifier
|
||||
self.label = label
|
||||
self.type = "image"
|
||||
self.help_text = help_text
|
||||
self.required = False
|
||||
self._default = default
|
||||
|
||||
@property
|
||||
def 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
|
||||
|
||||
|
||||
class FloatSettingsField(SettingsField):
|
||||
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: str | 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 = {"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):
|
||||
return super().asdict() | {"min": self.min, "max": self.max}
|
||||
|
||||
|
||||
class PassStyle:
|
||||
identifier: str # unique within platform
|
||||
name: str
|
||||
|
||||
def __init__(
|
||||
self, event, layout=None, file_settings: dict[str, File] | None = None
|
||||
):
|
||||
self.event = event
|
||||
self.layout = layout
|
||||
self.file_settings = file_settings or {}
|
||||
self.placeholders = get_wallet_placeholders(self.event)
|
||||
|
||||
@property
|
||||
def fieldgroups(self) -> list[FieldGroup]:
|
||||
# order here limits in what order users can configure field "overspilling" (if too many fields are defined, where should the rest go)
|
||||
# -> can only go down in the list
|
||||
# we evaluate the fields in this order, so they overspill in this order as well
|
||||
return []
|
||||
|
||||
@property
|
||||
def settings(self) -> list[SettingsField]:
|
||||
return []
|
||||
|
||||
@property
|
||||
def preview_layout(self) -> list | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def cleaned_settings(self):
|
||||
if not self.layout:
|
||||
return {}
|
||||
settings = {}
|
||||
for setting in self.settings:
|
||||
if isinstance(setting, ImageSettingsField):
|
||||
settings[setting.identifier] = self.file_settings.get(
|
||||
setting.identifier
|
||||
)
|
||||
if not settings[setting.identifier] and setting.default:
|
||||
settings[setting.identifier] = setting.default
|
||||
else:
|
||||
settings[setting.identifier] = self.layout.get("settings", {}).get(
|
||||
setting.identifier, setting.default
|
||||
)
|
||||
if not settings[setting.identifier] and setting.default:
|
||||
settings[setting.identifier] = setting.default
|
||||
return settings
|
||||
|
||||
def asdict(self): # -> dict[str, Any]:
|
||||
context = LayoutContext(
|
||||
placeholders=self.placeholders,
|
||||
placeholder_renderer=get_wallet_placeholder_renderer(),
|
||||
)
|
||||
return {
|
||||
"identifier": self.identifier,
|
||||
"name": self.name,
|
||||
"fieldgroups": [x.asdict(context) for x in self.fieldgroups],
|
||||
"preview_layout": self.preview_layout,
|
||||
"settings": [x.asdict() for x in self.settings],
|
||||
}
|
||||
|
||||
def layout_schema(self):
|
||||
context = LayoutContext(
|
||||
placeholders=self.placeholders,
|
||||
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)
|
||||
},
|
||||
"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 get_pass_fields(self, op: OrderPosition):
|
||||
if not self.layout:
|
||||
raise ValueError("`value` needs to be set")
|
||||
|
||||
context = get_wallet_placeholder_renderer(order_position=op)
|
||||
|
||||
fields = {}
|
||||
for group in self.fieldgroups:
|
||||
if isinstance(group, PredefinedFieldGroup):
|
||||
pass
|
||||
|
||||
elif isinstance(group, PlaceholderFieldGroup):
|
||||
group_fields = fields.get(group.identifier, [])
|
||||
if group.identifier in self.layout["fieldgroups"]:
|
||||
for field in self.layout["fieldgroups"][group.identifier][
|
||||
"entries"
|
||||
]:
|
||||
field_entry = {}
|
||||
if group.display == FieldGroupDisplay.WITH_LABEL:
|
||||
field_entry["label"] = LazyI18nString(field["label"])
|
||||
if field["type"] == FieldEntryType.PLACEHOLDER.value:
|
||||
label, field_entry["value"] = self.render_placeholder(
|
||||
context, group.content_type.value, field["content"]
|
||||
)
|
||||
if (
|
||||
group.display == FieldGroupDisplay.WITH_LABEL
|
||||
and not str(field_entry["label"])
|
||||
and label
|
||||
):
|
||||
field_entry["label"] = LazyI18nString(label)
|
||||
|
||||
elif field["type"] == FieldEntryType.CUSTOM.value:
|
||||
field_entry["value"] = LazyI18nString(field["content"])
|
||||
if "value" in field_entry and field_entry["value"]:
|
||||
group_fields.append(field_entry)
|
||||
if group.min_entries and len(group_fields) < group.min_entries:
|
||||
raise ValueError(
|
||||
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(group_fields)} were provided"
|
||||
)
|
||||
fields[group.identifier] = group_fields[: group.max_entries]
|
||||
if overflow_group := self.layout["fieldgroups"][group.identifier][
|
||||
"overflow"
|
||||
]:
|
||||
fields.setdefault(overflow_group, [])
|
||||
fields[overflow_group] += group_fields[group.max_entries :]
|
||||
|
||||
else:
|
||||
raise ValueError("Unknown field group")
|
||||
return fields
|
||||
|
||||
def group_is_active(self, identifier: str):
|
||||
if not self.layout:
|
||||
raise ValueError("`value` needs to be set")
|
||||
|
||||
return self.layout["fieldgroups"].get(identifier, {}).get("active", False)
|
||||
|
||||
def generate(self, op: OrderPosition):
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,414 @@
|
||||
from functools import cached_property
|
||||
from urllib.parse import urljoin
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext as _
|
||||
from walletobjects import ButtonJWT, EventTicketClass, EventTicketObject
|
||||
from walletobjects.comms import Comms
|
||||
from walletobjects.constants import (
|
||||
Barcode,
|
||||
ClassType,
|
||||
MultipleDevicesAndHoldersAllowedStatus,
|
||||
ObjectState,
|
||||
ObjectType,
|
||||
ReviewStatus,
|
||||
AnimationType,
|
||||
DoorsOpen,
|
||||
ConfirmationCode
|
||||
)
|
||||
|
||||
|
||||
from pretix.base.models import Event, OrderPosition
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from pretix.plugins.wallet.models import GoogleWalletInstance, GoogleWalletType
|
||||
from pretix.plugins.wallet.styles.base import (
|
||||
ColorSettingsField,
|
||||
FieldGroup,
|
||||
FieldGroupDisplay,
|
||||
FloatSettingsField,
|
||||
ImageFieldGroup,
|
||||
ImageSettingsField,
|
||||
PassStyle,
|
||||
PlaceholderFieldEntry,
|
||||
PredefinedFieldGroup,
|
||||
TextFieldGroup,
|
||||
WalletPlatform,
|
||||
)
|
||||
from django.db import transaction
|
||||
|
||||
SHIMMER = False
|
||||
|
||||
|
||||
def _get_instance_uuid():
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.wallet_google_instance_uuid:
|
||||
gs.settings.wallet_google_instance_uuid = str(uuid.uuid4())
|
||||
|
||||
return gs.settings.wallet_google_instance_uuid
|
||||
|
||||
|
||||
def get_class_id(event: Event, op: OrderPosition):
|
||||
instance_uuid = _get_instance_uuid()
|
||||
issuer_id = event.settings.get("wallet_google_issuer_id")
|
||||
return "%s.pretix-%s-%s-%s-%s-%s" % (
|
||||
issuer_id,
|
||||
instance_uuid,
|
||||
event.organizer.slug,
|
||||
event.slug,
|
||||
op.item_id,
|
||||
op.variation_id or 0,
|
||||
)
|
||||
|
||||
|
||||
def get_object_id(op: OrderPosition):
|
||||
instance_uuid = _get_instance_uuid()
|
||||
issuer_id = op.order.event.settings.get("wallet_google_issuer_id")
|
||||
|
||||
return "%s.pretix-%s-%s-%s-%s-%s-1" % (
|
||||
issuer_id,
|
||||
instance_uuid,
|
||||
op.order.event.organizer.slug,
|
||||
op.order.event.slug,
|
||||
op.order.code,
|
||||
op.positionid,
|
||||
)
|
||||
|
||||
|
||||
def get_translated_dict(string, locales):
|
||||
translated = {}
|
||||
|
||||
for locale in locales:
|
||||
translation.activate(locale)
|
||||
translated[locale] = _(string)
|
||||
translation.deactivate()
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
def get_translated_string(string, locale):
|
||||
translation.activate(locale)
|
||||
translated = _(string)
|
||||
translation.deactivate()
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
class GooglePlatform(WalletPlatform):
|
||||
identifier = "google"
|
||||
name = _("Google")
|
||||
|
||||
|
||||
class GoogleWalletEventTicketStyle(PassStyle):
|
||||
platform = GooglePlatform
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
return [
|
||||
ImageSettingsField(
|
||||
identifier="logo",
|
||||
label=_("Logo"),
|
||||
default="pretixplugins/wallet/logo.png",
|
||||
),
|
||||
ImageSettingsField(
|
||||
identifier="hero",
|
||||
label=_("Hero Image"),
|
||||
default=self.event.settings.logo_image,
|
||||
),
|
||||
ColorSettingsField(
|
||||
identifier="bg_color",
|
||||
label=_("Background Color"),
|
||||
default=self.event.settings.primary_color,
|
||||
),
|
||||
FloatSettingsField(identifier="lat", label=_("Latitude"), min=-90, max=90),
|
||||
FloatSettingsField(
|
||||
identifier="long", label=_("Longitude"), min=-180, max=180
|
||||
),
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def comms(self):
|
||||
return Comms(self.event.settings.get("wallet_google_credentials").read())
|
||||
|
||||
def put_item_cached(self, instance_type, item_type, object):
|
||||
with transaction.atomic():
|
||||
instance, created = GoogleWalletInstance.objects.get_or_create(
|
||||
event=self.event,
|
||||
type=instance_type,
|
||||
identifier=object["id"],
|
||||
defaults={"data": object},
|
||||
)
|
||||
if created or instance.data != object:
|
||||
result = self.comms.put_item(item_type, object["id"], object)
|
||||
instance.data = object
|
||||
instance.save(update_fields=["data"])
|
||||
return result
|
||||
|
||||
def _generate_class(self):
|
||||
output_class = EventTicketClass(
|
||||
self.event.organizer.name,
|
||||
get_class_id(self.event, self.op),
|
||||
MultipleDevicesAndHoldersAllowedStatus.multipleHolders, # TODO: Make configurable
|
||||
self.event.name,
|
||||
ReviewStatus.underReview,
|
||||
self.event.settings.locale,
|
||||
)
|
||||
|
||||
output_class.homepage_uri(
|
||||
eventreverse_absolute(self.event, "presale:event.index"),
|
||||
get_translated_string("Website", self.event.settings.locale),
|
||||
get_translated_dict("Website", self.event.settings.locales),
|
||||
)
|
||||
|
||||
# TODO: callback url
|
||||
# output_class.callback_url(eventreverse_absolute(event.organizer,"plugins:wallet:google_webhook",))
|
||||
|
||||
if (lat := self.cleaned_settings["lat"]) and (
|
||||
long := self.cleaned_settings["long"]
|
||||
):
|
||||
output_class.locations(lat, long)
|
||||
elif self.event.geo_lat and self.event.geo_lon:
|
||||
output_class.locations(self.event.geo_lat, self.event.geo_lon)
|
||||
|
||||
output_class.country_code(self.event.settings.locale)
|
||||
|
||||
if hero := self.cleaned_settings["hero"]:
|
||||
output_class.hero_image(
|
||||
urljoin(eventreverse_absolute(self.event, "presale:event.index"), hero),
|
||||
self.event.name,
|
||||
)
|
||||
|
||||
if logo := self.cleaned_settings["logo"]:
|
||||
output_class.logo(
|
||||
urljoin(eventreverse_absolute(self.event, "presale:event.index"), logo),
|
||||
self.event.name,
|
||||
)
|
||||
|
||||
output_class.hex_background_color(self.cleaned_settings["bg_color"])
|
||||
|
||||
# # if event.date_from and event.date_to and event.date_admission:
|
||||
# output_class.date_time(
|
||||
# DoorsOpen.doorsOpen,
|
||||
# event.date_admission.isoformat(),
|
||||
# event.date_from.isoformat(),
|
||||
# event.date_to.isoformat(),
|
||||
# )
|
||||
|
||||
output_class.confirmation_code_label(ConfirmationCode.orderNumber)
|
||||
|
||||
# if event.seating_plan_id is not None:
|
||||
# output_class.seat_label(Seat.seat)
|
||||
|
||||
# return self._comms().put_item(ClassType.eventTicketClass, class_name, output_class)
|
||||
return output_class
|
||||
|
||||
def _generate_object(self, op: OrderPosition, class_id: str):
|
||||
output_object = EventTicketObject(
|
||||
get_object_id(op), class_id, ObjectState.active, self.event.settings.locale
|
||||
)
|
||||
return output_object
|
||||
|
||||
def generate(self, op):
|
||||
self.op = op
|
||||
|
||||
class_object = self._generate_class()
|
||||
ticket_object = self._generate_object(op, class_id=class_object["id"])
|
||||
|
||||
self.put_item_cached(
|
||||
GoogleWalletType.CLASS, ClassType.eventTicketClass, class_object
|
||||
)
|
||||
self.put_item_cached(
|
||||
GoogleWalletType.OBJECT, ObjectType.eventTicketObject, ticket_object
|
||||
)
|
||||
|
||||
generated_jwt = self.comms.sign_jwt(
|
||||
ButtonJWT(
|
||||
origins=[settings.SITE_URL],
|
||||
issuer=self.comms.client_email,
|
||||
event_ticket_objects=[ticket_object],
|
||||
skinny=True,
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
"googlepaypass",
|
||||
"text/uri-list",
|
||||
"https://pay.google.com/gp/v/save/%s" % generated_jwt,
|
||||
)
|
||||
|
||||
|
||||
class GoogleWalletDefaultEventTicket(GoogleWalletEventTicketStyle):
|
||||
identifier = "event"
|
||||
name = "Event Ticket"
|
||||
|
||||
@property
|
||||
def fieldgroups(self):
|
||||
return [
|
||||
ImageFieldGroup(
|
||||
identifier="logo",
|
||||
name=_("Logo"),
|
||||
min_entries=0,
|
||||
max_entries=1,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
content="poweredby",
|
||||
)
|
||||
],
|
||||
),
|
||||
PredefinedFieldGroup(identifier="venue", name=_("Venue")),
|
||||
PredefinedFieldGroup(identifier="date", name=_("Date")),
|
||||
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
|
||||
PredefinedFieldGroup(identifier="ticket_holder", name=_("Ticket Holder")),
|
||||
TextFieldGroup(
|
||||
identifier="code",
|
||||
name=_("QR-Code"),
|
||||
max_entries=1,
|
||||
display=FieldGroupDisplay.CODE,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
content="secret",
|
||||
)
|
||||
],
|
||||
context_args={"order_position"},
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
def preview_layout(self):
|
||||
return [
|
||||
{
|
||||
"style": {
|
||||
"--background-color": {"setting": "bg_color"},
|
||||
"color": "contrast-color(var(--background-color))",
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"children": [
|
||||
{"setting": "logo", "display": ["img-inline"]},
|
||||
{
|
||||
"value": str(self.event.organizer.name),
|
||||
"relSize": 3,
|
||||
"display": ["large", "centered"],
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"fieldgroup": "venue",
|
||||
"sample": [
|
||||
{"content": self.venue()[0], "label": ""},
|
||||
],
|
||||
},
|
||||
{"value": str(self.event.name), "display": "large"},
|
||||
],
|
||||
"direction": "column",
|
||||
"display": ["tight"],
|
||||
},
|
||||
{
|
||||
"fieldgroup": "date",
|
||||
"sample": [
|
||||
{"content": "01/01/1970", "label": "Date"},
|
||||
{"content": "12:34", "label": "Time"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"fieldgroup": "seating",
|
||||
"sample": [
|
||||
{"content": "5", "label": "Row"},
|
||||
{"content": "2", "label": "Seat"},
|
||||
],
|
||||
},
|
||||
{"fieldgroup": "code"},
|
||||
{"setting": "hero"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"rows": [
|
||||
{
|
||||
"fieldgroup": "ticket_holder",
|
||||
"sample": [
|
||||
{"content": _("John Doe"), "label": _("Ticket Holder")},
|
||||
],
|
||||
},
|
||||
{
|
||||
"fieldgroup": "venue",
|
||||
"sample": [
|
||||
{"content": self.venue()[1], "label": self.venue()[0]},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
def venue(self):
|
||||
if self.event.location:
|
||||
name = {}
|
||||
address = {}
|
||||
|
||||
for key, value in self.event.location.data.items():
|
||||
lines = value.splitlines()
|
||||
name[key] = lines[0]
|
||||
# We must provide at least one address line each for the name and address - no way around it.
|
||||
if len(lines) > 1:
|
||||
address[key] = "\n".join(value.splitlines()[1:])
|
||||
else:
|
||||
address[key] = lines[0]
|
||||
|
||||
return name, address
|
||||
return "", ""
|
||||
|
||||
def _generate_class(self):
|
||||
output_class = super()._generate_class()
|
||||
if self.group_is_active("venue") and all(self.venue()):
|
||||
output_class.venue(*self.venue())
|
||||
if SHIMMER:
|
||||
output_class.securityAnimation(AnimationType.FOIL_SHIMMER)
|
||||
return output_class
|
||||
|
||||
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)
|
||||
fields = self.get_pass_fields(op)
|
||||
|
||||
if fields["code"]:
|
||||
output_object.barcode(
|
||||
Barcode.qrCode, fields["code"][0]["value"], fields["code"][0]["value"]
|
||||
)
|
||||
output_object.reservation_info(op.order.code)
|
||||
output_object.ticket_number(op.code)
|
||||
|
||||
if self.group_is_active("venue") and self.ticket_holder_name(op):
|
||||
output_object.ticket_holder_name(self.ticket_holder_name(op))
|
||||
# output_object.ticket_type(
|
||||
# get_translated_dict(
|
||||
# str(op.item) + (" – " + str(op.variation.value) if op.variation else ""),
|
||||
# op.order.event.settings.get('locales')
|
||||
# )
|
||||
# )
|
||||
|
||||
# places = django_settings.CURRENCY_PLACES.get(op.order.event.currency, 2)
|
||||
# output_object.face_value(int(op.price * 1000 ** places), op.order.event.currency)
|
||||
|
||||
# if op.order.event.seating_plan_id is not None:
|
||||
# if op.seat:
|
||||
# output_object.seat(
|
||||
# get_translated_dict(
|
||||
# _(str(op.seat)),
|
||||
# op.order.event.settings.get('locales')
|
||||
# )
|
||||
# )
|
||||
# else:
|
||||
# output_object.seat(
|
||||
# get_translated_dict(
|
||||
# _('General admission'),
|
||||
# op.order.event.settings.get('locales')
|
||||
# )
|
||||
# )
|
||||
|
||||
# return self._comms().put_item(ObjectType.eventTicketObject, object_name, output_object)
|
||||
return output_object
|
||||
@@ -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,19 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Wallet layout" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Wallet layout" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans with name=object.name %}Are you sure you want to delete <strong>"{{ name }}"</strong>?{% endblocktrans %}</p>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "plugins:wallet:index" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load bootstrap3 %}
|
||||
{% load vite %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
|
||||
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "Edit layout" %}</h1>
|
||||
{{ platforms|json_script:"platforms" }}
|
||||
{{ variables|json_script:"variables" }}
|
||||
{{ locales|json_script:"locales" }}
|
||||
<div id="editor" data-layout-id="{{ object.pk }}"></div>
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %}
|
||||
|
||||
{% csrf_token %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load wallet %}
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Wallet layouts" %}</h1>
|
||||
{% if object_list|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You haven't created any layouts yet.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
{% if "event.settings.general:write" in request.eventpermset %}
|
||||
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new layout" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Default" %}</th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in object_list %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if "can_change_event_settings" in request.eventpermset %}
|
||||
<strong><a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
|
||||
{{ l.name }}
|
||||
</a></strong>
|
||||
{% else %}
|
||||
<strong>{{ l.name }}</strong>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if l.default %}
|
||||
<span class="text-success">
|
||||
<span class="fa fa-check"></span>
|
||||
{% trans "Default" %}
|
||||
</span>
|
||||
{% elif "can_change_event_settings" in request.eventpermset %}
|
||||
<form class="form-inline" method="post"
|
||||
action="{% url "plugins:wallet:default" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-sm">
|
||||
{% trans "Make default" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
{% if "can_change_event_settings" in request.eventpermset %}
|
||||
<a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ l.id }}"
|
||||
class="btn btn-default btn-sm" title="{% trans "Clone" %}" data-toggle="tooltip"><i class="fa fa-copy"></i></a>
|
||||
<a href="{% url "plugins:wallet:delete" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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,210 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
from pretix.base.ticketoutput import BaseTicketOutput
|
||||
from pretix.control.forms import ClearableBasenameFileInput
|
||||
from pretix.plugins.wallet.styles.base import WalletPlatform
|
||||
|
||||
from .forms import CertificateFileField, validate_rsa_privkey
|
||||
from .styles.apple import ApplePlatform
|
||||
from .styles.google import GooglePlatform
|
||||
|
||||
logger = logging.getLogger("pretix.plugins.wallet")
|
||||
|
||||
|
||||
class WalletSettingsHolder(BaseTicketOutput):
|
||||
identifier = "wallet"
|
||||
verbose_name = _("Wallet Output")
|
||||
|
||||
is_meta = True
|
||||
is_enabled = False
|
||||
preview_allowed = (
|
||||
False # TODO: implement own preview view or hide button for meta-outputs
|
||||
)
|
||||
|
||||
def settings_content_render(self, request) -> str:
|
||||
return render_to_string(
|
||||
"pretixplugins/wallet/settings_content.html", {"request": request}
|
||||
)
|
||||
|
||||
|
||||
class WalletOutput(BaseTicketOutput):
|
||||
settings_form_fields = []
|
||||
platform: WalletPlatform
|
||||
|
||||
def __init__(self, event: Event):
|
||||
super().__init__(event)
|
||||
self.settings = SettingsSandbox(
|
||||
"ticketoutput", WalletSettingsHolder.identifier, event
|
||||
)
|
||||
|
||||
def generate(self, op):
|
||||
if hasattr(op.item, "walletlayout"):
|
||||
wallet_layout = op.item.walletlayout.layout
|
||||
else:
|
||||
wallet_layout = op.event.wallet_layouts.get(default=True)
|
||||
platform_layout = get_object_or_404(
|
||||
wallet_layout.platform_layouts, platform=self.platform.identifier
|
||||
)
|
||||
return platform_layout.pass_layout.generate(op)
|
||||
|
||||
|
||||
class GoogleWalletTicketOutput(WalletOutput):
|
||||
identifier = "wallet_google"
|
||||
verbose_name = _("Google")
|
||||
download_button_text = "Add to Google Wallet"
|
||||
platform = GooglePlatform
|
||||
is_cacheable = False
|
||||
|
||||
@property
|
||||
def confirmation_text(self) -> str | None:
|
||||
return _(
|
||||
"Please be aware, that contrary to other virtual wallets/passes (like Apple Wallet), Google Wallet Passes are not "
|
||||
"handled offline. Every pass that is created, has to be transmitted to Google Inc.\n"
|
||||
"\n"
|
||||
'By clicking "Confirm" below, we will transfer some of your personal information, which is '
|
||||
"necessary to provide you with your Google Wallet Pass, to Google Inc.\n"
|
||||
"\n"
|
||||
"Please be aware, that there is no way to delete the data, once it has been transmitted.\n"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_global_settings(cls, **kwargs):
|
||||
return OrderedDict(
|
||||
[
|
||||
(
|
||||
"wallet_google_issuer_id",
|
||||
forms.CharField(
|
||||
label=_("Google Wallet Issuer/Merchant ID"),
|
||||
help_text=_(
|
||||
# TODO: update text
|
||||
"After getting accepted by Google into the Google Pay API for Passes program, "
|
||||
"your Issuer ID can be found in the Merchant center at "
|
||||
"https://wallet.google.com/merchant/walletobjects/"
|
||||
),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_google_credentials",
|
||||
forms.FileField(
|
||||
label=_("Google Wallet Service Account Credentials"),
|
||||
help_text=_(
|
||||
"Please paste the contents of the JSON credentials file "
|
||||
"of the service account you tied to your Google Pay API "
|
||||
"for Passes Issuer ID"
|
||||
),
|
||||
required=False,
|
||||
# TODO: add validator
|
||||
# validators=[validate_json_credentials]
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_google_instance_uuid",
|
||||
forms.CharField(
|
||||
label=_("Google Wallet Pass Instance UUID"),
|
||||
help_text=_(
|
||||
"Instance-specific part to be added to the wallet ids"
|
||||
),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class AppleWalletTicketOutput(WalletOutput):
|
||||
identifier = "wallet_apple"
|
||||
verbose_name = _("Apple")
|
||||
download_button_text = "Add to Apple Wallet"
|
||||
platform = ApplePlatform
|
||||
|
||||
@classmethod
|
||||
def get_global_settings(cls, **kwargs):
|
||||
return OrderedDict(
|
||||
[
|
||||
(
|
||||
"wallet_apple_team_id",
|
||||
forms.CharField(
|
||||
label=_("Apple Wallet Pass team ID"),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_apple_pass_type_id",
|
||||
forms.CharField(
|
||||
label=_("Apple Wallet Pass type"),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_apple_certificate",
|
||||
CertificateFileField(
|
||||
label=_("Apple Wallet Pass certificate file"),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_apple_ca_certificate",
|
||||
CertificateFileField(
|
||||
label=_("Apple Wallet Pass CA Certificate"),
|
||||
help_text=_(
|
||||
"You can download the current CA certificate from apple at "
|
||||
"https://www.apple.com/certificateauthority/AppleWWDRCAG4.cer"
|
||||
),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_apple_key",
|
||||
forms.FileField(
|
||||
label=_("Apple Wallet Pass secret key"),
|
||||
required=False,
|
||||
validators=[validate_rsa_privkey],
|
||||
widget=ClearableBasenameFileInput,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_apple_key_password",
|
||||
forms.CharField(
|
||||
label=_("Apple Wallet Pass key password"),
|
||||
widget=forms.PasswordInput(render_value=True),
|
||||
required=False,
|
||||
help_text=_(
|
||||
"Optional, only necessary if the key entered above requires a password to use."
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput]
|
||||
@@ -0,0 +1,65 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django.urls import re_path
|
||||
|
||||
from pretix.api.urls import event_router
|
||||
|
||||
from .api import WalletLayoutViewSet
|
||||
from .views import (
|
||||
LayoutCreateView, LayoutDelete, LayoutEditorView, LayoutListView,
|
||||
LayoutPreviewView, LayoutSetDefault,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
re_path(
|
||||
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/$",
|
||||
LayoutListView.as_view(),
|
||||
name="index",
|
||||
),
|
||||
re_path(
|
||||
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/add/$",
|
||||
LayoutCreateView.as_view(),
|
||||
name="add",
|
||||
),
|
||||
re_path(
|
||||
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<layout>[^/]+)/$",
|
||||
LayoutEditorView.as_view(),
|
||||
name="edit",
|
||||
),
|
||||
re_path(
|
||||
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/preview/$",
|
||||
LayoutPreviewView.as_view(),
|
||||
name="preview",
|
||||
),
|
||||
re_path(
|
||||
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/default/(?P<layout>[^/]+)/$",
|
||||
LayoutSetDefault.as_view(),
|
||||
name="default",
|
||||
),
|
||||
re_path(
|
||||
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/delete/(?P<layout>[^/]+)/$",
|
||||
LayoutDelete.as_view(),
|
||||
name="delete",
|
||||
),
|
||||
]
|
||||
|
||||
event_router.register("walletlayouts", WalletLayoutViewSet)
|
||||
@@ -0,0 +1,279 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import BadRequest
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import (
|
||||
CreateView, DeleteView, DetailView, ListView, View,
|
||||
)
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from pretix_vrpayment_wero.payment import HttpRequest
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.middleware import add_to_response_csp
|
||||
from pretix.base.services.tickets import get_preview_position
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
from pretix.helpers.models import modelclone
|
||||
|
||||
from .models import WalletLayout
|
||||
from .placeholders import (
|
||||
get_wallet_placeholder_renderer, get_wallet_placeholders,
|
||||
)
|
||||
from .styles import (
|
||||
AVAILABLE_PLATFORMS, AVAILABLE_STYLES, AVAILABLE_STYLES_DICT,
|
||||
)
|
||||
|
||||
|
||||
def localize_lazyi18n_string(string: LazyI18nString, locales):
|
||||
data = {}
|
||||
for locale in locales:
|
||||
data[locale] = string.localize(locale)
|
||||
return data
|
||||
|
||||
|
||||
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": (
|
||||
localize_lazyi18n_string(p.label, event.settings.locales)
|
||||
if isinstance(p.label, LazyI18nString)
|
||||
else str(p.label)
|
||||
),
|
||||
"sample": str(context.render_sample(p)),
|
||||
"required_context": list(sorted(p.required_context)),
|
||||
}
|
||||
for pid, p in ps.items()
|
||||
}
|
||||
for t, ps in get_wallet_placeholders(event).items()
|
||||
}
|
||||
return placeholders
|
||||
|
||||
|
||||
class WalletLayoutMixin:
|
||||
model = WalletLayout
|
||||
permission = "event.settings.general:write"
|
||||
pk_url_kwarg = "layout"
|
||||
context_object_name = "layouts"
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.wallet_layouts.all()
|
||||
|
||||
|
||||
class LayoutListView(WalletLayoutMixin, EventPermissionRequiredMixin, ListView):
|
||||
template_name = "pretixplugins/wallet/layout_list.html"
|
||||
|
||||
|
||||
class LayoutDetailView(WalletLayoutMixin, EventPermissionRequiredMixin, DetailView):
|
||||
pass
|
||||
|
||||
|
||||
class LayoutEditorView(LayoutDetailView):
|
||||
template_name = "pretixplugins/wallet/edit.html"
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["platforms"] = [
|
||||
{
|
||||
"identifier": platform.identifier,
|
||||
"name": platform.name,
|
||||
"styles": {
|
||||
style.identifier: style(self.request.event).asdict()
|
||||
for style in AVAILABLE_STYLES.get(platform.identifier)
|
||||
},
|
||||
}
|
||||
for platform in AVAILABLE_PLATFORMS
|
||||
]
|
||||
context["variables"] = get_editor_placeholders(self.request.event)
|
||||
context["locales"] = {
|
||||
l: dict(settings.LANGUAGES).get(l, l)
|
||||
for l in self.request.event.settings.get("locales")
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
add_to_response_csp(
|
||||
response,
|
||||
{
|
||||
"img-src": ["blob:"],
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class WalletLayoutCreateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = WalletLayout
|
||||
fields = ("name",)
|
||||
|
||||
def __init__(self, *args, event, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.event = event
|
||||
|
||||
def save(self, *args, **kwargs) -> Any:
|
||||
self.instance.event = self.event
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class LayoutCreateView(WalletLayoutMixin, EventPermissionRequiredMixin, CreateView):
|
||||
template_name = "pretixplugins/wallet/create.html"
|
||||
form_class = WalletLayoutCreateForm
|
||||
permission = "event.settings.general:write"
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save()
|
||||
if self.copy_from:
|
||||
for pl in self.copy_from.platform_layouts.all():
|
||||
modelclone(pl, parent=self.object).save()
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
def get_form_kwargs(self) -> dict[str, Any]:
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["event"] = self.request.event
|
||||
|
||||
if self.copy_from:
|
||||
kwargs["instance"] = modelclone(self.copy_from, default=False)
|
||||
kwargs.setdefault("initial", {})
|
||||
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(
|
||||
"plugins:wallet:edit",
|
||||
kwargs={
|
||||
"organizer": self.request.event.organizer.slug,
|
||||
"event": self.request.event.slug,
|
||||
"layout": self.object.pk,
|
||||
},
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def copy_from(self) -> WalletLayout | None:
|
||||
if self.request.GET.get("copy_from"):
|
||||
try:
|
||||
return self.get_queryset().get(pk=self.request.GET.get("copy_from"))
|
||||
except WalletLayout.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class LayoutPreviewView(EventPermissionRequiredMixin, View):
|
||||
permission = "event.settings.general:write"
|
||||
|
||||
def post(self, request, **kwargs):
|
||||
event = request.event
|
||||
platform_id = request.POST.get("platform")
|
||||
style_id = request.POST.get("style")
|
||||
layout = request.POST.get("layout")
|
||||
file_settings = json.loads(request.POST.get("file_settings"))
|
||||
base_layout = (
|
||||
event.wallet_layouts.get(pk=request.POST.get("id"))
|
||||
.platform_layouts.filter(platform=platform_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if base_layout:
|
||||
base_file_settings = {
|
||||
fs.key: fs.file for fs in base_layout.file_settings.all()
|
||||
}
|
||||
else:
|
||||
base_file_settings = {}
|
||||
|
||||
platform = None
|
||||
for p in AVAILABLE_PLATFORMS:
|
||||
if p.identifier == platform_id:
|
||||
platform = p
|
||||
if not platform:
|
||||
raise BadRequest("Unknown platform")
|
||||
if style_id not in AVAILABLE_STYLES_DICT[platform_id]:
|
||||
raise BadRequest("Unknown style")
|
||||
style = AVAILABLE_STYLES_DICT[platform_id][style_id]
|
||||
|
||||
layout = json.loads(layout)
|
||||
with (
|
||||
rolledback_transaction(),
|
||||
language(request.event.settings.locale, request.event.settings.region),
|
||||
):
|
||||
p = get_preview_position(request.event)
|
||||
l = style(event=event, layout=layout)
|
||||
file_settings = {
|
||||
**base_file_settings,
|
||||
**l.extract_file_settings(request, file_settings),
|
||||
}
|
||||
layout = style(event, layout, file_settings)
|
||||
layout.validate()
|
||||
|
||||
fname, mimet, data = layout.generate(p)
|
||||
resp = HttpResponse(data, content_type=mimet)
|
||||
ftype = fname.split(".")[-1]
|
||||
if not mimet.startswith("text/"):
|
||||
resp["Content-Disposition"] = (
|
||||
'attachment; filename="ticket-preview.{}"'.format(ftype)
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
class LayoutSetDefault(LayoutDetailView):
|
||||
@transaction.atomic
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
request.event.wallet_layouts.exclude(pk=obj.pk).update(default=False)
|
||||
obj.default = True
|
||||
obj.save(update_fields=["default"])
|
||||
messages.success(self.request, _("Your changes have been saved."))
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(
|
||||
"plugins:wallet:index",
|
||||
kwargs={
|
||||
"organizer": self.request.event.organizer.slug,
|
||||
"event": self.request.event.slug,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class LayoutDelete(WalletLayoutMixin, DeleteView):
|
||||
template_name = "pretixplugins/wallet/delete.html"
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(
|
||||
"plugins:wallet:index",
|
||||
kwargs={
|
||||
"organizer": self.request.event.organizer.slug,
|
||||
"event": self.request.event.slug,
|
||||
},
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
self.object = self.get_object()
|
||||
self.object.log_action(
|
||||
action="pretix.plugins.wallet.layout.deleted", user=self.request.user
|
||||
)
|
||||
self.object.delete()
|
||||
|
||||
if not self.request.event.wallet_layouts.filter(default=True).exists():
|
||||
f = self.request.event.wallet_layouts.first()
|
||||
if f:
|
||||
f.default = True
|
||||
f.save(update_fields=["default"])
|
||||
|
||||
messages.success(self.request, _("The selected layout been deleted."))
|
||||
return redirect(self.get_success_url())
|
||||
@@ -222,10 +222,11 @@
|
||||
{% 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 %}">
|
||||
method="post" data-asynctask data-asynctask-download class="download-btn-form{% if b.javascript_required or b.confirmation_text %} requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-sm {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
|
||||
class="btn btn-sm {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}"
|
||||
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
|
||||
<span class="fa {{ b.icon }}" aria-hidden="true"></span> {{ b.text }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load rich_text %}
|
||||
{% if download_email_required %}
|
||||
<div class="panel panel-primary">
|
||||
<div class="panel-heading">
|
||||
@@ -43,10 +44,11 @@
|
||||
{% 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 or b.confirmation_text %}requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
|
||||
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}"
|
||||
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
|
||||
<span class="fa {{ b.icon }}" aria-hidden="true"></span> {{ b.multi_text }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -62,11 +64,12 @@
|
||||
<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 %}">
|
||||
method="post" data-asynctask data-asynctask-download
|
||||
class="download-btn-form {% if b.javascript_required or b.confirmation_text %}requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
|
||||
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}"
|
||||
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
|
||||
<span class="fa {{ b.icon }}" aria-hidden="true"></span> {{ b.long_text }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -43,6 +43,8 @@ import re
|
||||
from collections import Counter, OrderedDict, defaultdict
|
||||
from decimal import Decimal
|
||||
from urllib.parse import quote, urlencode
|
||||
from datetime import timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
@@ -62,12 +64,14 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.views.decorators.clickjacking import xframe_options_exempt
|
||||
from django.views.generic import ListView, TemplateView, View
|
||||
from pretixeu.views import reverse
|
||||
|
||||
from pretix.base.auth import has_event_access_permission
|
||||
from pretix.base.models import (
|
||||
CachedTicket, Checkin, GiftCard, Invoice, Order, OrderPosition, Quota,
|
||||
TaxRule,
|
||||
)
|
||||
from pretix.base.models.base import CachedFile
|
||||
from pretix.base.models.orders import (
|
||||
CachedCombinedTicket, InvoiceAddress, OrderFee, OrderPayment, OrderRefund,
|
||||
QuestionAnswer,
|
||||
@@ -183,7 +187,6 @@ class OrderPositionDetailMixin(NoSearchIndexViewMixin):
|
||||
def position(self):
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.request.event,
|
||||
addon_to__isnull=True,
|
||||
order__code=self.kwargs['order'],
|
||||
positionid=self.kwargs['position']
|
||||
).select_related('order', 'order__event')
|
||||
@@ -284,7 +287,8 @@ class TicketPageMixin:
|
||||
'icon': provider.download_button_icon or 'fa-download',
|
||||
'identifier': provider.identifier,
|
||||
'multi': provider.multi_download_enabled,
|
||||
'javascript_required': provider.javascript_required
|
||||
'javascript_required': provider.javascript_required,
|
||||
'confirmation_text': provider.confirmation_text,
|
||||
})
|
||||
return buttons
|
||||
|
||||
@@ -1183,9 +1187,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)
|
||||
@@ -1219,12 +1220,33 @@ 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 self.output.is_cacheable and (ct := self.get_last_ct()):
|
||||
return self.success(ct)
|
||||
|
||||
if not self.output.is_cacheable:
|
||||
cf = CachedFile(web_download=True, date=now(), expires=now() + timedelta(hours=2))
|
||||
cf.bind_to_session(request)
|
||||
cf.save()
|
||||
else:
|
||||
cf = None
|
||||
|
||||
return self.do('orderposition' if 'position' in kwargs else 'order',
|
||||
self.order_position.pk if 'position' in kwargs else self.order.pk,
|
||||
self.output.identifier)
|
||||
self.output.identifier, cf.pk if cf else None)
|
||||
|
||||
def get_success_url(self, value):
|
||||
if isinstance(value, UUID):
|
||||
cf = CachedFile.objects.filter(pk=value).first()
|
||||
else:
|
||||
cf = None
|
||||
|
||||
if not cf or not cf.allowed_for_session(self.request):
|
||||
return self.get_self_url()
|
||||
|
||||
if cf.type == 'text/uri-list':
|
||||
return cf.file.file.read().decode()
|
||||
else:
|
||||
return reverse('cachedfile.download', kwargs={"id":str(value)})
|
||||
|
||||
def get_success_message(self, value):
|
||||
return ""
|
||||
@@ -1263,17 +1285,20 @@ 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:
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
|
||||
).last()
|
||||
if self.output.is_cacheable:
|
||||
if 'position' in self.kwargs:
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
|
||||
).last()
|
||||
else:
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=self.order, provider=self.output.identifier, file__isnull=False
|
||||
).last()
|
||||
else:
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=self.order, provider=self.output.identifier, file__isnull=False
|
||||
).last()
|
||||
ct = None
|
||||
if not ct or not ct.file:
|
||||
return None
|
||||
return ct
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -22,8 +22,8 @@ function pgettext (context, msgid) {
|
||||
|
||||
function interpolate (fmt, object, named) {
|
||||
if (named) {
|
||||
return fmt.replace(/%\(\w+\)s/g, function (match) { return String(obj[match.slice(2, -2)]) })
|
||||
return fmt.replace(/%\(\w+\)s/g, function (match) { return String(object[match.slice(2, -2)]) })
|
||||
} else {
|
||||
return fmt.replace(/%s/g, function (match) { return String(obj.shift()) })
|
||||
return fmt.replace(/%s/g, function (match) { return String(object.shift()) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,16 @@ input[type=number]::-webkit-outer-spin-button {
|
||||
border: transparent;
|
||||
}
|
||||
|
||||
.btn.disabled, .btn[disabled], fieldset[disabled] .btn {
|
||||
box-shadow: 0px 0px 0px 1px #ccc inset, inset 0 1px 3px 0 #d9d9d9;
|
||||
background: #eee;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.btn-link.disabled, .btn-link[disabled], fieldset[disabled] .btn-link {
|
||||
box-shadow: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.btn-group-flex { display: flex; }
|
||||
.btn-group-flex > .btn { flex: 1; }
|
||||
|
||||
|
||||
@@ -813,7 +813,13 @@ function setup_basics (el) {
|
||||
return
|
||||
}
|
||||
|
||||
$('<li><a href="#' + scrollTarget.id + '">' + $.trim(label) + '</a> – ' + description + '</li>')
|
||||
$('<li></li>')
|
||||
.append(
|
||||
$('<a></a>').attr("href", "#" + scrollTarget.id).text($.trim(label))
|
||||
)
|
||||
.append(
|
||||
document.createTextNode(' - ' + description)
|
||||
)
|
||||
.appendTo(alert.querySelector('ul') || $('<ul>').appendTo(alert))
|
||||
.find('a').on('click', function (e) {
|
||||
$panel.collapse('show')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,12 @@
|
||||
.profile-scope:last-child .profile-save-container {
|
||||
margin-bottom: -15px;
|
||||
}
|
||||
|
||||
/* the rule above removes blank space after any profile-save-container at the end of every fieldset, which causes an overlay if addon-questions are used. So re-add margin for non-addon-fieldsets followed by fieldsets (currently only happens it addon-questions are available) */
|
||||
fieldset:not(:has(.profile-add-on)):has( + fieldset) .profile-scope:last-child .profile-save-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.profile-save-container .help-block {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -199,6 +199,84 @@ def test_sendmail_rule_send_order_vs_pos(send_to, amount_mails, recipients, orde
|
||||
assert djmail.outbox[0].subject == 'Dummy: Foo City @ 11:30'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('send_to,amount_mails,recipients,ticket_mail,addon_mail, products', [
|
||||
(Rule.ATTENDEES, 1, ['addon-attendee@dummy.test'], 'attendee@dummy.test', 'addon-attendee@dummy.test', 'addon'),
|
||||
(Rule.ATTENDEES, 2, ['attendee@dummy.test', 'addon-attendee@dummy.test'], 'attendee@dummy.test',
|
||||
'addon-attendee@dummy.test', 'both'),
|
||||
(Rule.ATTENDEES, 1, ['attendee@dummy.test'], 'attendee@dummy.test', 'attendee@dummy.test', 'both'),
|
||||
(Rule.ATTENDEES, 1, ['attendee@dummy.test'], 'attendee@dummy.test', None, 'addon'),
|
||||
(Rule.ATTENDEES, 1, ['attendee@dummy.test'], 'attendee@dummy.test', None, 'both'),
|
||||
(Rule.ATTENDEES, 1, ['dummy@dummy.test'], None, None, 'addon'),
|
||||
(Rule.ATTENDEES, 1, ['dummy@dummy.test'], None, None, 'both'),
|
||||
(Rule.ATTENDEES, 2, ['dummy@dummy.test', 'addon-attendee@dummy.test'], None, 'addon-attendee@dummy.test', 'both'),
|
||||
(Rule.ATTENDEES, 1, ['dummy@dummy.test'], None, None, 'all'),
|
||||
(Rule.ATTENDEES, 2, ['dummy@dummy.test', 'addon-attendee@dummy.test'], None, 'addon-attendee@dummy.test', 'all'),
|
||||
])
|
||||
@scopes_disabled()
|
||||
def test_sendmail_rule_send_addons(send_to, amount_mails, recipients, ticket_mail, addon_mail, products, order,
|
||||
event, pos, item, item2):
|
||||
djmail.outbox = []
|
||||
|
||||
order.status = order.STATUS_PAID
|
||||
order.save()
|
||||
|
||||
p = pos
|
||||
p.attendee_email = ticket_mail
|
||||
p.save()
|
||||
order.all_positions.create(item=item2, price=0, attendee_email=addon_mail, addon_to=p)
|
||||
rule = order.event.sendmail_rules.create(date_is_absolute=True, send_date=dt_now - datetime.timedelta(hours=1),
|
||||
send_to=send_to, subject='meow', template='meow meow meow',
|
||||
all_products=products == 'all')
|
||||
if products == 'addon':
|
||||
rule.limit_products.set([item2])
|
||||
if products == 'both':
|
||||
rule.limit_products.set([item, item2])
|
||||
|
||||
sendmail_run_rules(None)
|
||||
|
||||
assert len(djmail.outbox) == amount_mails
|
||||
|
||||
_recipients = [mail.to[0] for mail in djmail.outbox]
|
||||
assert set(recipients) == set(_recipients)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('send_to,amount_mails,recipients,ticket_mail,addon_mail, products', [
|
||||
(Rule.ATTENDEES, 2, ['attendee@dummy.test', 'addon-attendee@dummy.test'], 'attendee@dummy.test',
|
||||
'addon-attendee@dummy.test', 'addon'),
|
||||
(Rule.ATTENDEES, 2, ['attendee@dummy.test', 'addon-attendee@dummy.test'], 'attendee@dummy.test',
|
||||
'addon-attendee@dummy.test', 'both'),
|
||||
])
|
||||
@scopes_disabled()
|
||||
def test_sendmail_rule_send_addons_one_unp(send_to, amount_mails, recipients, ticket_mail, addon_mail, products, order,
|
||||
event, pos, item, item2):
|
||||
djmail.outbox = []
|
||||
|
||||
order.status = order.STATUS_PAID
|
||||
order.save()
|
||||
|
||||
p = pos
|
||||
p.attendee_email = ticket_mail
|
||||
p.save()
|
||||
order.all_positions.create(item=item2, price=0, attendee_email=addon_mail, addon_to=p)
|
||||
order.all_positions.create(item=item2, price=0, addon_to=p)
|
||||
rule = order.event.sendmail_rules.create(date_is_absolute=True, send_date=dt_now - datetime.timedelta(hours=1),
|
||||
send_to=send_to, subject='meow', template='meow meow meow',
|
||||
all_products=False)
|
||||
if products == 'addon':
|
||||
rule.limit_products.set([item2])
|
||||
if products == 'both':
|
||||
rule.limit_products.set([item, item2])
|
||||
|
||||
sendmail_run_rules(None)
|
||||
|
||||
assert len(djmail.outbox) == amount_mails
|
||||
|
||||
_recipients = [mail.to[0] for mail in djmail.outbox]
|
||||
assert set(recipients) == set(_recipients)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@scopes_disabled()
|
||||
def test_sendmail_rule_send_attendees_unset_mail(order, event, item):
|
||||
|
||||
@@ -406,6 +406,312 @@ def test_sendmail_attendee_product_filter(logged_in_client, sendmail_url, event,
|
||||
assert '/order/' not in djmail.outbox[0].body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_attendee_addon_filter(logged_in_client, sendmail_url, event, order, pos):
|
||||
event.settings.attendee_emails_asked = True
|
||||
with scopes_disabled():
|
||||
addon = Item.objects.create(name='Test addon', event=event, default_price=12)
|
||||
p = pos
|
||||
p.attendee_email = 'attendee1@dummy.test'
|
||||
p.save()
|
||||
order.positions.create(
|
||||
item=addon, price=0, attendee_email='add-on-attendee@dummy.test', addon_to=p
|
||||
)
|
||||
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url + 'orders/',
|
||||
{'sendto': 'na',
|
||||
'action': 'send',
|
||||
'recipients': 'attendees',
|
||||
'items': addon.pk,
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.',
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == ['add-on-attendee@dummy.test']
|
||||
assert '/ticket/' in djmail.outbox[0].body
|
||||
assert '/order/' not in djmail.outbox[0].body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_attendee_ticket_and_addon_filter(logged_in_client, sendmail_url, event, order, pos):
|
||||
event.settings.attendee_emails_asked = True
|
||||
with scopes_disabled():
|
||||
addon = Item.objects.create(name='Test addon', event=event, default_price=12)
|
||||
p = pos
|
||||
p.attendee_email = 'attendee1@dummy.test'
|
||||
p.save()
|
||||
order.positions.create(
|
||||
item=addon, price=0, attendee_email='add-on-attendee@dummy.test', addon_to=p
|
||||
)
|
||||
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url + 'orders/',
|
||||
{'sendto': 'na',
|
||||
'action': 'send',
|
||||
'recipients': 'attendees',
|
||||
'items': {addon.pk, p.item_id},
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.',
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
assert len(djmail.outbox) == 2
|
||||
for msg in djmail.outbox:
|
||||
assert msg.to in [['attendee1@dummy.test'], ['add-on-attendee@dummy.test']]
|
||||
assert '/ticket/' in msg.body
|
||||
assert '/order/' not in msg.body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_attendee_ticket_and_same_addon_filter(logged_in_client, sendmail_url, event, order, pos):
|
||||
event.settings.attendee_emails_asked = True
|
||||
with scopes_disabled():
|
||||
addon = Item.objects.create(name='Test addon', event=event, default_price=12)
|
||||
p = pos
|
||||
p.attendee_email = 'attendee1@dummy.test'
|
||||
p.save()
|
||||
order.positions.create(
|
||||
item=addon, price=0, attendee_email='attendee1@dummy.test', addon_to=p
|
||||
)
|
||||
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url + 'orders/',
|
||||
{'sendto': 'na',
|
||||
'action': 'send',
|
||||
'recipients': 'attendees',
|
||||
'items': {addon.pk, p.item_id},
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.',
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == ['attendee1@dummy.test']
|
||||
assert '/ticket/' in djmail.outbox[0].body
|
||||
assert '/order/' not in djmail.outbox[0].body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_attendee_addon_unpersonalized_filter(logged_in_client, sendmail_url, event, order, pos):
|
||||
event.settings.attendee_emails_asked = True
|
||||
with scopes_disabled():
|
||||
addon = Item.objects.create(name='Test addon', event=event, default_price=12)
|
||||
p = pos
|
||||
p.attendee_email = 'attendee1@dummy.test'
|
||||
p.save()
|
||||
order.positions.create(
|
||||
item=addon, price=0, addon_to=p
|
||||
)
|
||||
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url + 'orders/',
|
||||
{'sendto': 'na',
|
||||
'action': 'send',
|
||||
'recipients': 'attendees',
|
||||
'items': addon.pk,
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.',
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == ['attendee1@dummy.test']
|
||||
assert '/ticket/' in djmail.outbox[0].body
|
||||
assert '/order/' not in djmail.outbox[0].body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_attendee_ticket_and_addon_unp_filter(logged_in_client, sendmail_url, event, order, pos):
|
||||
event.settings.attendee_emails_asked = True
|
||||
with scopes_disabled():
|
||||
addon = Item.objects.create(name='Test addon', event=event, default_price=12)
|
||||
p = pos
|
||||
p.attendee_email = 'attendee1@dummy.test'
|
||||
p.save()
|
||||
order.positions.create(
|
||||
item=addon, price=0, addon_to=p
|
||||
)
|
||||
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url + 'orders/',
|
||||
{'sendto': 'na',
|
||||
'action': 'send',
|
||||
'recipients': 'attendees',
|
||||
'items': {addon.pk, p.item_id},
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.',
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == ['attendee1@dummy.test']
|
||||
assert '/ticket/' in djmail.outbox[0].body
|
||||
assert '/order/' not in djmail.outbox[0].body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_attendee_ticket_unp_and_addon_filter(logged_in_client, sendmail_url, event, order, pos):
|
||||
event.settings.attendee_emails_asked = True
|
||||
with scopes_disabled():
|
||||
addon = Item.objects.create(name='Test addon', event=event, default_price=12)
|
||||
order.positions.create(
|
||||
item=addon, price=0, attendee_email='add-on-attendee@dummy.test', addon_to=pos
|
||||
)
|
||||
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url + 'orders/',
|
||||
{'sendto': 'na',
|
||||
'action': 'send',
|
||||
'recipients': 'attendees',
|
||||
'items': {addon.pk, pos.item_id},
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.',
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
assert len(djmail.outbox) == 2
|
||||
for msg in djmail.outbox:
|
||||
assert msg.to in [[order.email], ['add-on-attendee@dummy.test']]
|
||||
if msg.to == [order.email]:
|
||||
assert '/ticket/' not in msg.body
|
||||
assert '/order/' in msg.body
|
||||
else:
|
||||
assert msg.to == ['add-on-attendee@dummy.test']
|
||||
assert '/ticket/' in msg.body
|
||||
assert '/order/' not in msg.body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_attendee_addon_unp_unp_filter(logged_in_client, sendmail_url, event, order, pos):
|
||||
event.settings.attendee_emails_asked = True
|
||||
with scopes_disabled():
|
||||
addon = Item.objects.create(name='Test addon', event=event, default_price=12)
|
||||
order.positions.create(
|
||||
item=addon, price=0, addon_to=pos
|
||||
)
|
||||
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url + 'orders/',
|
||||
{'sendto': 'na',
|
||||
'action': 'send',
|
||||
'recipients': 'attendees',
|
||||
'items': addon.pk,
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.',
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == [order.email]
|
||||
assert '/ticket/' not in djmail.outbox[0].body
|
||||
assert '/order/' in djmail.outbox[0].body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_attendee_and_addon_unp_unp_filter(logged_in_client, sendmail_url, event, order, pos):
|
||||
event.settings.attendee_emails_asked = True
|
||||
with scopes_disabled():
|
||||
addon = Item.objects.create(name='Test addon', event=event, default_price=12)
|
||||
order.positions.create(
|
||||
item=addon, price=0, addon_to=pos
|
||||
)
|
||||
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url + 'orders/',
|
||||
{'sendto': 'na',
|
||||
'action': 'send',
|
||||
'recipients': 'attendees',
|
||||
'items': {addon.pk, pos.item_id},
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.',
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == [order.email]
|
||||
assert '/ticket/' not in djmail.outbox[0].body
|
||||
assert '/order/' in djmail.outbox[0].body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_attendee_two_addons_one_unp_filter(logged_in_client, sendmail_url, event, order, pos):
|
||||
event.settings.attendee_emails_asked = True
|
||||
with scopes_disabled():
|
||||
p = pos
|
||||
p.attendee_email = 'attendee1@dummy.test'
|
||||
p.save()
|
||||
addon = Item.objects.create(name='Test addon', event=event, default_price=12)
|
||||
order.positions.create(
|
||||
item=addon, price=0, attendee_email='add-on-attendee@dummy.test', addon_to=p
|
||||
)
|
||||
order.positions.create(
|
||||
item=addon, price=0, addon_to=p
|
||||
)
|
||||
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url + 'orders/',
|
||||
{'sendto': 'na',
|
||||
'action': 'send',
|
||||
'recipients': 'attendees',
|
||||
'items': addon.pk,
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.',
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
assert len(djmail.outbox) == 2
|
||||
for msg in djmail.outbox:
|
||||
assert msg.to in [['attendee1@dummy.test'], ['add-on-attendee@dummy.test']]
|
||||
assert '/ticket/' in msg.body
|
||||
assert '/order/' not in msg.body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_attendee_and_two_addons_one_unp_filter(logged_in_client, sendmail_url, event, order, pos):
|
||||
event.settings.attendee_emails_asked = True
|
||||
with scopes_disabled():
|
||||
p = pos
|
||||
p.attendee_email = 'attendee1@dummy.test'
|
||||
p.save()
|
||||
addon = Item.objects.create(name='Test addon', event=event, default_price=12)
|
||||
order.positions.create(
|
||||
item=addon, price=0, attendee_email='add-on-attendee@dummy.test', addon_to=p
|
||||
)
|
||||
order.positions.create(
|
||||
item=addon, price=0, addon_to=p
|
||||
)
|
||||
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url + 'orders/',
|
||||
{'sendto': 'na',
|
||||
'action': 'send',
|
||||
'recipients': 'attendees',
|
||||
'items': {addon.pk, p.item_id},
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.',
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
assert len(djmail.outbox) == 2
|
||||
for msg in djmail.outbox:
|
||||
assert msg.to in [['attendee1@dummy.test'], ['add-on-attendee@dummy.test']]
|
||||
assert '/ticket/' in msg.body
|
||||
assert '/order/' not in msg.body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_attendee_subevent_filter(logged_in_client, sendmail_url, event, item, order, pos):
|
||||
event.settings.attendee_emails_asked = True
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
from pretix.plugins.wallet.styles.apple import SignedZipFile, StringResource, AppleWalletEventTicket
|
||||
from django.utils.translation import gettext as _
|
||||
import pytest
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
from cryptography import x509
|
||||
import datetime
|
||||
import io
|
||||
import zipfile
|
||||
import json
|
||||
import jsonschema
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pkpass_context():
|
||||
key_pw = b"TESTPW"
|
||||
now = datetime.datetime.now()
|
||||
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||
ca_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(
|
||||
x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "TTDR")])
|
||||
)
|
||||
.issuer_name(
|
||||
x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "ROOT Inc.")])
|
||||
)
|
||||
.public_key(ca_key.public_key())
|
||||
.serial_number(1)
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + datetime.timedelta(days=365))
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(
|
||||
x509.Name(
|
||||
[x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "UID=pass.test.test")]
|
||||
)
|
||||
)
|
||||
.issuer_name(
|
||||
x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "TTDR")])
|
||||
)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(2)
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + datetime.timedelta(days=365))
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
ca_cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
|
||||
cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
|
||||
key_pem = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(key_pw),
|
||||
)
|
||||
return {
|
||||
"ca_certificate": ca_cert_pem,
|
||||
"certificate": cert_pem,
|
||||
"key": key_pem,
|
||||
"password": key_pw,
|
||||
}
|
||||
|
||||
|
||||
def test_signed_zip(pkpass_context):
|
||||
pkpass = SignedZipFile(**pkpass_context)
|
||||
generated_pass = pkpass.finish()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(generated_pass), "r") as zip_file:
|
||||
assert set(zip_file.namelist()) == {"manifest.json", "signature"}
|
||||
with zip_file.open("manifest.json") as f:
|
||||
manifest = json.load(f)
|
||||
assert manifest == {}
|
||||
|
||||
with zip_file.open("signature") as f:
|
||||
signature = f.read()
|
||||
|
||||
assert signature
|
||||
|
||||
pkpass = SignedZipFile(**pkpass_context)
|
||||
pkpass.add_file("test", b"test content")
|
||||
generated_pass = pkpass.finish()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(generated_pass), "r") as zip_file:
|
||||
assert set(zip_file.namelist()) == {"test", "manifest.json", "signature"}
|
||||
with zip_file.open("manifest.json") as f:
|
||||
manifest = json.load(f)
|
||||
assert manifest == {"test": "1eebdf4fdc9fc7bf283031b93f9aef3338de9052"}
|
||||
|
||||
with zip_file.open("signature") as f:
|
||||
signature = f.read()
|
||||
|
||||
assert signature
|
||||
|
||||
pkpass = SignedZipFile(**pkpass_context)
|
||||
pkpass.add_file("test/test", "test content")
|
||||
generated_pass = pkpass.finish()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(generated_pass), "r") as zip_file:
|
||||
assert set(zip_file.namelist()) == {"test/test", "manifest.json", "signature"}
|
||||
with zip_file.open("manifest.json") as f:
|
||||
manifest = json.load(f)
|
||||
assert manifest == {"test/test": "1eebdf4fdc9fc7bf283031b93f9aef3338de9052"}
|
||||
|
||||
with zip_file.open("signature") as f:
|
||||
signature = f.read()
|
||||
|
||||
assert signature
|
||||
|
||||
|
||||
def test_stringresource_minimal():
|
||||
resource = StringResource(locales=["de", "en"])
|
||||
resource.add_entry("TEST", LazyI18nString({"de": "test-de", "en": "test-en"}))
|
||||
stringfiles = resource.generate()
|
||||
|
||||
assert stringfiles.keys() == {"de", "en"}
|
||||
assert stringfiles["de"] == '"TEST" = "test-de";'
|
||||
assert stringfiles["en"] == '"TEST" = "test-en";'
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input,output",
|
||||
[
|
||||
['te"st', 'te\\"st'],
|
||||
["te\rst", "te\\rst"],
|
||||
["te\nst", "te\\nst"],
|
||||
["te\r\nst", "te\\r\\nst"],
|
||||
["te\r\nst", "te\\r\\nst"],
|
||||
["te\\st", "te\\\\st"],
|
||||
],
|
||||
)
|
||||
def test_stringresource_escaping(input, output):
|
||||
resource = StringResource(locales=["en"])
|
||||
resource.add_entry("TEST", LazyI18nString({"en": input}))
|
||||
stringfiles = resource.generate()
|
||||
|
||||
assert stringfiles.keys() == {"en"}
|
||||
assert stringfiles["en"] == f'"TEST" = "{output}";'
|
||||
|
||||
resource = StringResource(locales=["en"])
|
||||
resource.add_entry(input, LazyI18nString({"en": "test"}))
|
||||
stringfiles = resource.generate()
|
||||
|
||||
assert stringfiles.keys() == {"en"}
|
||||
assert stringfiles["en"] == f'"{output}" = "test";'
|
||||
|
||||
|
||||
|
||||
def test_stringresource_additional_locale():
|
||||
resource = StringResource(locales=["de", "en", "fr"])
|
||||
resource.add_entry("TEST", LazyI18nString({"de": "test-de", "en": "test-en"}))
|
||||
stringfiles = resource.generate()
|
||||
|
||||
assert stringfiles.keys() == {"de", "en", "fr"}
|
||||
assert stringfiles["de"] == '"TEST" = "test-de";'
|
||||
assert stringfiles["en"] == '"TEST" = "test-en";'
|
||||
assert stringfiles["fr"] == '"TEST" = "test-en";'
|
||||
|
||||
def test_generate_pass_json():
|
||||
context = {
|
||||
"placeholders": {
|
||||
"text": {"test_placeholder": {"evaluate": lambda: "test placeholder"}}
|
||||
},
|
||||
"description": "Ticket for Test",
|
||||
"organizationName": "TestOrg",
|
||||
"serialNumber": "1",
|
||||
"passTypeIdentifier": "pass.test.test",
|
||||
"teamIdentifier": "ABCDEF123456"
|
||||
}
|
||||
layout = {"fieldgroups": {"primary": {"entries": [{"type": "placeholder", "label": "test", "content": "test_placeholder"}, {"type": "text", "label": {"de":"test-de", "en": "test-en"}, "content": "test content"}]}}}
|
||||
style = AppleWalletEventTicket()
|
||||
schema = style.layout_schema(context)
|
||||
jsonschema.validate(schema, layout)
|
||||
|
||||
result = style.generate_pass_json(layout, context)
|
||||
|
||||
required_fields = ["description", "formatVersion", "organizationName", "passTypeIdentifier", "serialNumber", "teamIdentifier"]
|
||||
for field in required_fields:
|
||||
assert field in result
|
||||
|
||||
assert result['formatVersion'] == 1
|
||||
@@ -0,0 +1,389 @@
|
||||
from pretix.plugins.wallet.styles.base import (
|
||||
PassStyle,
|
||||
PredefinedFieldGroup,
|
||||
WalletPlatform,
|
||||
PlaceholderFieldGroup,
|
||||
FieldContentType,
|
||||
FieldGroupType,
|
||||
FieldEntryType,
|
||||
FieldGroupDisplay
|
||||
)
|
||||
from django.utils.translation import gettext as _
|
||||
import jsonschema
|
||||
import pytest
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
from cryptography import x509
|
||||
import datetime
|
||||
import io
|
||||
import zipfile
|
||||
import json
|
||||
from pretix.plugins.wallet.placeholders import WalletPlaceholderRenderer, FunctionalContextTransformation, PlaceholderContextTransformation, get_available_context, get_transformed_context
|
||||
from pretix.base.models import Organizer, Event, Item, Order, User, InvoiceAddress
|
||||
from django_scopes import scopes_disabled, scope
|
||||
from django.utils.timezone import now
|
||||
from decimal import Decimal
|
||||
|
||||
class WalletTestPlatform(WalletPlatform):
|
||||
identifier = "test_platform"
|
||||
name = _("Test Wallet Platform")
|
||||
|
||||
|
||||
class MinimalTestStyle(PassStyle):
|
||||
platform = WalletTestPlatform
|
||||
identifier = "test_style"
|
||||
name = _("Test Wallet Style")
|
||||
fieldgroups = []
|
||||
|
||||
|
||||
class TicketTestStyle(PassStyle):
|
||||
platform = WalletTestPlatform
|
||||
identifier = "test_ticket"
|
||||
name = _("Test Wallet Style Ticket")
|
||||
fieldgroups = [
|
||||
PlaceholderFieldGroup(
|
||||
identifier="text1",
|
||||
name=_("Text 1"),
|
||||
content_type=FieldContentType.TEXT,
|
||||
required=True,
|
||||
),
|
||||
PlaceholderFieldGroup(
|
||||
identifier="text2",
|
||||
name=_("Text 2"),
|
||||
content_type=FieldContentType.TEXT,
|
||||
required=False,
|
||||
display=FieldGroupDisplay.PLAIN,
|
||||
),
|
||||
PlaceholderFieldGroup(
|
||||
identifier="image1",
|
||||
name=_("Image 1"),
|
||||
content_type=FieldContentType.IMAGE,
|
||||
required=False,
|
||||
display=FieldGroupDisplay.PLAIN,
|
||||
),
|
||||
]
|
||||
|
||||
def generate(self, op):
|
||||
fields = self.get_pass_fields({})
|
||||
return fields
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def layout_context():
|
||||
return {
|
||||
"placeholders": {
|
||||
"text": {"test_placeholder": {"evaluate": lambda: "test placeholder"}}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_schema_generation_minimal():
|
||||
style = MinimalTestStyle
|
||||
context = {}
|
||||
schema = style.layout_schema(context)
|
||||
assert isinstance(schema, dict)
|
||||
assert "properties" in schema
|
||||
assert "fieldgroups" in schema["properties"]
|
||||
|
||||
jsonschema.validate({}, schema)
|
||||
jsonschema.validate({"fieldgroups": {}}, schema)
|
||||
|
||||
|
||||
def test_schema_ticket_generation(layout_context):
|
||||
style = TicketTestStyle
|
||||
schema = style.layout_schema(layout_context)
|
||||
assert isinstance(schema, dict)
|
||||
assert "properties" in schema
|
||||
assert "fieldgroups" in schema["properties"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout",
|
||||
[
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": "test",
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": {"de": "test-de", "en": "test-en"},
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{"type": "text", "label": "test", "content": "test content"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": {"de": "test-de", "en": "test-en"},
|
||||
"content": "test_placeholder",
|
||||
},
|
||||
{"type": "text", "label": "test", "content": "test content"},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": {"de": "test-de", "en": "test-en"},
|
||||
"content": "test_placeholder",
|
||||
},
|
||||
{"type": "text", "label": "test", "content": "test content"},
|
||||
],
|
||||
"overflow": "text2",
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_schema_ticket_valid(layout_context, layout):
|
||||
style = TicketTestStyle
|
||||
schema = style.layout_schema(layout_context)
|
||||
|
||||
jsonschema.validate(layout, schema)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout",
|
||||
[
|
||||
{},
|
||||
{"fieldgroups": {}},
|
||||
{"fieldgroups": {"text1": {}}},
|
||||
{"fieldgroups": {"text1": {"entries": []}}},
|
||||
{"fieldgroups": {"text1": {"overflow": "test"}}},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [{"type": "placeholder", "content": "test_placeholder"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": [],
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {"entries": [{"type": "text", "content": "test content"}]}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": "test",
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
],
|
||||
"overflow": "invalid_group",
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": "test",
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
],
|
||||
"overflow": "image1",
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": "test",
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
],
|
||||
},
|
||||
"text2": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": "test",
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
],
|
||||
"overflow": "text1",
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_schema_ticket_invalid(layout_context, layout):
|
||||
style = TicketTestStyle
|
||||
schema = style.layout_schema(layout_context)
|
||||
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
jsonschema.validate(layout, schema)
|
||||
|
||||
|
||||
def test_style_representation():
|
||||
style = TicketTestStyle
|
||||
style_dict = style.asdict()
|
||||
assert style_dict["platform"] == "test_platform"
|
||||
assert style_dict["identifier"] == "test_ticket"
|
||||
assert style_dict["name"] == _("Test Wallet Style Ticket")
|
||||
|
||||
assert style_dict["fieldgroups"][0]["identifier"] == "text1"
|
||||
assert style_dict["fieldgroups"][0]["name"] == "Text 1"
|
||||
assert style_dict["fieldgroups"][0]["content_type"] == "text"
|
||||
assert style_dict["fieldgroups"][0]["labels"] == True
|
||||
assert style_dict["fieldgroups"][0]["required"] == True
|
||||
|
||||
|
||||
def test_layout_generate(layout_context):
|
||||
style = TicketTestStyle
|
||||
layout = {
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": {"de": "test-de", "en": "test-en"},
|
||||
"content": "test_placeholder",
|
||||
},
|
||||
{"type": "text", "label": "test", "content": "test content"},
|
||||
],
|
||||
"overflow": "text2",
|
||||
}
|
||||
}
|
||||
}
|
||||
# TODO: create event and pass here
|
||||
pass_layout = style(event=None, layout=layout)
|
||||
generated_pass = pass_layout.generate(layout_context)
|
||||
|
||||
assert (
|
||||
generated_pass
|
||||
== "Generated Pass: Test Wallet Style Ticket\n\nGroup: Text 1\ntest-en: test placeholder\ntest: test content\n\n"
|
||||
)
|
||||
|
||||
|
||||
def test_placeholder_transformation_graph():
|
||||
context = {"order_position"}
|
||||
trans1 = FunctionalContextTransformation("order", {"order_position"}, lambda order_position: order_position.order)
|
||||
trans2 = FunctionalContextTransformation("event", {"order"}, lambda order: order.event)
|
||||
trans3 = FunctionalContextTransformation("name", {"invoice_address"}, lambda invoice_address: "name")
|
||||
assert get_available_context(context, [trans1, trans2]) == {"order_position", "order", "event"}
|
||||
assert get_available_context(context, [trans2, trans1]) == {"order_position", "order", "event"}
|
||||
assert get_available_context(context, [trans1, trans2, trans3]) == {"order_position", "order", "event"}
|
||||
|
||||
@pytest.fixture
|
||||
def env():
|
||||
o = Organizer.objects.create(name='Dummy', slug='dummy')
|
||||
prop1 = o.meta_properties.get_or_create(name="Test")[0]
|
||||
prop2 = o.meta_properties.get_or_create(name="Website")[0]
|
||||
event = Event.objects.create(
|
||||
organizer=o, name='Dummy', slug='dummy',
|
||||
date_from=now()
|
||||
)
|
||||
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=o):
|
||||
yield event, user, o
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@scopes_disabled()
|
||||
def item(env):
|
||||
return env[0].items.create(name="Budget Ticket", default_price=23)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@scopes_disabled()
|
||||
def order(env, item):
|
||||
event, _, _ = env
|
||||
o = Order.objects.create(
|
||||
code="FOO",
|
||||
event=event,
|
||||
email="dummy@dummy.test",
|
||||
status=Order.STATUS_PENDING,
|
||||
secret="k24fiuwvu8kxz3y1",
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
total=23,
|
||||
locale="en",
|
||||
)
|
||||
o.positions.create(
|
||||
order=o,
|
||||
item=item,
|
||||
variation=None,
|
||||
price=Decimal("23"),
|
||||
attendee_email="peter@example.org",
|
||||
attendee_name_parts={"given_name": "Peter", "family_name": "Miller"},
|
||||
secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
pseudonymization_id="ABCDEFGHKL",
|
||||
)
|
||||
InvoiceAddress.objects.create(
|
||||
order=o,
|
||||
name_parts={"given_name": "Peter", "family_name": "Miller"},
|
||||
)
|
||||
return o
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_placeholder_transformation_calculation(order):
|
||||
op = order.positions.first()
|
||||
event = order.event
|
||||
context_args = {"order_position": op}
|
||||
trans1 = FunctionalContextTransformation("order", {"order_position"}, lambda order_position: order_position.order)
|
||||
trans2 = FunctionalContextTransformation("event", {"order"}, lambda order: order.event)
|
||||
trans3 = FunctionalContextTransformation("name", {"invoice_address"}, lambda invoice_address: "name")
|
||||
expected_context = {"order_position": op, "order": order, "event": event}
|
||||
assert get_transformed_context(context_args, [trans1, trans2]) == expected_context
|
||||
assert get_transformed_context(context_args, [trans2, trans1]) == expected_context
|
||||
assert get_transformed_context(context_args, [trans1, trans2, trans3]) == expected_context
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user