Compare commits

..

15 Commits

Author SHA1 Message Date
Kara Engelhardt
d4c8637ad0 Remove static layout name from editor page 2026-06-02 16:06:20 +02:00
Kara Engelhardt
b849a4b840 use placeholder name as label default 2026-06-01 15:35:23 +02:00
Kara Engelhardt
ac515f6774 WIP: first successful pass creation 2026-06-01 15:25:59 +02:00
Kara Engelhardt
7d88b40fa7 Add pretix.cfg to django autoreloader files 2026-06-01 14:02:36 +02:00
Kara Engelhardt
2ca91ee35d WalletLayout now contain layouts for all platforms 2026-06-01 13:36:19 +02:00
Kara Engelhardt
7c6876a653 WIP 2026-06-01 13:36:19 +02:00
Kara Engelhardt
77708cdde5 WIP: i18n editor, start apple wallet generation 2026-06-01 13:36:19 +02:00
Kara Engelhardt
280c314aed WIP: i18nfields, refactoring, jsonschema-validatoin 2026-06-01 13:36:19 +02:00
Kara Engelhardt
ec5695e866 WIP: use api 2026-06-01 13:36:19 +02:00
Kara Engelhardt
a2bca9c887 WIP 2026-06-01 13:36:19 +02:00
Kara Engelhardt
16bb64813b WIP 2026-06-01 13:36:19 +02:00
Kara Engelhardt
e828bd0384 WIP 2026-06-01 13:36:18 +02:00
Kara Engelhardt
b354b54de0 WIP 2026-06-01 13:36:18 +02:00
Kara Engelhardt
53fa2504bf WIP 2026-06-01 13:36:18 +02:00
Kara Engelhardt
d963172ecb Add wallet plugins stub 2026-06-01 13:36:18 +02:00
55 changed files with 2789 additions and 313 deletions

View File

@@ -64,8 +64,8 @@ Backend
.. automodule:: pretix.control.signals
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
order_info, order_approve_info, event_settings_widget, oauth_application_registered,
order_position_buttons, subevent_forms, item_formsets, order_search_filter_q, order_search_forms
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms,
item_formsets, order_search_filter_q, order_search_forms
.. automodule:: pretix.base.signals
:no-index:

View File

@@ -23,7 +23,7 @@
"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": {

View File

@@ -93,7 +93,7 @@ dependencies = [
"redis==7.4.*",
"reportlab==4.5.*",
"requests==2.32.*",
"sentry-sdk==2.61.*",
"sentry-sdk==2.60.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",

View File

@@ -32,6 +32,7 @@ ignore =
src/tests/plugins/stripe/*
src/tests/plugins/sendmail/*
src/tests/plugins/ticketoutputpdf/*
src/tests/plugins/wallet/*
.*
CODE_OF_CONDUCT.md
CONTRIBUTING.md

View File

@@ -66,6 +66,7 @@ INSTALLED_APPS = [
'pretix.plugins.returnurl',
'pretix.plugins.autocheckin',
'pretix.plugins.webcheckin',
'pretix.plugins.wallet',
'django_countries',
'oauth2_provider',
'phonenumber_field',

View File

@@ -83,8 +83,7 @@ from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format
from pretix.helpers.daterange import datetimerange
from pretix.helpers.reportlab import (
ThumbnailingImageReader, find_font_supporting_text,
register_ttf_font_if_new, reshaper,
ThumbnailingImageReader, register_ttf_font_if_new, reshaper,
)
from pretix.presale.style import get_fonts
@@ -806,10 +805,7 @@ class Renderer:
else:
self.bg_bytes = None
self.bg_pdf = None
event_fonts = get_fonts(event, pdf_support_required=True) | {'Open Sans': {"bold", "italic", "bolditalic"}}
# sorted by font name to match ordering of libpretixprint
self.event_fonts = dict(sorted(event_fonts.items(), key=lambda x: x[0]))
self.event_fonts = list(get_fonts(event, pdf_support_required=True).keys()) + ['Open Sans']
@classmethod
def _register_fonts(cls, event: Event = None):
@@ -1008,25 +1004,7 @@ class Renderer:
)
canvas.restoreState()
def _prepare_text_paragraph_text(self, op: OrderPosition, order: Order, o: dict):
# add an almost-invisible space   after hyphens as word-wrap in ReportLab only works on space chars
text = conditional_escape(
self._get_text_content(op, order, o) or "",
).replace("\n", "<br/>\n").replace("-", "-&hairsp;")
# reportlab does not support unicode combination characters
# It's important we do this before we use ArabicReshaper
text = unicodedata.normalize("NFC", text)
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
# to resolve all ligatures and python-bidi to switch RTL texts.
try:
text = "<br/>".join(get_display(reshaper.reshape(l)) for l in text.split("<br/>"))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
return text
def _get_text_paragraph_font(self, o: dict, text: str):
def _text_paragraph(self, op: OrderPosition, order: Order, o: dict, legacy_lineheight=False, override_fontsize=None):
font = o['fontfamily']
# Since pdfmetrics.registerFont is global, we want to make sure that no one tries to sneak in a font, they
@@ -1040,14 +1018,6 @@ class Renderer:
if o['italic']:
font += ' I'
font = find_font_supporting_text(self.event_fonts, text, font)
return font
def _text_paragraph(self, op: OrderPosition, order: Order, o: dict, legacy_lineheight=False, override_fontsize=None):
text = self._prepare_text_paragraph_text(op, order, o)
font = self._get_text_paragraph_font(o, text)
fontsize = override_fontsize if override_fontsize is not None else float(o['fontsize'])
try:
ad = getAscentDescent(font, fontsize)
@@ -1076,6 +1046,21 @@ class Renderer:
alignment=align_map[o['align']],
splitLongWords=o.get('splitlongwords', True),
)
# add an almost-invisible space &hairsp; after hyphens as word-wrap in ReportLab only works on space chars
text = conditional_escape(
self._get_text_content(op, order, o) or "",
).replace("\n", "<br/>\n").replace("-", "-&hairsp;")
# reportlab does not support unicode combination characters
# It's important we do this before we use ArabicReshaper
text = unicodedata.normalize("NFC", text)
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
# to resolve all ligatures and python-bidi to switch RTL texts.
try:
text = "<br/>".join(get_display(reshaper.reshape(l)) for l in text.split("<br/>"))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
p = Paragraph(text, style=style)
return p, ad, lineheight

View File

@@ -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:
"""

View File

@@ -461,31 +461,3 @@ class SalesChannelCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
**super().create_option(name, value, label, selected, index, subindex, attrs),
"plugin_missing": plugin and plugin not in self.event.get_plugins(),
}
class ModelChoiceIteratorWithNone(forms.models.ModelChoiceIterator):
# see django.forms.models.ModelChoiceIterator for original implementation
def __iter__(self):
if self.field.empty_label is not None:
yield ("", self.field.empty_label)
if self.field.none_label is not None:
yield ("_none", self.field.none_label)
queryset = self.queryset
# Can't use iterator() when queryset uses prefetch_related()
if not queryset._prefetch_related_lookups:
queryset = queryset.iterator()
for obj in queryset:
yield self.choice(obj)
class ModelChoiceFieldWithNone(forms.ModelChoiceField):
iterator = ModelChoiceIteratorWithNone
def __init__(self, *args, **kwargs):
self.none_label = kwargs.pop("none_label", None)
super().__init__(*args, **kwargs)
def to_python(self, value):
if value == "_none":
return value
return super().to_python(value)

View File

@@ -945,7 +945,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):

View File

@@ -29,30 +29,17 @@ class Select2Mixin:
super().__init__(*args, **kwargs)
def options(self, name, value, attrs=None):
if not value or not value[0]:
return
has_none = "_none" in value
if has_none:
value = [v for v in value if v != "_none"]
yield self.create_option(
None,
"_none",
self.choices.field.none_label,
True,
0,
subindex=None,
attrs=attrs
)
for i, selected in enumerate(self.choices.queryset.filter(pk__in=value)):
yield self.create_option(
None,
self.choices.field.prepare_value(selected),
self.choices.field.label_from_instance(selected),
True,
i + (1 if has_none else 0),
subindex=None,
attrs=attrs
)
if value and value[0]:
for i, selected in enumerate(self.choices.queryset.filter(pk__in=value)):
yield self.create_option(
None,
self.choices.field.prepare_value(selected),
self.choices.field.label_from_instance(selected),
True,
i,
subindex=None,
attrs=attrs
)
return
def optgroups(self, name, value, attrs=None):

View File

@@ -261,16 +261,6 @@ As with all event plugin signals, the ``sender`` keyword argument will contain t
Additionally, the argument ``order`` and ``request`` are available.
"""
order_approve_info = EventPluginSignal()
"""
Arguments: ``order``, ``request``
This signal is sent out to display additional information on the order approve page
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available.
"""
order_position_buttons = EventPluginSignal()
"""
Arguments: ``order``, ``position``, ``request``

View File

@@ -1,5 +1,4 @@
{% extends "pretixcontrol/event/base.html" %}
{% load eventsignal %}
{% load i18n %}
{% block title %}
{% trans "Approve order" %}
@@ -8,9 +7,6 @@
<h1>
{% trans "Approve order" %}
</h1>
{% eventsignal request.event "pretix.control.signals.order_approve_info" order=order request=request %}
<p>{% blocktrans trimmed %}
Do you really want to approve this order?
{% endblocktrans %}</p>

View File

@@ -965,7 +965,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):
@@ -1068,6 +1068,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,
@@ -1077,17 +1082,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

View File

@@ -567,6 +567,8 @@ 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',

View File

@@ -145,21 +145,11 @@ def event_list(request):
if 'can_copy' in request.GET:
qs = EventWizardCopyForm.copy_from_queryset(request.user, request.session)
else:
permission = request.GET.get('permission')
if permission:
qs = request.user.get_events_with_permission(permission, request)
else:
qs = request.user.get_events_with_any_permission(request)
name_slug_q = Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query)
organizer = request.GET.get('organizer')
if organizer:
qs = qs.filter(organizer__slug=organizer)
else:
name_slug_q |= Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query)
qs = request.user.get_events_with_any_permission(request)
qs = qs.filter(
name_slug_q
Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) |
Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query)
).annotate(
min_from=Min('subevents__date_from'),
max_from=Max('subevents__date_from'),
@@ -172,19 +162,10 @@ def event_list(request):
total = qs.count()
pagesize = 20
offset = (page - 1) * pagesize
results = []
if page == 1 and 'include_none' in request.GET and not query:
results.append({
'id': "_none",
'text': _("No event"),
'name': _("No event"),
'type': "event",
})
results += [
serialize_event(e) for e in qs.select_related('organizer')[offset:offset + pagesize]
]
doc = {
'results': results,
'results': [
serialize_event(e) for e in qs.select_related('organizer')[offset:offset + pagesize]
],
'pagination': {
"more": total >= (offset + pagesize)
}

View File

@@ -70,40 +70,37 @@ reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={
}))
def font_supports_text(text, font_name):
if not text:
return True
font = pdfmetrics.getFont(font_name)
return all(
ord(c) in font.face.charToGlyph or not c.isprintable()
for c in text
)
def find_font_supporting_text(fonts, text, preferred_font):
if font_supports_text(text, preferred_font):
return preferred_font
for family, styles in fonts.items():
if font_supports_text(text, family):
if (preferred_font.endswith("It") or preferred_font.endswith(" I")) and "italic" in styles:
return family + " I"
if (preferred_font.endswith("Bd") or preferred_font.endswith(" B")) and "bold" in styles:
return family + " B"
return family
return preferred_font
class FontFallbackParagraph(Paragraph):
def __init__(self, text, style=None, *args, **kwargs):
if style is None:
style = ParagraphStyle(name='paragraphImplicitDefaultStyle')
supporting_font = find_font_supporting_text(get_fonts(pdf_support_required=True), text, style.fontName)
if supporting_font != style.fontName:
logger.debug(f"replacing {style.fontName} with {supporting_font} for {text!r}")
style = style.clone(name=style.name + '_' + supporting_font, fontName=supporting_font)
if not self._font_supports_text(text, style.fontName):
newFont = self._find_font(text, style.fontName)
if newFont:
logger.debug(f"replacing {style.fontName} with {newFont} for {text!r}")
style = style.clone(name=style.name + '_' + newFont, fontName=newFont)
super().__init__(text, style, *args, **kwargs)
def _font_supports_text(self, text, font_name):
if not text:
return True
font = pdfmetrics.getFont(font_name)
return all(
ord(c) in font.face.charToGlyph or not c.isprintable()
for c in text
)
def _find_font(self, text, original_font):
for family, styles in get_fonts(pdf_support_required=True).items():
if self._font_supports_text(text, family):
if (original_font.endswith("It") or original_font.endswith(" I")) and "italic" in styles:
return family + " I"
if (original_font.endswith("Bd") or original_font.endswith(" B")) and "bold" in styles:
return family + " B"
return family
def register_ttf_font_if_new(name, path):
from reportlab.pdfbase import pdfmetrics

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-05-29 17:00+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"PO-Revision-Date: 2026-05-01 21:00+0000\n"
"Last-Translator: Paul Berschick <paul@plainschwarz.com>\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.5\n"
"X-Generator: Weblate 5.17\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670
#: pretix/control/templates/pretixcontrol/events/index.html:166
@@ -620,17 +620,16 @@ msgstr ""
"como variaciones o paquetes."
#: pretix/api/webhooks.py:413
#, fuzzy
#| msgid "Quota handling"
msgid "Quota changed"
msgstr "Se ha modificado la cuota"
msgstr "Gestión de cuotas"
#: pretix/api/webhooks.py:414
msgid ""
"This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability."
msgstr ""
"Esto incluye acciones relacionadas, como la creación, la eliminación, la "
"apertura o el cierre de cuotas. No se envía ningún webhook cuando se "
"producen cambios en la disponibilidad resultante."
#: pretix/api/webhooks.py:419
msgid "Shop taken live"
@@ -3419,13 +3418,11 @@ msgid ""
"The field \"%(label)s\" may not contain special characters such as "
"\"%(chars)s\"."
msgstr ""
"El campo «%(label)s» no puede contener caracteres especiales como «%(chars)s"
"»."
#: pretix/base/forms/questions.py:305
#, python-format
msgid "The field \"%(label)s\" may not contain an URL (%(url)s)."
msgstr "El campo «%(label)s» no puede contener una URL (%(url)s)."
msgstr ""
#: pretix/base/forms/questions.py:338
msgctxt "phonenumber"
@@ -8364,14 +8361,19 @@ msgid "Program times"
msgstr "Horarios del programa"
#: pretix/base/pdf.py:503
#, fuzzy
#| msgid ""
#| "2017-05-31 10:00 12:00\n"
#| "2017-05-31 14:00 16:00\n"
#| "2017-05-31 14:00 2017-06-01 14:00"
msgid ""
"2017-05-31 10:00 12:00, Room 1\n"
"2017-05-31 14:00 16:00, Room 2\n"
"2017-05-31 14:00 2017-06-01 14:00, Building A"
msgstr ""
"31 de mayo de 2017, de 10:00 a 12:00, Sala 1\n"
"31 de mayo de 2017, de 14:00 a 16:00, Sala 2\n"
"31 de mayo de 2017, de 14:00 a 1 de junio de 2017, 14:00, Edificio A"
"2017-05-31 10:00 12:00\n"
"2017-05-31 14:00 16:00\n"
"2017-05-31 14:00 2017-06-01 14:00"
#: pretix/base/pdf.py:507
msgid "Reusable Medium ID"
@@ -8901,7 +8903,13 @@ msgid "This voucher code is not known in our database."
msgstr "Este vale de compra no se conoce en nuestra base de datos."
#: pretix/base/services/cart.py:165
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product."
@@ -8909,14 +8917,22 @@ msgid_plural ""
"The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching products."
msgstr[0] ""
"El código de descuento «%(voucher)s» solo se puede utilizar si seleccionas "
"al menos%(number)s productos que cumplan los requisitos."
"El vale de compra \"%(voucher)s\" solo se puede utilizar si selecciona al "
"menos %(number)s productos coincidentes."
msgstr[1] ""
"El código de descuento «%(voucher)s» solo se puede utilizar si seleccionas "
"al menos %(number)s productos que cumplan los requisitos."
"Los vales de compra \"%(voucher)s\" solo se pueden utilizar si selecciona al "
"menos %(number)s productos coincidentes."
#: pretix/base/services/cart.py:170
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product. We have therefore removed some positions from "
@@ -8926,15 +8942,13 @@ msgid_plural ""
"%(number)s matching products. We have therefore removed some positions from "
"your cart that can no longer be purchased like this."
msgstr[0] ""
"El código promocional «%(voucher)s» solo se puede utilizar si seleccionas al "
"menos %(number)s producto que cumpla los requisitos. Por lo tanto, hemos "
"eliminado de tu carrito algunos artículos que ya no se pueden comprar de "
"esta forma."
"El vale de compra \"%(voucher)s\" solo se puede utilizar si selecciona al "
"menos %(number)s productos coincidentes. Por lo tanto, hemos eliminado "
"algunas posiciones de su carrito que ya no se pueden comprar así."
msgstr[1] ""
"El código promocional «%(voucher)s» solo se puede utilizar si seleccionas al "
"menos %(number)s productos que cumplan los requisitos. Por lo tanto, hemos "
"eliminado de tu carrito algunos artículos que ya no se pueden comprar de "
"esta forma."
"Los vale de compra \"%(voucher)s\" solo se pueden utilizar si selecciona al "
"menos %(number)s productos coincidentes. Por lo tanto, hemos eliminado "
"algunas posiciones de su carrito que ya no se pueden comprar así."
#: pretix/base/services/cart.py:176
msgid ""
@@ -14240,8 +14254,6 @@ msgid ""
"You entered an URL, which is not allowed. Please remove %(match)s from your "
"input."
msgstr ""
"Ha introducido una URL que no está permitida. Elimina %(match)s de su "
"entrada."
#: pretix/base/views/errors.py:48
msgid ""
@@ -16182,8 +16194,14 @@ msgid "inactive"
msgstr "inactivo"
#: pretix/control/forms/item.py:1414
#, fuzzy
#| msgid ""
#| "Sample Conference Center\n"
#| "Heidelberg, Germany"
msgid "Sample Conference Center, Heidelberg, Germany"
msgstr "Ejemplo de Centro de Conferencia : Heidelberg, Alemania"
msgstr ""
"Ejemplo de Centro de Conferencia \n"
"Heidelberg, Alemania"
#: pretix/control/forms/mailsetup.py:42
msgid "Hostname"
@@ -23641,8 +23659,11 @@ msgid "Quota history"
msgstr "Historial de cuotas"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:6
#, fuzzy
#| msgctxt "subevent"
#| msgid "Change multiple dates"
msgid "Change multiple quotas"
msgstr "Modificar varias cuotas"
msgstr "Cambiar varias fechas"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:8
#: pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html:8
@@ -23692,15 +23713,18 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:4
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:6
#, fuzzy
#| msgid "Delete quota"
msgid "Delete quotas"
msgstr "Eliminar cuotas"
msgstr "Borrar cuota"
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:10
#, python-format
#, fuzzy, python-format
#| msgid "Are you sure you want to delete the following dates?"
msgid "Are you sure you want to delete the following quota?"
msgid_plural "Are you sure you want to delete the following %(num)s quotas?"
msgstr[0] "¿Está seguro de que desea eliminar la siguiente cuota?"
msgstr[1] "¿Está seguro de que desea eliminar las siguientes %(num)s cuotas?"
msgstr[0] "¿Está seguro de que desea borrar las fechas siguientes?"
msgstr[1] "¿Está seguro de que desea borrar las fechas siguientes?"
#: pretix/control/templates/pretixcontrol/items/quotas.html:9
msgid ""
@@ -24305,15 +24329,12 @@ msgid ""
"generated once the customer pays the invoice or selects a payment method "
"that requires an invoice."
msgstr ""
"Este pedido se modificó después de que se generara la última factura. Aún no "
"se ha generado una nueva factura, ya que las facturas están configuradas "
"para generarse al realizar el pago o si así lo exige la forma de pago. Se "
"generará una nueva factura una vez que el cliente abone la factura o "
"seleccione una forma de pago que requiera una factura."
#: pretix/control/templates/pretixcontrol/order/index.html:152
#, fuzzy
#| msgid "Request invoice"
msgid "Reissue invoice"
msgstr "Reemitir factura"
msgstr "Solicitar factura"
#: pretix/control/templates/pretixcontrol/order/index.html:161
#: pretix/control/templates/pretixcontrol/order/index.html:413
@@ -24744,16 +24765,23 @@ msgid "How should the refund be sent?"
msgstr "¿Cómo se debe de realizar este reembolso?"
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:25
#, fuzzy
#| msgid ""
#| "Any payments that you selected for automatical refunds will be "
#| "immediately communicate the refund request to the respective payment "
#| "provider. Manual refunds will be created as pending refunds, you can then "
#| "later mark them as done once you actually transferred the money back to "
#| "the customer."
msgid ""
"Any payments you selected for automatic refunds will have the refund request "
"sent immediately to the respective payment provider. Manual refunds will be "
"created as pending refunds, which you can later mark as done once you have "
"actually transferred the money back to the customer."
msgstr ""
"Los pagos que hayas seleccionado para reembolsos automáticos se enviarán "
"inmediatamente al proveedor de pagos correspondiente. Los reembolsos "
"manuales se crearán como reembolsos pendientes, que podrás marcar como "
"completados más adelante, una vez que hayas devuelto el dinero al cliente."
"Cualquier pago que haya seleccionado de manera automática para reembolso "
"será comunicado inmediatamente a la entidad de pago correspondiente. Los "
"devoluciones manuales se crearán como reembolsos pendientes, podrá marcarlos "
"como hechos una vez que se haya transferido el dinero al cliente."
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:32
msgid "Refund to original payment method"
@@ -29309,8 +29337,11 @@ msgid "The new question has been created."
msgstr "La nueva pregunta ha sido creada."
#: pretix/control/views/item.py:918
#, fuzzy
#| msgctxt "subevent"
#| msgid "The selected dates have been deleted or disabled."
msgid "The selected quotas have been deleted or disabled."
msgstr "Las cuotas seleccionadas se han eliminado o desactivado."
msgstr "Las fechas seleccionadas se han borrado o desactivado."
#: pretix/control/views/item.py:1074
msgid "The new quota has been created."
@@ -30042,9 +30073,11 @@ msgstr ""
"Este plugin no está permitido actualmente para su cuenta de organizador."
#: pretix/control/views/organizer.py:832
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "This plugin can be enabled or disabled for events individually."
msgid "This plugin cannot be activated for event {}."
msgstr "Este complemento no se puede activar para el evento {}."
msgstr ""
"Este plugin se puede activar o desactivar para eventos de forma individual."
#: pretix/control/views/organizer.py:901
msgid "The team has been created. You can now add members to the team."
@@ -31089,9 +31122,10 @@ msgid "{width} x {height} mm label"
msgstr "etiqueta {width} x {height} mm"
#: pretix/plugins/badges/templates.py:265
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "{width} x {height} mm label"
msgid "{width} x {height} inch label"
msgstr "Etiqueta de {width} x {height} pulgadas"
msgstr "etiqueta {width} x {height} mm"
#: pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html:16
#: pretix/plugins/badges/templates/pretixplugins/badges/index.html:27

View File

@@ -4,16 +4,16 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-05-29 17:00+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/"
"fr/>\n"
"PO-Revision-Date: 2026-05-08 04:00+0000\n"
"Last-Translator: corentin-spec <corentin@spectentaculaire.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"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 2026.5\n"
"X-Generator: Weblate 5.17.1\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670
#: pretix/control/templates/pretixcontrol/events/index.html:166
@@ -618,17 +618,16 @@ msgstr ""
"aux objets imbriqués tels que les variantes ou les lots."
#: pretix/api/webhooks.py:413
#, fuzzy
#| msgid "Quota handling"
msgid "Quota changed"
msgstr "Quota modifié"
msgstr "Traitement des quotas"
#: pretix/api/webhooks.py:414
msgid ""
"This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability."
msgstr ""
"Cela inclut les événements associés, tels que la création, la suppression, "
"l'ouverture ou la suppression de quotas. Aucun webhook n'est envoyé en cas "
"de modification de la disponibilité qui en résulte."
#: pretix/api/webhooks.py:419
msgid "Shop taken live"
@@ -3423,13 +3422,11 @@ msgid ""
"The field \"%(label)s\" may not contain special characters such as "
"\"%(chars)s\"."
msgstr ""
"Le champ « %(label)s » ne doit pas contenir de caractères spéciaux tels que "
"«%(chars)s »."
#: pretix/base/forms/questions.py:305
#, python-format
msgid "The field \"%(label)s\" may not contain an URL (%(url)s)."
msgstr "Le champ « %(label)s » ne doit pas contenir d'URL (%(url)s)."
msgstr ""
#: pretix/base/forms/questions.py:338
msgctxt "phonenumber"
@@ -8412,14 +8409,19 @@ msgid "Program times"
msgstr "Horaires du programme"
#: pretix/base/pdf.py:503
#, fuzzy
#| msgid ""
#| "2017-05-31 10:00 12:00\n"
#| "2017-05-31 14:00 16:00\n"
#| "2017-05-31 14:00 2017-06-01 14:00"
msgid ""
"2017-05-31 10:00 12:00, Room 1\n"
"2017-05-31 14:00 16:00, Room 2\n"
"2017-05-31 14:00 2017-06-01 14:00, Building A"
msgstr ""
"31 mai 2017, de 10 h à 12 h, salle 1\n"
"31 mai 2017, de 14 h à 16 h, salle 2\n"
"Du 31 mai 2017 à 1 h du matin au 1er juin 2017 à 14 h, bâtiment A"
"2017-05-31 10:00 12:00\n"
"2017-05-31 14:00 16:00\n"
"2017-05-31 14:00 2017-06-01 14:00"
#: pretix/base/pdf.py:507
msgid "Reusable Medium ID"
@@ -8955,7 +8957,13 @@ msgid "This voucher code is not known in our database."
msgstr "Ce code promotionnel n'est pas connu dans notre base de données."
#: pretix/base/services/cart.py:165
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product."
@@ -8963,14 +8971,22 @@ msgid_plural ""
"The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching products."
msgstr[0] ""
"Le code promo « %(voucher)s » ne peut être utilisé que si vous sélectionnez "
"Le code promo \"%(voucher)s\" ne peut être utilisé que si vous sélectionnez "
"au moins %(number)s produit correspondant."
msgstr[1] ""
"Le code promo « %(voucher)s » ne peut être utilisé que si vous sélectionnez "
"Le code promo \"%(voucher)s\" ne peut être utilisé que si vous sélectionnez "
"au moins %(number)s produits correspondants."
#: pretix/base/services/cart.py:170
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product. We have therefore removed some positions from "
@@ -14363,8 +14379,6 @@ msgid ""
"You entered an URL, which is not allowed. Please remove %(match)s from your "
"input."
msgstr ""
"Vous avez saisi une URL, ce qui n'est pas autorisé. Veuillez supprimer %"
"(match)s de votre saisie."
#: pretix/base/views/errors.py:48
msgid ""
@@ -16314,8 +16328,14 @@ msgid "inactive"
msgstr "inactif"
#: pretix/control/forms/item.py:1414
#, fuzzy
#| msgid ""
#| "Sample Conference Center\n"
#| "Heidelberg, Germany"
msgid "Sample Conference Center, Heidelberg, Germany"
msgstr "Centre de conférences d'exemple, Heidelberg, Allemagne"
msgstr ""
"Exemple de centre de conférence\n"
"Centre des Congrès, France"
#: pretix/control/forms/mailsetup.py:42
msgid "Hostname"
@@ -23811,8 +23831,11 @@ msgid "Quota history"
msgstr "Historique des quotas"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:6
#, fuzzy
#| msgctxt "subevent"
#| msgid "Change multiple dates"
msgid "Change multiple quotas"
msgstr "Modifier plusieurs quotas"
msgstr "Modifier plusieurs dates"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:8
#: pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html:8
@@ -23860,15 +23883,18 @@ msgstr "Les produits suivants pourraient ne plus être disponibles à la vente
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:4
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:6
#, fuzzy
#| msgid "Delete quota"
msgid "Delete quotas"
msgstr "Supprimer les quotas"
msgstr "Supprimer le quota"
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:10
#, python-format
#, fuzzy, python-format
#| msgid "Are you sure you want to delete the following dates?"
msgid "Are you sure you want to delete the following quota?"
msgid_plural "Are you sure you want to delete the following %(num)s quotas?"
msgstr[0] "Êtes-vous sûr de vouloir supprimer le quota suivant?"
msgstr[1] "Êtes-vous sûr de vouloir supprimer les %(num)s quotas suivants?"
msgstr[0] "Voulez-vous vraiment supprimer les dates suivantes ?"
msgstr[1] "Voulez-vous vraiment supprimer les dates suivantes ?"
#: pretix/control/templates/pretixcontrol/items/quotas.html:9
msgid ""
@@ -24477,15 +24503,12 @@ msgid ""
"generated once the customer pays the invoice or selects a payment method "
"that requires an invoice."
msgstr ""
"Cette commande a été modifiée après l'émission de la dernière facture. "
"Aucune nouvelle facture n'a encore été générée, car les factures sont "
"configurées pour être émises lors du paiement ou si le mode de paiement "
"l'exige. Une nouvelle facture sera générée dès que le client aura réglé la "
"facture ou choisi un mode de paiement nécessitant une facture."
#: pretix/control/templates/pretixcontrol/order/index.html:152
#, fuzzy
#| msgid "Request invoice"
msgid "Reissue invoice"
msgstr "Réémettre une facture"
msgstr "Demande de facture"
#: pretix/control/templates/pretixcontrol/order/index.html:161
#: pretix/control/templates/pretixcontrol/order/index.html:413
@@ -24919,17 +24942,25 @@ msgid "How should the refund be sent?"
msgstr "Comment le remboursement doit-il être envoyé ?"
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:25
#, fuzzy
#| msgid ""
#| "Any payments that you selected for automatical refunds will be "
#| "immediately communicate the refund request to the respective payment "
#| "provider. Manual refunds will be created as pending refunds, you can then "
#| "later mark them as done once you actually transferred the money back to "
#| "the customer."
msgid ""
"Any payments you selected for automatic refunds will have the refund request "
"sent immediately to the respective payment provider. Manual refunds will be "
"created as pending refunds, which you can later mark as done once you have "
"actually transferred the money back to the customer."
msgstr ""
"Pour tous les paiements que vous avez sélectionnés pour un remboursement "
"automatique, la demande de remboursement sera immédiatement transmise au "
"prestataire de paiement concerné. Les remboursements manuels seront "
"enregistrés comme remboursements en attente; vous pourrez les marquer comme "
"effectués une fois que vous aurez effectivement reversé l'argent au client."
"Tous les paiements que vous avez sélectionnés pour des remboursements "
"automatiques seront immédiatement communiqués à la demande de remboursement "
"au fournisseur de paiement respectif. Les remboursements manuels seront "
"créés en tant que remboursements en attente, vous pourrez ensuite les "
"marquer comme terminés une fois que vous aurez effectivement transféré "
"largent au client."
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:32
msgid "Refund to original payment method"
@@ -29527,8 +29558,11 @@ msgid "The new question has been created."
msgstr "La nouvelle question a été créée."
#: pretix/control/views/item.py:918
#, fuzzy
#| msgctxt "subevent"
#| msgid "The selected dates have been deleted or disabled."
msgid "The selected quotas have been deleted or disabled."
msgstr "Les quotas sélectionnés ont été supprimés ou désactivés."
msgstr "Les dates sélectionnées ont été supprimées ou désactivées."
#: pretix/control/views/item.py:1074
msgid "The new quota has been created."
@@ -30268,9 +30302,12 @@ msgstr ""
"Ce plugin n'est actuellement pas autorisé pour ce compte d'organisateur."
#: pretix/control/views/organizer.py:832
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "This plugin can be enabled or disabled for events individually."
msgid "This plugin cannot be activated for event {}."
msgstr "Ce plugin ne peut pas être activé pour l'événement {}."
msgstr ""
"Ce plugin peut être activé ou désactivé individuellement pour chaque "
"événement."
#: pretix/control/views/organizer.py:901
msgid "The team has been created. You can now add members to the team."
@@ -31325,9 +31362,10 @@ msgid "{width} x {height} mm label"
msgstr "{width} x {height} mm étiquette"
#: pretix/plugins/badges/templates.py:265
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "{width} x {height} mm label"
msgid "{width} x {height} inch label"
msgstr "{width} x {height} pouce étiquette"
msgstr "{width} x {height} mm étiquette"
#: pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html:16
#: pretix/plugins/badges/templates/pretixplugins/badges/index.html:27

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-01 09:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"PO-Revision-Date: 2026-05-12 06:34+0000\n"
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\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.5\n"
"X-Generator: Weblate 5.17.1\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670
#: pretix/control/templates/pretixcontrol/events/index.html:166
@@ -608,20 +608,20 @@ msgstr ""
"更を含みます。"
#: pretix/api/webhooks.py:413
#, fuzzy
#| msgid "Quota handling"
msgid "Quota changed"
msgstr "クォータが変更されました"
msgstr "クォータの処理"
#: pretix/api/webhooks.py:414
msgid ""
"This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability."
msgstr ""
"これには、クォータの作成、削除、開始または終了といった関連イベントが含まれま"
"す。結果として得られる可用性の変更については、Webhookが送信されません。"
#: pretix/api/webhooks.py:419
msgid "Shop taken live"
msgstr "ショップがオンラインになりました"
msgstr "ショップが公開中になりました"
#: pretix/api/webhooks.py:423
msgid "Shop taken offline"
@@ -3394,13 +3394,11 @@ msgid ""
"The field \"%(label)s\" may not contain special characters such as "
"\"%(chars)s\"."
msgstr ""
"フィールド「%(label)s」には、\"%(chars)s\" のような特殊文字を含めることはでき"
"ません。"
#: pretix/base/forms/questions.py:305
#, python-format
msgid "The field \"%(label)s\" may not contain an URL (%(url)s)."
msgstr "フィールド「%(label)s」には URL (%(url)s) を含めることができません。"
msgstr ""
#: pretix/base/forms/questions.py:338
msgctxt "phonenumber"
@@ -8191,14 +8189,19 @@ msgid "Program times"
msgstr "プログラム時間"
#: pretix/base/pdf.py:503
#, fuzzy
#| msgid ""
#| "2017-05-31 10:00 12:00\n"
#| "2017-05-31 14:00 16:00\n"
#| "2017-05-31 14:00 2017-06-01 14:00"
msgid ""
"2017-05-31 10:00 12:00, Room 1\n"
"2017-05-31 14:00 16:00, Room 2\n"
"2017-05-31 14:00 2017-06-01 14:00, Building A"
msgstr ""
"2017-05-31 10:00 12:00、部屋1\n"
"2017-05-31 14:00 16:00、部屋2\n"
"2017-05-31 14:00 2017-06-01 14:00、ビルA"
"2017-05-31 10:00 12:00\n"
"2017-05-31 14:00 16:00\n"
"2017-05-31 14:00 2017-06-01 14:00"
#: pretix/base/pdf.py:507
msgid "Reusable Medium ID"
@@ -8707,7 +8710,13 @@ msgid "This voucher code is not known in our database."
msgstr "このバウチャーコードは、当社のデータベースには登録されていません。"
#: pretix/base/services/cart.py:165
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products."
msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product."
@@ -8719,7 +8728,15 @@ msgstr[0] ""
"した場合にのみ使用できます。"
#: pretix/base/services/cart.py:170
#, python-format
#, fuzzy, python-format
#| msgid ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
#| msgid_plural ""
#| "The voucher code \"%(voucher)s\" can only be used if you select at least "
#| "%(number)s matching products. We have therefore removed some positions "
#| "from your cart that can no longer be purchased like this."
msgid ""
"The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching product. We have therefore removed some positions from "
@@ -13820,8 +13837,6 @@ msgid ""
"You entered an URL, which is not allowed. Please remove %(match)s from your "
"input."
msgstr ""
"URL を入力しましたが、許可されていません。入力から %(match)s を削除してくださ"
"い。"
#: pretix/base/views/errors.py:48
msgid ""
@@ -15718,8 +15733,14 @@ msgid "inactive"
msgstr "無効"
#: pretix/control/forms/item.py:1414
#, fuzzy
#| msgid ""
#| "Sample Conference Center\n"
#| "Heidelberg, Germany"
msgid "Sample Conference Center, Heidelberg, Germany"
msgstr "サンプル・カンファレンスセンター, ドイツ, ハイデルベルク"
msgstr ""
"サンプル・カンファレンスセンター\n"
"ドイツ、ハイデルベルク"
#: pretix/control/forms/mailsetup.py:42
msgid "Hostname"
@@ -22960,8 +22981,11 @@ msgid "Quota history"
msgstr "クォータ履歴"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:6
#, fuzzy
#| msgctxt "subevent"
#| msgid "Change multiple dates"
msgid "Change multiple quotas"
msgstr "複数のクォータを変更"
msgstr "複数の日付を変更"
#: pretix/control/templates/pretixcontrol/items/quota_bulk_edit.html:8
#: pretix/control/templates/pretixcontrol/organizers/device_bulk_edit.html:8
@@ -23007,14 +23031,17 @@ msgstr "以下の製品は販売できなくなる可能性があります:"
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:4
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:6
#, fuzzy
#| msgid "Delete quota"
msgid "Delete quotas"
msgstr "クォータを削除"
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html:10
#, python-format
#, fuzzy, python-format
#| msgid "Are you sure you want to delete the following dates?"
msgid "Are you sure you want to delete the following quota?"
msgid_plural "Are you sure you want to delete the following %(num)s quotas?"
msgstr[0] "以下の%(num)sのクォータを削除してもよろしいですか?"
msgstr[0] "以下の日付を削除してもよろしいですか?"
#: pretix/control/templates/pretixcontrol/items/quotas.html:9
msgid ""
@@ -23607,14 +23634,12 @@ msgid ""
"generated once the customer pays the invoice or selects a payment method "
"that requires an invoice."
msgstr ""
"この注文は、最後の請求書が生成された後に変更されました。新しい請求書はまだ作"
"成されていません。請求書は支払い時に生成されるか、支払方法によって必要とされ"
"る場合に設定されているためです。お客様が請求書を支払うか、請求書が必要な支払"
"方法を選択すると、新しい請求書が生成されます。"
#: pretix/control/templates/pretixcontrol/order/index.html:152
#, fuzzy
#| msgid "Request invoice"
msgid "Reissue invoice"
msgstr "請求書を再発行する"
msgstr "請求書を要求"
#: pretix/control/templates/pretixcontrol/order/index.html:161
#: pretix/control/templates/pretixcontrol/order/index.html:413
@@ -24039,15 +24064,22 @@ msgid "How should the refund be sent?"
msgstr "どのように払い戻しますか?"
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:25
#, fuzzy
#| msgid ""
#| "Any payments that you selected for automatical refunds will be "
#| "immediately communicate the refund request to the respective payment "
#| "provider. Manual refunds will be created as pending refunds, you can then "
#| "later mark them as done once you actually transferred the money back to "
#| "the customer."
msgid ""
"Any payments you selected for automatic refunds will have the refund request "
"sent immediately to the respective payment provider. Manual refunds will be "
"created as pending refunds, which you can later mark as done once you have "
"actually transferred the money back to the customer."
msgstr ""
"自動返金をご選択いただいたすべての支払いについては、返金リクエストが直ちに該"
"当する決済プロバイダーへ送信されます。手動返金は保留中の返金として作成され、"
"実際に顧客に返金した後で完了としてマークできます。"
"自動払い戻しに選択した支払いは、該当する決済プロバイダーに払い戻し要求が即座"
"に通知されます。手動払い戻しは保留中の払い戻しとして作成され、実際に顧客に送"
"金した後で完了済みとしてマークできます。"
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:32
msgid "Refund to original payment method"
@@ -28472,8 +28504,11 @@ msgid "The new question has been created."
msgstr "新しい質問が作成されました。"
#: pretix/control/views/item.py:918
#, fuzzy
#| msgctxt "subevent"
#| msgid "The selected dates have been deleted or disabled."
msgid "The selected quotas have been deleted or disabled."
msgstr "選択したクォータは削除されたか無効す。"
msgstr "選択した日付は削除されたか無効になっています。"
#: pretix/control/views/item.py:1074
msgid "The new quota has been created."
@@ -29180,9 +29215,10 @@ msgid "This plugin is currently not allowed for this organizer account."
msgstr "このプラグインは現在、この主催者アカウントでは許可されていません。"
#: pretix/control/views/organizer.py:832
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "This plugin can be enabled or disabled for events individually."
msgid "This plugin cannot be activated for event {}."
msgstr "このプラグインは、イベント{}に対してアクティベートできません。"
msgstr "このプラグインは、イベントごとに個別に有効化または無効化できま。"
#: pretix/control/views/organizer.py:901
msgid "The team has been created. You can now add members to the team."
@@ -30200,9 +30236,10 @@ msgid "{width} x {height} mm label"
msgstr "{width} x {height} mm ラベル"
#: pretix/plugins/badges/templates.py:265
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "{width} x {height} mm label"
msgid "{width} x {height} inch label"
msgstr "{width} x {height} インチラベル"
msgstr "{width} x {height} mm ラベル"
#: pretix/plugins/badges/templates/pretixplugins/badges/control_order_info.html:16
#: pretix/plugins/badges/templates/pretixplugins/badges/index.html:27

View File

@@ -8,16 +8,16 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-01 09:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Korean <https://translate.pretix.eu/projects/pretix/pretix/"
"ko/>\n"
"PO-Revision-Date: 2026-02-01 21:00+0000\n"
"Last-Translator: z3rrry <z3rrry@gmail.com>\n"
"Language-Team: Korean <https://translate.pretix.eu/projects/pretix/pretix/ko/"
">\n"
"Language: ko\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 2026.5\n"
"X-Generator: Weblate 5.15.2\n"
#: htmlcov/d_daa1541d0cbf5e2b_dashboards_py.html:670
#: pretix/control/templates/pretixcontrol/events/index.html:166
@@ -48,7 +48,7 @@ msgstr "사전판매 시작하지 않음"
#: pretix/control/templates/pretixcontrol/subevents/index.html:176
#: pretix/control/views/dashboards.py:549
msgid "On sale"
msgstr "세일 중"
msgstr ""
#: pretix/_base_settings.py:89
msgid "English"
@@ -427,8 +427,10 @@ msgstr ""
#: pretix/api/serializers/organizer.py:495
#: pretix/control/views/organizer.py:1035
#, fuzzy
#| msgid "pretix account invitation"
msgid "Account invitation"
msgstr "계정 초대"
msgstr "프레틱스 계정 초대"
#: pretix/api/serializers/organizer.py:516
#: pretix/control/views/organizer.py:1134
@@ -18085,8 +18087,10 @@ msgid "A payment has been performed."
msgstr "수동 거래가 수행되었습니다."
#: pretix/control/logdisplay.py:807
#, fuzzy
#| msgid "A manual transaction has been performed."
msgid "A refund has been performed. "
msgstr "환불이 처리되었습니다. "
msgstr "수동 거래가 수행되었습니다."
#: pretix/control/logdisplay.py:808
#, python-brace-format

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-27 15:47+0000\n"
"PO-Revision-Date: 2026-06-01 09:00+0000\n"
"PO-Revision-Date: 2026-05-21 15:08+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Chinese (Traditional Han script) <https://translate.pretix.eu/"
"projects/pretix/pretix/zh_Hant/>\n"
@@ -595,16 +595,16 @@ msgid ""
msgstr "這包括新增或刪除的產品,以及對變體或捆綁等巢狀物件的更改。"
#: pretix/api/webhooks.py:413
#, fuzzy
#| msgid "Quota handling"
msgid "Quota changed"
msgstr "配額改變了"
msgstr "額度處理"
#: pretix/api/webhooks.py:414
msgid ""
"This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability."
msgstr ""
"這包括建立、刪除、開啟或關閉配額等相關事件。 沒有傳送webhook來更改結果的可用"
"性。"
#: pretix/api/webhooks.py:419
msgid "Shop taken live"
@@ -650,7 +650,7 @@ msgstr "優惠券已更改"
msgid ""
"Only includes explicit changes to the voucher, not e.g. an increase of the "
"number of redemptions."
msgstr "僅包括對代金券的明確更改,例如不包括兌換次數的增加。"
msgstr ""
#: pretix/api/webhooks.py:460
msgid "Voucher deleted"
@@ -669,16 +669,22 @@ msgid "Customer account anonymized"
msgstr "客戶帳戶已匿名化"
#: pretix/api/webhooks.py:476
#, fuzzy
#| msgid "Gift card code"
msgid "Gift card added"
msgstr "添加了禮品卡"
msgstr "禮品卡代碼"
#: pretix/api/webhooks.py:480
#, fuzzy
#| msgid "Gift card code"
msgid "Gift card modified"
msgstr "禮品卡修改了"
msgstr "禮品卡代碼"
#: pretix/api/webhooks.py:484
#, fuzzy
#| msgid "Gift card transactions"
msgid "Gift card used in transaction"
msgstr "交易中使用的禮品卡"
msgstr "禮品卡交易"
#: pretix/base/addressvalidation.py:100 pretix/base/addressvalidation.py:103
#: pretix/base/addressvalidation.py:108 pretix/base/forms/questions.py:1074

View File

@@ -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/>.
#

View File

@@ -0,0 +1,87 @@
from rest_framework import viewsets
from django.db import transaction
from .styles import PassLayout, AVAILABLE_STYLES_DICT, AVAILABLE_PLATFORMS
from .models import WalletLayout, WalletPlatformLayout
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from .views import get_layout_variables
from rest_framework import serializers
class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
platform = serializers.ChoiceField(choices=[p.identifier for p in AVAILABLE_PLATFORMS])
style = serializers.CharField(allow_null=True, required=False)
class Meta:
model = WalletPlatformLayout
fields = ("platform", "style", "layout")
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"]]
layout = PassLayout(style=style, layout=data["layout"])
context = {"placeholders": get_layout_variables(self.context['event'])}
layout.validate(context=context)
return data
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']:
instance.platform_layouts.update_or_create(platform=layout['platform'], defaults=layout)
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,
)

View File

@@ -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 signals # NOQA

View File

@@ -0,0 +1,98 @@
# Generated by Django 5.2.13 on 2026-05-19 15:39
import django.db.models.deletion
import pretix.base.models.base
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("pretixbase", "0300_alter_customer_locale_alter_user_locale"),
]
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),
),
]

View File

@@ -0,0 +1,67 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.db import models
from django.utils.translation import gettext_lazy as _
from pretix.base.models import LoggedModel
from django_scopes import ScopedManager
from django.core.exceptions import ValidationError
class WalletLayout(LoggedModel):
event = models.ForeignKey(
'pretixbase.Event',
on_delete=models.CASCADE,
related_name='wallet_layouts'
)
name = models.CharField(
max_length=190,
verbose_name=_('Name')
)
objects = ScopedManager(organizer='event__organizer')
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'),)
class WalletLayoutItem(models.Model):
item = models.ForeignKey('pretixbase.Item', null=True, blank=True, related_name='walletlayout_assignments',
on_delete=models.CASCADE)
layout = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name='item_assignments')
class Meta:
unique_together = (('item', 'layout'),)
def clean(self):
if self.item.event != self.layout.event:
raise ValidationError("cannot bind layout to item of different event")

View File

View File

@@ -0,0 +1,35 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from pretix.base.signals import register_ticket_outputs
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"output_{output.identifier}")
connect_signals()

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from "vue";
import StyleSettings from "./style-settings.vue";
import Select from "./input/select.vue";
import Input from "./input/input.vue";
const gettext = (window as any).gettext;
const isLoading = ref<boolean>(true);
const wallet_layout = ref<Layout | null>(null);
const PLATFORMS: Platforms = JSON.parse(
document.querySelector("#platforms")?.textContent ?? "{}",
);
const VARIABLES: VariableConfig = JSON.parse(
document.querySelector("#variables")?.textContent ?? "{}",
);
const LOCALES: Record<string, string> = JSON.parse(
document.querySelector("#locales")?.textContent ?? "{}",
);
const CSRF_TOKEN =
document.querySelector<HTMLInputElement>("input[name=csrfmiddlewaretoken]")
?.value ?? "";
const props = defineProps<{
layoutId: string;
}>();
watchEffect(() => {
// TODO: error handling / proper api client
isLoading.value = true;
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${props.layoutId}/`,
)
.then((x) => x.json())
.then((x) => {
wallet_layout.value = x;
isLoading.value = false;
});
});
function saveLayout(e: SubmitEvent) {
e.preventDefault();
isLoading.value = true;
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${props.layoutId}/`,
{
method: "PUT",
headers: {
"content-type": "application/json",
"X-CSRFToken": CSRF_TOKEN,
},
body: JSON.stringify(wallet_layout.value),
},
)
.then((x) => x.json())
.catch((x) => alert(x))
.then((x) => {
wallet_layout.value = x;
isLoading.value = false;
});
}
const currentPlatform = ref(PLATFORMS[0].identifier);
const currentLayout = computed(() => ({}));
const platformStyles = computed(() => {
for (const platform of PLATFORMS) {
if (platform.identifier === currentPlatform.value) {
return platform.styles
}
}
});
const platformLayout = computed(() => {
for (const layout of wallet_layout.value.platform_layouts) {
if (layout.platform === currentPlatform.value) {
return layout
}
}
const newLayout = {platform: currentPlatform, style: null, layout: {}};
wallet_layout.value.platform_layouts.push(newLayout);
return newLayout
});
const platformChoices = computed(() => {
return [[null, "Do not generate pass"], ...Object.values(platformStyles.value).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="isLoading") {{ gettext("Loading...") }}
form(v-else @submit="saveLayout")
.form-group
Input(label="Name" v-model="wallet_layout.name")
nav
ul.nav.nav-tabs
li(v-for="platform in PLATFORMS" :class="{'active': currentPlatform === platform.identifier}")
a(role="tab" @click="currentPlatform = platform.identifier") {{ platform.name }}
.tabbed-form.tab-content
.tab-pane.active.row
.col-md-8
Select.form-group(label="Style" v-model="platformLayout.style" :choices="platformChoices")
StyleSettings(v-if="platformLayout.style" v-model="platformLayout.layout" :style="platformStyles[platformLayout.style]" :variables="VARIABLES" :locales="LOCALES")
.col-md-4
.panel.panel-default
.panel-heading Preview
.panel-body
// TODO: Preview
pre
code {{ platformLayout }}
pre(v-if="wallet_layout.style")
code {{ platformStyles[wallet_layout.style] }}
pre
code {{ wallet_layout }}
.form-group.submit-group
button.btn.btn-primary.btn-save(type="submit") Submit
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { watchEffect } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
errors?: string[],
locales: 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(props.locales).map((x): [string, string] => [x, oldVal]))
}
})
</script>
<template lang="pug">
input.form-control(v-for="(human_readable, locale) in locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="human_readable")
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useId } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string,
errors?: string[],
}>()
const modelValue = defineModel<string|null>();
const id = useId()
</script>
<template lang="pug">
label.control-label(:for="id", v-if="props.label") {{ props.label }}
input.form-control(:id="id" v-model="modelValue" v-bind="$attrs")
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { useId, watchEffect } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string
choices: Array<[string, string]>
errors?: string[],
class: string
}>()
const modelValue = defineModel<string|null>();
const id = useId()
watchEffect(() => {
if (props.choices.length === 1) {
modelValue.value = props.choices[0][0]
} else if (props.choices.length < 1) {
modelValue.value = null
}
})
</script>
<template lang="pug">
template(v-if="choices.length >= 1" :class="props.class")
label.control-label(v-if="props.label" :for="id") {{ props.label }}
select.form-control(:id="id" v-model="modelValue" v-bind="$attrs" required)
option(v-for="choice in props.choices" :key="choice[0]" :value="choice[0]") {{ choice[1] }}
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, reactive, watchEffect } from "vue";
import Select from "./input/select.vue";
import Input from "./input/input.vue";
import I18nInput from "./input/i18ninput.vue";
import TextContent from "./text-content.vue";
const gettext = (window as any).gettext;
const props = defineProps<{
fieldgroup: PlaceholderFieldGroupDefinition;
overflows: FieldGroupDefinition[];
variables: Variables;
locales: Record<string, string>;
}>();
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))
}
});
</script>
<template lang="pug">
.panel.panel-default
.panel-heading
h3.panel-title {{ fieldgroup.name }}
.panel-body(v-if="fieldConfig")
.form-group()
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
h4 {{ gettext("Content") }}
table.table.table-hover
thead
tr
th.col-md-5(v-if="fieldgroup.labels") {{ gettext('Label') }}
th(:class="'col-md-' + (fieldgroup.labels ? '6' : '11')") {{ gettext('Content') }}
th.col-xs-1
tbody
tr(v-for="n,i in fieldConfig.entries.length" :key="i")
td(v-if="fieldgroup.labels")
.i18n-form-group
I18nInput(v-model="fieldConfig.entries[n-1].label" :locales="locales")
td
TextContent(v-if='fieldgroup.content_type == "text"'
v-model="fieldConfig.entries[n-1]"
:variables="props.variables"
:locales="locales")
Select(v-else-if='fieldgroup.content_type == "image"'
v-model="fieldConfig.entries[n-1].content"
:choices="Object.entries(props.variables).map(([k,v]) => [k, v.label])"
)
td.text-right
button.btn.btn-danger.form-control-static(type="button" @click="fieldConfig.entries.splice(n-1, 1)")
i.fa.fa-trash
span.sr-only {{ gettext('Delete')}}
button.btn.btn-default(type="button" @click="addVariable")
i.fa.fa-plus
| {{ gettext("Add field") }}
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
const gettext = (window as any).gettext;
const props = defineProps<{
fieldgroup: FieldGroupDefinition;
}>();
const fieldConfig = defineModel<PredefinedFieldGroupConfig>({ required: true });
</script>
<template lang="pug">
.panel.panel-default
.panel-heading
h3.panel-title {{ fieldgroup.name }}
.panel-body
.form-group
span.text-muted These fields appear somewhere and are visible too.
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed, watchEffect } from "vue";
import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
import PredefinedFieldSettings from "./predefined-field-settings.vue";
const gettext = (window as any).gettext;
const props = defineProps<{
variables: VariableConfig
style?: Style;
locales: Record<string, string>;
}>();
const layout = defineModel<LayoutData>();
watchEffect(() => {
if (layout.value === undefined) {
return
}
if (layout.value.fieldgroups === undefined) {
layout.value.fieldgroups = {};
}
});
</script>
<template lang="pug">
h2.h3 {{ gettext("Field Groups") }}
template(v-if="props.style && layout.fieldgroups"
v-for="(fieldgroup, fieldgroupId) in props.style.fieldgroups")
PlaceholderFieldSettings(
v-if="fieldgroup.type == 'placeholder'"
v-model="layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup"
:overflows="props.style.fieldgroups.slice(fieldgroupId + 1).filter(x => x.type == 'placeholder' && x.content_type === fieldgroup.content_type)"
:variables="variables[fieldgroup.content_type]"
:locales="locales"
)
PredefinedFieldSettings(v-else-if="fieldgroup.type == 'predefined'"
v-model="layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup")
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, reactive } from 'vue'
import Select from './input/select.vue'
import I18nInput from './input/i18ninput.vue'
const gettext = (window as any).gettext
const props = defineProps<{
variables: Variables
locales: Record<string, string>;
}>()
const entry = defineModel<FieldEntry>({ required: true })
const selectChoices = computed(() =>{
const choices = Object.entries(props.variables).map(([k,v]): [string, string] => [k, v.label])
choices.push(["other", gettext("Other…")])
return choices
});
const selection = computed({
get() {
if (entry.value.type === 'placeholder') {
return entry.value.content
} else if (entry.value.type === 'custom') {
return "other"
} else {
throw new Error(`Unknown entry type "${entry.value.type}"`);
}
},
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
} else {
throw new Error(`Unknown entry type "${entry.value.type}"`);
}
},
set(newValue) {
entry.value.content = newValue
}
})
</script>
<template lang="pug">
.i18n-form-group
Select(
v-model="selection"
:choices="selectChoices"
)
I18nInput(v-model="textContent" v-if="selection === 'other'" :locales="locales")
</template>

View File

@@ -0,0 +1,81 @@
type BaseFieldGroupDefinition = {
type: string;
identifier: string;
name: string;
required: boolean;
}
type FieldGroupDefinition = PlaceholderFieldGroupDefinition | PredefinedFieldGroupDefinition;
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
type: 'placeholder';
content_type: FieldContentType;
default_entries: FieldEntry[];
labels: boolean;
min_entries: number|null;
max_entries: number|null;
}
type PredefinedFieldGroupDefinition = BaseFieldGroupDefinition & {
type: 'predefined';
}
type I18nString = string | Record<string, string>
type FieldContentType = 'text' | 'image';
type PlaceholderFieldEntry = {
type: 'placeholder';
label?: I18nString;
content?: string;
}
type ContentFieldEntry = {
type: FieldContentType;
label?: I18nString;
content?: I18nString;
}
type FieldEntry = PlaceholderFieldEntry | ContentFieldEntry;
type Style = {
identifier: string;
name: string;
fieldgroups: FieldGroupDefinition[];
};
type Variable = {
label: string
};
type Platform = {
identifier: string;
name: string;
styles: Styles;
};
type Styles = Record<string, Style>;
type Variables = Record<string, Variable>;
type VariableConfig = Record<string, Variables>;
type Platforms = Record<string, Platform>;
type PlaceholderFieldGroupConfig = {
entries: Array<FieldEntry>;
overflow: string | null;
};
type PredefinedFieldGroupConfig = {};
type FieldGroupConfig = PlaceholderFieldGroupConfig | PredefinedFieldGroupConfig;
type LayoutData = {
fieldgroups: Record<string, FieldGroupConfig>;
};
type Layout = {
name?: string;
style?: string;
layout?: LayoutData;
};

View File

@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import App from './components/app.vue'
const mountEl = document.querySelector<HTMLElement>('#editor')!
const app = createApp(App, mountEl.dataset)
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)
}

View File

@@ -0,0 +1,18 @@
from .apple import ApplePlatform, AppleWalletEventTicket
from .google import GooglePlatform, GoogleWalletEventTicket
from .base import PassLayout
AVAILABLE_PLATFORMS = [ApplePlatform, GooglePlatform]
AVAILABLE_STYLES = {
"apple": [AppleWalletEventTicket()],
"google": [
GoogleWalletEventTicket()
],
}
AVAILABLE_STYLES_DICT = {
plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items()
}
__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassLayout"]

View File

@@ -0,0 +1,245 @@
from .base import (
FieldEntryType,
ImageFieldGroup,
PlaceholderFieldGroup,
PredefinedFieldGroup,
TextFieldGroup,
WalletPlatform,
PassStyle,
PlaceholderFieldEntry,
)
from django.utils.translation import gettext as _
from i18nfield.strings import LazyI18nString
import io
import hashlib
import zipfile
import cryptography
import cryptography.hazmat.primitives.serialization.pkcs7
import json
from django.contrib.staticfiles import finders
class ApplePlatform(WalletPlatform):
identifier = "apple"
name = _("Apple")
class StringResource:
# mapping string in default event locale -> LazyI18nString
entries: dict[str, LazyI18nString]
locales: set[str]
def __init__(self, locales):
self.entries = {}
self.locales = set(locales)
def add_entry(self, key: str, value: LazyI18nString):
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, certificate, key, password):
self.ca_certificate = cryptography.x509.load_pem_x509_certificate(
ca_certificate
)
self.certificate = cryptography.x509.load_pem_x509_certificate(certificate)
self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
key, password
)
self.password = password
self.file = io.BytesIO()
self.zip_file = zipfile.ZipFile(self.file, "w")
self.manifest = {}
def sign(self, data: bytes):
return (
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7SignatureBuilder()
.set_data(data)
.add_signer(
self.certificate,
self.key,
cryptography.hazmat.primitives.hashes.SHA256(),
)
.add_certificate(self.ca_certificate)
.sign(
cryptography.hazmat.primitives.serialization.Encoding.DER,
[
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.Binary,
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.DetachedSignature,
],
)
)
def finish(self):
manifest = json.dumps(self.manifest).encode()
signature = self.sign(manifest)
self.add_file("manifest.json", manifest)
self.add_file("signature", signature)
self.zip_file.close()
return self.file.getvalue()
def add_file(self, filename: str, content: str | bytes):
if isinstance(content, str):
content = content.encode()
with self.zip_file.open(filename, "w") as f:
f.write(content)
self.manifest[filename] = hashlib.sha1(content).hexdigest()
class AppleWalletStyle(PassStyle):
platform = ApplePlatform
def pass_content(self, fields, strings):
raise NotImplementedError()
def generate_pass_json(self, fields, context, strings):
def add_from_context(key):
value = context.get(key)
if not value:
raise ValueError(f"{key} must be set to a truthy value")
return value
pass_json = {
"formatVersion": 1,
"description": add_from_context("description"),
"organizationName": add_from_context("organizationName"),
"passTypeIdentifier": add_from_context("passTypeIdentifier"),
"teamIdentifier": add_from_context("teamIdentifier"),
"serialNumber": add_from_context("serialNumber"),
**self.pass_content(fields, strings),
}
return pass_json
def generate(self, layout, context):
for key in ["ca_certificate", "certificate", "key", "password", "locales"]:
if key not in context:
raise ValueError(f"{key} missing from context")
fields = self.get_pass_fields(layout, context)
pkpass = SignedZipFile(
context["ca_certificate"],
context["certificate"],
context["key"],
context["password"],
)
strings = StringResource(locales=context['locales'])
pass_json = self.generate_pass_json(fields, context, strings)
print(pass_json)
if fields['logo']:
logo = fields['logo'][0]['value']
else:
logo = open(finders.find("pretix_passbook/logo.png"), "rb")
if fields['icon']:
icon = fields['icon'][0]['value']
else:
icon = open(finders.find("pretix_passbook/icon.png"), "rb")
pkpass.add_file("icon.png", icon.read())
pkpass.add_file("logo.png", logo.read())
for lang, content in strings.generate().items():
pkpass.add_file(f"{lang}.lproj/pass.strings", content)
pkpass.add_file("pass.json", json.dumps(pass_json))
return pkpass.finish()
class AppleWalletEventTicket(AppleWalletStyle):
identifier = "event_1"
name = _("Event Ticket Layout 1")
fieldgroups = [
ImageFieldGroup(
identifier="icon",
name=_("Icon"),
min_entries=0,
max_entries=1,
labels=False,
default_entries=[
PlaceholderFieldEntry(
content="poweredby",
)
],
),
ImageFieldGroup(
identifier="logo",
name=_("Logo"),
min_entries=0,
max_entries=1,
labels=False,
default_entries=[
PlaceholderFieldEntry(
content="poweredby",
)
],
),
TextFieldGroup(
identifier="primary",
name=_("Primary"),
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderFieldEntry(
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
content="item",
)
], # TODO: support Lazyi18nproxy here
description=_("These fields appear prominently featured on the pass."),
),
TextFieldGroup(
identifier="secondary", name=_("Secondary"), max_entries=4
), # 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="headers", name=_("Header"), max_entries=3
), # TODO: header image
TextFieldGroup(identifier="auxillary", name=_("Auxillary"), max_entries=4),
TextFieldGroup(identifier="back", name=_("Back")),
]
# preview_image = "apple/event_ticket.svg"
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):
return {
"eventTicket": {
"primaryFields": self.convert_fields(strings, fields['primary'], 'primary'),
"secondaryFields": self.convert_fields(strings, fields['secondary'], 'secondary'),
"auxillaryFields": self.convert_fields(strings, fields['auxillary'], 'auxillary'),
"backFields": self.convert_fields(strings, fields['back'], 'back'),
}
}

View File

@@ -0,0 +1,346 @@
import enum
from i18nfield.strings import LazyI18nString
import jsonschema
from django.core.exceptions import ValidationError
class WalletPlatform:
identifier: str
name: str
class FieldGroupType(enum.Enum):
PLACEHOLDER = "placeholder"
PREDEFINED = "predefined"
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: dict,
) -> dict:
raise NotImplemented()
def asdict(self):
return {
"type": self.type.value,
"identifier": self.identifier,
"name": self.name,
"description": self.description,
"required": self.required,
}
class FieldContentType(enum.Enum):
IMAGE = "image"
TEXT = "text"
class FieldEntryType(enum.Enum):
CUSTOM = "custom"
PLACEHOLDER = "placeholder"
class FieldEntry[T]:
type: FieldEntryType
label: LazyI18nString | None
content: T
def __init__(
self, type: FieldEntryType, content: T, label: LazyI18nString | None = None
):
self.type = type
self.label = label
self.content = content
def asdict(self) -> dict:
return {"type": self.type.value, "content": self.content, "label": self.label.data if self.label else None}
class PlaceholderFieldEntry(FieldEntry[str]):
type = FieldEntryType.PLACEHOLDER
label: LazyI18nString | None
content: str
def __init__(
self, content: str, label: LazyI18nString | None = None
):
self.label = label
self.content = content
class CustomFieldEntry(FieldEntry[LazyI18nString]):
type: FieldEntryType
label: LazyI18nString | None
content: LazyI18nString
def asdict(self) -> dict:
return {"type": self.type.value, "content": self.content.data, "label": self.label.data if self.label else None}
class PredefinedFieldGroup(FieldGroup):
type = FieldGroupType.PREDEFINED
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: dict,
):
return {
"type": "object"
}
class PlaceholderFieldGroup(FieldGroup):
type = FieldGroupType.PLACEHOLDER
content_type: FieldContentType
default_entries: list[FieldEntry]
labels: bool
min_entries: int | None
max_entries: int | None
def __init__(
self,
identifier: str,
name: str,
content_type: FieldContentType,
description: str=None,
required=False,
default_entries=None,
min_entries=None,
max_entries=None,
labels=True,
):
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.labels = labels
if self.required and (self.min_entries is None or self.min_entries < 1):
self.min_entries = 1
def asdict(self):
return {
**super().asdict(),
"content_type": self.content_type.value,
"default_entries": [x.asdict() for x in self.default_entries],
"labels": self.labels,
"min_entries": self.min_entries,
"max_entries": self.max_entries,
}
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: dict,
):
placeholders = list(context.get("placeholders", {}).get(self.content_type.value, {}).keys())
return {
"type": "object",
"properties": {
"entries": self.entries_schema(placeholders=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.labels:
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.labels:
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
def __init__(self, **kwargs):
super().__init__(content_type=self.content_type, **kwargs)
class PassStyle:
platform: type[WalletPlatform]
identifier: str # unique within platform
name: str
# order here limits in what order users can configure field "overspilling" (if too many fields are defined, where should the rest go) -> can only go down in the list
# we evaluate the fields in this order, so they overspill in this order as well (fields from primary are appended to the overspilling field before fields from secondary are etc)
fieldgroups: list[FieldGroup]
def asdict(self):
return {
"platform": self.platform.identifier,
"identifier": self.identifier,
"name": self.name,
"fieldgroups": [x.asdict() for x in self.fieldgroups],
}
def layout_schema(self, context):
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
],
}
},
"$defs": {
"I18nString": {
"oneOf": [
{"type": "string"},
{"type": "object", "additionalProperties": {"type": "string"}},
]
}
},
}
if any(group.required for group in self.fieldgroups):
schema["required"] = ["fieldgroups"]
return schema
def generate(self, layout, context):
raise NotImplementedError()
def render_placeholder(self, context, content_type, content):
placeholder = (
context.get("placeholders")
.get(content_type, {})
.get(content)
)
if placeholder:
placeholder_value = placeholder["evaluate"](
*context.get("evaluation_context", [])
)
if placeholder_value:
return placeholder["label"], placeholder_value
return None, None
def get_pass_fields(self, layout, context):
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 layout["fieldgroups"]:
for field in layout["fieldgroups"][group.identifier]["entries"]:
field_entry = {}
if group.labels:
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.labels 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 := 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
class PassLayout:
style: PassStyle
layout: dict
def __init__(self, style, layout):
self.style = style
self.layout = layout
def validate(self, context):
schema = self.style.layout_schema(context)
try:
jsonschema.validate(self.layout, schema)
except jsonschema.ValidationError as e:
raise ValidationError("Invalid layout: {}".format(str(e)))
def generate(self, context):
# TODO: how to handle nonexisting placeholders here?
self.validate(context)
return self.style.generate(self.layout, context)

View File

@@ -0,0 +1,20 @@
from .base import PassStyle, PredefinedFieldGroup, TextFieldGroup, WalletPlatform
from django.utils.translation import gettext_lazy as _
class GooglePlatform(WalletPlatform):
identifier = "google"
name = _("Google")
class GoogleWalletStyle(PassStyle):
platform = GooglePlatform
class GoogleWalletEventTicket(PassStyle):
identifier = "event"
name = "Event Ticket"
platform = GooglePlatform
fieldgroups = [
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
TextFieldGroup(identifier="qrcode", name=_("QR-Code"), labels=False),
]

View File

@@ -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 %}

View File

@@ -0,0 +1,21 @@
{% 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 %}

View File

@@ -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 layouts|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 layouts %}
<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 %}

View File

@@ -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>

View File

@@ -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)

View File

@@ -0,0 +1,136 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
from django.utils.translation import gettext_lazy as _
from pretix.base.ticketoutput import BaseTicketOutput
from pretix.base.models import Event
from pretix.base.settings import SettingsSandbox
from django.template.loader import render_to_string
from .styles import AVAILABLE_STYLES_DICT
from .styles.apple import ApplePlatform
from .styles.google import GooglePlatform
from .models import WalletLayout
from .views import get_layout_variables
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 = []
def __init__(self, event: Event):
super().__init__(event)
self.settings = SettingsSandbox(
"ticketoutput", WalletSettingsHolder.identifier, event
)
class GoogleWalletTicketOutput(WalletOutput):
identifier = "wallet_google"
verbose_name = _("Google")
download_button_text = "Add to Google Wallet"
platform = GooglePlatform
class AppleWalletTicketOutput(WalletOutput):
identifier = "wallet_apple"
verbose_name = _("Apple")
download_button_text = "Add to Apple Wallet"
platform = ApplePlatform
def generate(self, op):
order = op.order
event = order.event
filename = "{}-{}.pkpass".format(order.event.slug, order.code)
# layout = self.override_layout_signal.send_chained(
# order.event, 'layout', orderposition=op, layout=self.layout_map.get(
# (op.item_id, self.override_channel or order.sales_channel.identifier),
# self.layout_map.get(
# (op.item_id, 'web'),
# self.default_layout
# )
# )
# )
layout = WalletLayout.objects.get(pk=1)
platform_layout = layout.platform_layouts.get(platform=self.platform.identifier)
ticket = str(op.item.name)
if op.variation:
ticket += " - " + str(op.variation)
serialNumber = "%s-%s-%s-%d" % (
order.event.organizer.slug,
order.event.slug,
order.code,
op.pk,
)
context = {
"placeholders": get_layout_variables(op.order.event),
"evaluation_context": [op, order, order.event],
"ca_certificate": open(
"/Users/engelhardt/code/tmp/wallet/apple/ca_cert.pem", "rb"
).read(),
"certificate": open(
"/Users/engelhardt/code/tmp/wallet/apple/cert.pem", "rb"
).read(),
"key": open(
"/Users/engelhardt/code/tmp/wallet/apple/secret_key.pem", "rb"
).read(),
"password": None,
"description": _("Ticket for {event} ({product})").format( # TODO: i18n
event=event.name, product=ticket
),
"organizationName": event.organizer.name,
"passTypeIdentifier": "pass.test.test",
"teamIdentifier": "TEST123456",
"serialNumber": serialNumber,
"locales": event.settings.locales
}
data = AVAILABLE_STYLES_DICT[self.platform.identifier][platform_layout.style].generate(
platform_layout.layout, context
)
return filename, "application/vnd.apple.pkpass", data
OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput]

View File

@@ -0,0 +1,45 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.urls import re_path
from pretix.api.urls import event_router
from .views import (
LayoutEditorView,
LayoutCreateView,
LayoutListView
)
from .api import WalletLayoutViewSet
urlpatterns = [
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/$',
LayoutListView.as_view(), name='index'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/add/$',
LayoutCreateView.as_view(), name='add'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<layout>[^/]+)/$',
LayoutEditorView.as_view(), name='edit'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/default/(?P<layout>[^/]+)/$', # TODO
LayoutEditorView.as_view(), name='default'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/delete/(?P<layout>[^/]+)/$', # TODO
LayoutEditorView.as_view(), name='delete'),
]
event_router.register('walletlayouts', WalletLayoutViewSet)

View File

@@ -0,0 +1,106 @@
import json
from typing import Any
from django import forms
from django.http import Http404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, ListView
from pretix.base.pdf import get_images, get_variables
from pretix.control.permissions import EventPermissionRequiredMixin
from django.conf import settings
from .models import WalletLayout
from .styles import AVAILABLE_STYLES, AVAILABLE_PLATFORMS
from django.contrib.staticfiles import finders
def get_layout_variables(event):
return {
"text": get_variables(event),
"image": get_images(event)
| {"poweredby": {"label": _("pretix-Logo"), "evaluate": lambda *_: open(finders.find("pretix_passbook/logo.png"), "rb")},
"poweredby_icon": {"label": _("pretix-Icon"), "evaluate": lambda *_: open(finders.find("pretix_passbook/icon.png"), "rb")}}, # TODO: image upload
}
def get_editor_variables(event):
return {
t: {
vid: {"label": v.get("label"), "editor_sample": v.get("editor_sample")}
for vid, v in vs.items()
}
for t, vs in get_layout_variables(event).items()
}
class LayoutListView(EventPermissionRequiredMixin, ListView):
model = WalletLayout
permission = "can_change_event_settings"
template_name = "pretixplugins/wallet/layout_list.html"
context_object_name = "layouts"
def get_queryset(self):
return self.request.event.wallet_layouts.all()
class LayoutEditorView(DetailView):
template_name = "pretixplugins/wallet/edit.html"
model = WalletLayout
permission = "event.settings.general:write"
pk_url_kwarg = "layout"
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.asdict() for style in AVAILABLE_STYLES.get(platform.identifier)
}
} for platform in AVAILABLE_PLATFORMS
]
# context["styles"] = {
# style.identifier: style.asdict() for style in self.get_platform_styles()
# }
context["variables"] = get_editor_variables(self.request.event)
context["locales"] = {
l: dict(settings.LANGUAGES).get(l, l)
for l in self.request.event.settings.get("locales")
}
return context
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(CreateView):
template_name = "pretixplugins/wallet/create.html"
form_class = WalletLayoutCreateForm
permission = "event.settings.general:write"
def get_form_kwargs(self) -> dict[str, Any]:
kwargs = super().get_form_kwargs()
kwargs["event"] = self.request.event
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,
},
)

View File

@@ -705,7 +705,7 @@ if config.has_option('sentry', 'dsn') and not any(c in sys.argv for c in ('shell
from sentry_sdk.integrations.logging import (
LoggingIntegration, ignore_logger,
)
from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber
from sentry_sdk.scrubber import EventScrubber, DEFAULT_DENYLIST
from .sentry import PretixSentryIntegration, setup_custom_filters
@@ -901,8 +901,8 @@ if DEBUG:
# Reload if settings file changes
config_files_to_watch = [Path(x).absolute() for x in config_files]
from django.utils.autoreload import autoreload_started, BaseReloader
from django.dispatch import receiver
from django.utils.autoreload import BaseReloader, autoreload_started
@receiver(autoreload_started, dispatch_uid="pretix_watch_config_file")
def watch_config_file(sender: BaseReloader, *args, **kwargs):

View File

@@ -639,13 +639,11 @@ var form_handlers = function (el) {
).append(" ").append($("<div>").text(res.organizer).html())
);
}
if (res.date_range) {
$ret.append(
$("<span>").addClass("event-daterange").append(
$("<span>").addClass("fa fa-calendar fa-fw")
).append(" ").append(res.date_range)
);
}
$ret.append(
$("<span>").addClass("event-daterange").append(
$("<span>").addClass("fa fa-calendar fa-fw")
).append(" ").append(res.date_range)
);
return $ret;
},
}).on("select2:select", function () {

View File

@@ -0,0 +1,186 @@
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
breakpoint()

View File

@@ -0,0 +1,336 @@
from pretix.plugins.wallet.styles.base import (
PassStyle,
PredefinedFieldGroup,
WalletPlatform,
PlaceholderFieldGroup,
FieldContentType,
PassLayout,
FieldGroupType,
FieldEntryType,
)
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
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,
labels=False,
),
PlaceholderFieldGroup(
identifier="image1",
name=_("Image 1"),
content_type=FieldContentType.IMAGE,
required=False,
labels=False,
),
]
def generate(self, layout, context):
output = f"Generated Pass: {self.name}\n\n"
for group in self.fieldgroups:
if group.identifier in layout["fieldgroups"]:
output += f"Group: {group.name}\n"
if isinstance(group, PredefinedFieldGroup):
output += "PREDEFINED\n"
elif isinstance(group, PlaceholderFieldGroup):
for field in layout["fieldgroups"][group.identifier]["entries"]:
if group.labels:
label = LazyI18nString(field["label"])
output += f"{label}: "
if field["type"] == FieldEntryType.PLACEHOLDER.value:
placeholder = (
context.get("placeholders")
.get(group.content_type.value, {})
.get(field["content"])
)
if placeholder:
output += placeholder["evaluate"](
*context.get("evaluation_context", [])
)
else:
output += f"UNKNOWN: {field['content']}"
elif field["type"] == FieldEntryType.TEXT.value:
output += str(LazyI18nString(field["content"]))
elif field["type"] == FieldEntryType.IMAGE.value:
output += f"<IMG>{field['content']}</IMG>"
output += "\n"
else:
raise ValueError("Unknown field group")
output += "\n"
return output
@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",
}
}
}
pass_layout = PassLayout(style, 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"
)