mirror of
https://github.com/pretix/pretix.git
synced 2026-06-10 01:15:05 +00:00
Compare commits
15 Commits
pajowu/fon
...
pajowu/wal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c8637ad0 | ||
|
|
b849a4b840 | ||
|
|
ac515f6774 | ||
|
|
7d88b40fa7 | ||
|
|
2ca91ee35d | ||
|
|
7c6876a653 | ||
|
|
77708cdde5 | ||
|
|
280c314aed | ||
|
|
ec5695e866 | ||
|
|
a2bca9c887 | ||
|
|
16bb64813b | ||
|
|
e828bd0384 | ||
|
|
b354b54de0 | ||
|
|
53fa2504bf | ||
|
|
d963172ecb |
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,6 +66,7 @@ INSTALLED_APPS = [
|
||||
'pretix.plugins.returnurl',
|
||||
'pretix.plugins.autocheckin',
|
||||
'pretix.plugins.webcheckin',
|
||||
'pretix.plugins.wallet',
|
||||
'django_countries',
|
||||
'oauth2_provider',
|
||||
'phonenumber_field',
|
||||
|
||||
@@ -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("-", "- ")
|
||||
|
||||
# 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   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("-", "- ")
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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``
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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é "
|
||||
"l’argent 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
21
src/pretix/plugins/wallet/__init__.py
Normal file
21
src/pretix/plugins/wallet/__init__.py
Normal 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/>.
|
||||
#
|
||||
87
src/pretix/plugins/wallet/api.py
Normal file
87
src/pretix/plugins/wallet/api.py
Normal 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,
|
||||
)
|
||||
41
src/pretix/plugins/wallet/apps.py
Normal file
41
src/pretix/plugins/wallet/apps.py
Normal 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
|
||||
|
||||
98
src/pretix/plugins/wallet/migrations/0001_initial.py
Normal file
98
src/pretix/plugins/wallet/migrations/0001_initial.py
Normal 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),
|
||||
),
|
||||
]
|
||||
0
src/pretix/plugins/wallet/migrations/__init__.py
Normal file
0
src/pretix/plugins/wallet/migrations/__init__.py
Normal file
67
src/pretix/plugins/wallet/models.py
Normal file
67
src/pretix/plugins/wallet/models.py
Normal 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")
|
||||
0
src/pretix/plugins/wallet/serializer.py
Normal file
0
src/pretix/plugins/wallet/serializer.py
Normal file
35
src/pretix/plugins/wallet/signals.py
Normal file
35
src/pretix/plugins/wallet/signals.py
Normal 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()
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
81
src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts
vendored
Normal file
81
src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts
vendored
Normal 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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
18
src/pretix/plugins/wallet/styles/__init__.py
Normal file
18
src/pretix/plugins/wallet/styles/__init__.py
Normal 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"]
|
||||
245
src/pretix/plugins/wallet/styles/apple.py
Normal file
245
src/pretix/plugins/wallet/styles/apple.py
Normal 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'),
|
||||
}
|
||||
}
|
||||
346
src/pretix/plugins/wallet/styles/base.py
Normal file
346
src/pretix/plugins/wallet/styles/base.py
Normal 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)
|
||||
20
src/pretix/plugins/wallet/styles/google.py
Normal file
20
src/pretix/plugins/wallet/styles/google.py
Normal 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),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load bootstrap3 %}
|
||||
{% load vite %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
|
||||
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "New layout" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form layout="control" %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{% trans "Ticket design" %}
|
||||
</label>
|
||||
<div class="col-md-9 form-control-static">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can modify the design after you saved this page.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
10
src/pretix/plugins/wallet/templatetags/wallet.py
Normal file
10
src/pretix/plugins/wallet/templatetags/wallet.py
Normal 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)
|
||||
136
src/pretix/plugins/wallet/ticketoutput.py
Normal file
136
src/pretix/plugins/wallet/ticketoutput.py
Normal 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]
|
||||
45
src/pretix/plugins/wallet/urls.py
Normal file
45
src/pretix/plugins/wallet/urls.py
Normal 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)
|
||||
106
src/pretix/plugins/wallet/views.py
Normal file
106
src/pretix/plugins/wallet/views.py
Normal 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,
|
||||
},
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
186
src/tests/plugins/wallet/test_apple.py
Normal file
186
src/tests/plugins/wallet/test_apple.py
Normal 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()
|
||||
336
src/tests/plugins/wallet/test_wallet.py
Normal file
336
src/tests/plugins/wallet/test_wallet.py
Normal 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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user