diff --git a/src/pretix/plugins/wallet/api.py b/src/pretix/plugins/wallet/api.py index 78b68be75..568970f3c 100644 --- a/src/pretix/plugins/wallet/api.py +++ b/src/pretix/plugins/wallet/api.py @@ -1,65 +1,83 @@ from rest_framework import viewsets from django.db import transaction -from .styles import PassLayout, get_platform_styles, get_platforms +from .styles import PassLayout, AVAILABLE_STYLES_DICT from .models import WalletLayout from pretix.api.serializers.i18n import I18nAwareModelSerializer import django_filters.rest_framework from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ +from .views import get_layout_variables + class WalletLayoutSerializer(I18nAwareModelSerializer): class Meta: model = WalletLayout - fields = ("event","platform","name","style","layout") - read_only_fields = ("event", "platform") + fields = ("id", "platform", "name", "style", "layout") + read_only_fields = ("id",) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance: + self.fields['platform'].read_only = True + + def save(self, *args, **kwargs): + super().save(*args, **kwargs, event=self.context["event"]) + + def validate_platform(self, value): + if self.instance and value != self.instance.platform: + raise ValidationError(_("Platform cannot be changed")) + + if value not in AVAILABLE_STYLES_DICT: + raise ValidationError(_("Invalid platform")) + return value def validate_layout(self, value): if not isinstance(value, dict): raise ValidationError(_("Layout must be a dict")) return value - - - def validate_platform(self, value): - if value not in get_platforms(): - raise ValidationError(_("Invalid Platform")) - return value def validate(self, data): - if "style" in data and "layout" in data and "platform" in data: - platform_styles = get_platform_styles(data['platform']) - if data['style'] not in platform_styles: + if self.instance: + platform = self.instance.platform + else: + platform = data.get('platform', None) + if "style" in data and "layout" in data and platform: + platform_styles = AVAILABLE_STYLES_DICT[platform] + + if data["style"] not in platform_styles: raise ValidationError(_("Invalid style")) - style = get_platform_styles(data['platform'])[data['style']] + style = platform_styles[data["style"]] - layout = PassLayout( - style=style, layout=data["layout"] - ) - breakpoint() - layout.validate(data['event']) + layout = PassLayout(style=style, layout=data["layout"]) + context = {"placeholders": {k: list(v.keys()) for k,v in get_layout_variables(self.context['event']).items()}} + layout.validate(context=context) return data - class WalletLayoutViewSet(viewsets.ModelViewSet): model = WalletLayout queryset = WalletLayout.objects.none() serializer_class = WalletLayoutSerializer filter_backends = (django_filters.rest_framework.DjangoFilterBackend,) - filterset_fields = ['platform'] + filterset_fields = ["platform"] permission = "event.settings.general:write" def get_queryset(self): return self.request.event.wallet_layouts.all() - + def get_serializer(self, *args, **kwargs): return super().get_serializer(*args, **kwargs) - + + 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', + action="pretix.plugins.wallet.layout.changed", user=self.request.user, auth=self.request.auth, data=self.request.data, diff --git a/src/pretix/plugins/wallet/models.py b/src/pretix/plugins/wallet/models.py index 45cceb01e..50108582c 100644 --- a/src/pretix/plugins/wallet/models.py +++ b/src/pretix/plugins/wallet/models.py @@ -23,6 +23,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from pretix.base.models import LoggedModel +from django_scopes import ScopedManager class WalletLayout(LoggedModel): @@ -37,11 +38,26 @@ class WalletLayout(LoggedModel): ) platform = models.CharField(max_length=10) style = models.CharField(max_length=255) - layout = models.JSONField(default={}) + layout = models.JSONField(default=dict) + + objects = ScopedManager(organizer='event__organizer') class Meta: ordering = ("name",) def __str__(self): return self.name - # TODO:ScopedManager + + +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') + sales_channel = models.ForeignKey( + "pretixbase.SalesChannel", + on_delete=models.CASCADE, + ) + + class Meta: + unique_together = (('item', 'layout', 'sales_channel'),) + ordering = ("id",) diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/app.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/app.vue index e69456ea7..2b4117abd 100644 --- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/app.vue +++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/app.vue @@ -10,52 +10,55 @@ const isLoading = ref(true); const wallet_layout = ref(null); const STYLES: Styles = JSON.parse( - document.querySelector("#styles")?.textContent ?? "{}", + document.querySelector("#styles")?.textContent ?? "{}", ); const VARIABLES: VariableConfig = JSON.parse( - document.querySelector("#variables")?.textContent ?? "{}", + document.querySelector("#variables")?.textContent ?? "{}", +); +const LOCALES: Record = JSON.parse( + document.querySelector("#locales")?.textContent ?? "{}", ); const CSRF_TOKEN = - document.querySelector("input[name=csrfmiddlewaretoken]") - ?.value ?? ""; + document.querySelector("input[name=csrfmiddlewaretoken]") + ?.value ?? ""; const props = defineProps<{ - layoutId: string; + 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; - }); + // 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()) - .then((x) => { - wallet_layout.value = x; - isLoading.value = false; - }); + 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()) + .then((x) => { + wallet_layout.value = x; + isLoading.value = false; + }); } @@ -73,7 +76,7 @@ function saveLayout(e: SubmitEvent) { .form-group() Select(label="Style" v-model="wallet_layout.style" :choices="Object.values(STYLES).map(x => [x.identifier, x.name])") - StyleSettings(v-if="wallet_layout.style" v-model="wallet_layout.layout" :style="STYLES[wallet_layout.style]" :variables="VARIABLES") + StyleSettings(v-if="wallet_layout.style" v-model="wallet_layout.layout" :style="STYLES[wallet_layout.style]" :variables="VARIABLES" :locales="LOCALES") .col-md-4 .panel.panel-default .panel-heading Preview @@ -81,6 +84,8 @@ function saveLayout(e: SubmitEvent) { // TODO: Preview pre code {{ wallet_layout }} + pre(v-if="wallet_layout.style") + code {{ STYLES[wallet_layout.style] }} .form-group.submit-group button.btn.btn-primary.btn-save(type="submit") Submit diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/field-settings.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/field-settings.vue deleted file mode 100644 index 37b60fdf7..000000000 --- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/field-settings.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/i18ninput.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/i18ninput.vue new file mode 100644 index 000000000..cc98f6c00 --- /dev/null +++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/i18ninput.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/select.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/select.vue index 52d365618..be41e492d 100644 --- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/select.vue +++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/select.vue @@ -25,7 +25,7 @@ watchEffect(() => { diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/placeholder-field-settings.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/placeholder-field-settings.vue new file mode 100644 index 000000000..4b0c3de47 --- /dev/null +++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/placeholder-field-settings.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/predefined-field-settings.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/predefined-field-settings.vue new file mode 100644 index 000000000..9621aaf1d --- /dev/null +++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/predefined-field-settings.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/style-settings.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/style-settings.vue index 333a99b4c..e5cd93789 100644 --- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/style-settings.vue +++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/style-settings.vue @@ -1,44 +1,41 @@ diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts b/src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts index 7bb02057d..4bf28557c 100644 --- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts +++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts @@ -1,14 +1,41 @@ -type FieldGroupDefinition = { +type BaseFieldGroupDefinition = { + type: string; identifier: string; - entry_type: string; name: string; - default_entries: FieldConfig[]; -}; + required: boolean; +} + +type FieldGroupDefinition = PlaceholderFieldGroupDefinition | PredefinedFieldGroup; + +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 + +type FieldContentType = 'text' | 'image'; + +type FieldEntry = { + type: 'placeholder' | FieldContentType; + label?: I18nString; + content?: string; +} + + type Style = { identifier: string; name: string; - fields: FieldGroupDefinition[]; + fieldgroups: FieldGroupDefinition[]; }; type Variable = { @@ -19,21 +46,19 @@ type Styles = Record; type Variables = Record; type VariableConfig = Record; -type I18nString = string | Record -type FieldEntry = { - type: 'placeholder' | 'text'; - label?: I18nString; // TODO i18n - content?: string; -} -type FieldConfig = { +type PlaceholderFieldGroupConfig = { entries: Array; overflow: string | null; }; +type PredefinedFieldGroupConfig = {}; + +type FieldGroupConfig = PlaceholderFieldGroupConfig | PredefinedFieldGroupConfig; + type LayoutData = { - fields?: Record; + fieldgroups: Record; }; type Layout = { @@ -41,3 +66,4 @@ type Layout = { style?: string; layout?: LayoutData; }; + diff --git a/src/pretix/plugins/wallet/styles.py b/src/pretix/plugins/wallet/styles.py deleted file mode 100644 index 6e0bac9d5..000000000 --- a/src/pretix/plugins/wallet/styles.py +++ /dev/null @@ -1,218 +0,0 @@ -from dataclasses import dataclass, field, asdict -from typing import Literal -import enum -from django.utils.translation import gettext_lazy as _ -from django.core.exceptions import ValidationError -from i18nfield.strings import LazyI18nString - -from pretix.base.pdf import get_images, get_variables -from .models import WalletLayout - - -class WalletPlatform: - identifier: str - name: str - - -class ApplePlatform(WalletPlatform): - identifier = "apple" - name = _("Apple") - - -class GooglePlatform(WalletPlatform): - identifier = "google" - name = _("Google") - - -class FieldType(enum.Enum): - TEXT = "text" - CODE = "qr" - IMAGE = "image" - PREDEFINED = "predefined" - # TODO: POWERED_BY ? - -class BaseField: - type: str - label: LazyI18nString - content: str - - def __init__(self, label: LazyI18nString, content: str): - self.label = label - self.content = content - - def asdict(self): - return {"type": self.type, "label": self.label.data, "content": self.content} - -class PlaceholderField(BaseField): - type = "placeholder" - -class TextField(BaseField): - type = "text" - -@dataclass -class FieldGroupDefinition: - name: str - identifier: str - entry_type: FieldType - min_entries: int | None = None - max_entries: int | None = None - - def asdict(self): - return { - "identifier": self.identifier, - "name": self.name, - "entry_type": self.entry_type.value, - "min_entries": self.min_entries, - "max_entries": self.max_entries, - } - - -@dataclass -class PlaceholderFieldGroup(FieldGroupDefinition): - entry_type: FieldType = FieldType.TEXT - default_entries: list[PlaceholderField | TextField] = field(default_factory=list) # TODO: TextField seems wrong here - - def asdict(self): - asdict = super().asdict() - asdict["default_entries"] = [x.asdict() for x in self.default_entries] - return asdict - - -@dataclass -class PredefinedFieldGroup(FieldGroupDefinition): - entry_type: FieldType = FieldType.PREDEFINED - min_entries = 0 - max_entries = 1 - - -class PassStyle: - identifier: str # unique within platform - name: str - platform: Literal["apple"] | Literal["google"] - fields: list[FieldGroupDefinition] - # preview_image: str # TODO: preview - - def asdict(self): - return { - "identifier": self.identifier, - "name": self.name, - "platform": self.platform, - "fields": [x.asdict() for x in self.fields], - } - - -class AppleWalletEventTicket(PassStyle): - identifier = "event_1" - name = "Event Ticket Layout 1" - platform = "apple" - # 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) - fields = [ - PlaceholderFieldGroup( - identifier="logo", - name=_("Logo"), - min_entries=1, - max_entries=1, - default_entries=[ - PlaceholderField(LazyI18nString("logo"), "event:image") - ], - entry_type=FieldType.IMAGE, - ), - PlaceholderFieldGroup( - identifier="primary", - name=_("Primary"), - min_entries=1, - max_entries=1, - default_entries=[ - PlaceholderField(LazyI18nString("Ticket type"), "item") - ], - ), - PlaceholderFieldGroup( - 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." - PlaceholderFieldGroup( - identifier="headers", name=_("Header"), max_entries=3 - ), # TODO: header image - PlaceholderFieldGroup( - identifier="auxillary", name=_("Auxillary"), max_entries=4 - ), - PlaceholderFieldGroup(identifier="back", name=_("Back")), - ] - # preview_image = "apple/event_ticket.svg" - - -class GoogleWalletEventTicket(PassStyle): - identifier = "event" - name = "Event Ticket" - platform = "google" - fields = [ - PredefinedFieldGroup(identifier="seating", name=_("Seating")), - PlaceholderFieldGroup( - identifier="qrcode", name=_("QR-Code"), entry_type=FieldType.CODE - ), - ] - - -AVAILABLE_PLATFORMS = {"apple": ApplePlatform, "google": GooglePlatform} -AVAILABLE_STYLES = [AppleWalletEventTicket(), GoogleWalletEventTicket()] - - -def get_platforms_with_styles(): - platforms_with_styles = {} - for style in AVAILABLE_STYLES: - platform = style.platform - if platform not in platforms_with_styles: - platforms_with_styles[platform] = {} - platforms_with_styles[platform][style.identifier] = style - return platforms_with_styles - - -def get_platform_styles(platform): - platform_styles = {} - for style in AVAILABLE_STYLES: - if style.platform == platform: - platform_styles[style.identifier] = style - return platform_styles - - -def get_platforms(): - return AVAILABLE_PLATFORMS - - -class PassLayout: - style: PassStyle - layout: dict - - def __init__(self, style, layout): - self.style = style - self.layout = layout - - def validate(self, event): - self.validate_fields(event) - - def validate_fields(self, event): - placeholders = {"text": get_variables(event), "image": get_images(event)} - - style_fields = self.style.fields - if "fields" not in self.layout: - raise ValidationError(_("Layout did not contain any fields")) - layout_fields = self.layout["fields"] - if not isinstance(layout_fields, dict): - raise ValidationError(_("'fields' must be dict")) - - for fieldgroup in style_fields: - layout_field_data = layout_fields.get(fieldgroup.identifier, {}) - if fieldgroup.min_entries and fieldgroup.min_entries < len( - layout_field_data.get('entries', []) - ): - raise ValidationError( - _("At least {min_entries} must be specified for {name}").format( - min_entries=fieldgroup.min_entries, name=fieldgroup.name - ) - ) - # TODO: move field validation to json schema - for entry in layout_field_data.get('entries', []): - if entry['type'] not in ('placeholder', fieldgroup.entry_type.value): - raise ValidationError(_("Placeholder of wrong type \"{type}\" in {name}").format(type=entry['type'], name="fieldgroup.name")) - if entry['type'] == 'placeholder' and entry['content'] not in placeholders[fieldgroup.entry_type.value]: - raise ValidationError(_("Unknown placeholder {name}").format(name=entry['content'])) \ No newline at end of file diff --git a/src/pretix/plugins/wallet/styles/__init__.py b/src/pretix/plugins/wallet/styles/__init__.py new file mode 100644 index 000000000..ef1468121 --- /dev/null +++ b/src/pretix/plugins/wallet/styles/__init__.py @@ -0,0 +1,17 @@ +from .apple import ApplePlatform, AppleWalletEventTicket +from .google import GooglePlatform, GoogleWalletEventTicket +from .base import PassLayout + +AVAILABLE_PLATFORMS = {"apple": ApplePlatform, "google": 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"] \ No newline at end of file diff --git a/src/pretix/plugins/wallet/styles/apple.py b/src/pretix/plugins/wallet/styles/apple.py new file mode 100644 index 000000000..372032f63 --- /dev/null +++ b/src/pretix/plugins/wallet/styles/apple.py @@ -0,0 +1,66 @@ +from .base import ( + FieldEntry, + FieldEntryContentType, + FieldContentType, + ImageFieldGroup, + PlaceholderFieldGroup, + TextFieldGroup, + WalletPlatform, + PassStyle, +) +from django.utils.translation import gettext as _ +from i18nfield.strings import LazyI18nString + + +class ApplePlatform(WalletPlatform): + identifier = "apple" + name = _("Apple") + + +class AppleWalletStyle(PassStyle): + platform = ApplePlatform + +class AppleWalletEventTicket(AppleWalletStyle): + identifier = "event_1" + name = _("Event Ticket Layout 1") + fieldgroups = [ + ImageFieldGroup( + identifier="logo", + name=_("Logo"), + min_entries=1, + max_entries=1, + labels=False, + default_entries=[ + FieldEntry( + type=FieldEntryContentType.IMAGE, + label=LazyI18nString("logo"), + content="event:image", + ) + ], + ), + TextFieldGroup( + identifier="primary", + name=_("Primary"), + min_entries=1, + max_entries=1, + default_entries=[ + FieldEntry( + type=FieldEntryContentType.PLACEHOLDER, + 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" + + diff --git a/src/pretix/plugins/wallet/styles/base.py b/src/pretix/plugins/wallet/styles/base.py new file mode 100644 index 000000000..5063e4f61 --- /dev/null +++ b/src/pretix/plugins/wallet/styles/base.py @@ -0,0 +1,269 @@ +import enum +from i18nfield.strings import LazyI18nString +import jsonschema + +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 FieldEntryContentType(enum.Enum): + IMAGE = "image" + TEXT = "text" + PLACEHOLDER = "placeholder" + + +class FieldEntry: + type: FieldEntryContentType + label: LazyI18nString | None + content: str + + def __init__( + self, type: FieldEntryContentType, label: LazyI18nString | None, content: str + ): + 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 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 = context.get("placeholders", {}).get(self.content_type.value, []) + return { + "type": "object", + "properties": { + "entries": self.entries_schema(placeholders=placeholders), + "overflow": { + "oneOf": [ + {"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", + "oneOf": [ + { + "properties": { + **baseprops, + "type": {"const": "placeholder"}, + "content": {"enum": placeholders}, + } + }, + { + "properties": { + **baseprops, + "type": {"const": self.content_type.value}, + "content": {"type": "string"}, + } + }, + ], + "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 + + +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))) diff --git a/src/pretix/plugins/wallet/styles/google.py b/src/pretix/plugins/wallet/styles/google.py new file mode 100644 index 000000000..63557237c --- /dev/null +++ b/src/pretix/plugins/wallet/styles/google.py @@ -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), + ] diff --git a/src/pretix/plugins/wallet/templates/pretixplugins/wallet/edit.html b/src/pretix/plugins/wallet/templates/pretixplugins/wallet/edit.html index 048610077..78d6001cc 100644 --- a/src/pretix/plugins/wallet/templates/pretixplugins/wallet/edit.html +++ b/src/pretix/plugins/wallet/templates/pretixplugins/wallet/edit.html @@ -13,6 +13,7 @@

{% trans "Edit layout" %} {{ object.name }}

{{ styles|json_script:"styles" }} {{ variables|json_script:"variables" }} + {{ locales|json_script:"locales" }}
{% vite_hmr %} {% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %} diff --git a/src/pretix/plugins/wallet/templates/pretixplugins/wallet/placeholder_formset.html b/src/pretix/plugins/wallet/templates/pretixplugins/wallet/placeholder_formset.html deleted file mode 100644 index b3b20d5eb..000000000 --- a/src/pretix/plugins/wallet/templates/pretixplugins/wallet/placeholder_formset.html +++ /dev/null @@ -1,73 +0,0 @@ -{% load i18n %} -{% load bootstrap3 %} -{% load escapejson %} -{% load formset_tags %} -
- {{ formset.management_form }} - {% bootstrap_formset_errors formset %} -
- {% for form in formset %} -
- {% bootstrap_form_errors form %} -
- {{ form.id }} - {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} - {% bootstrap_field form.ORDER form_group_class="" layout="inline" %} -
-
- {% bootstrap_field form.entry layout="inline" %} -
-
- - - - - - -
-
- {% endfor %} -
- -

- -

-
-{% if external_fields %} - {{ external_fields|json_script:external_fields_id }} -{% endif %} diff --git a/src/pretix/plugins/wallet/views.py b/src/pretix/plugins/wallet/views.py index 636e31b52..eaf54de7c 100644 --- a/src/pretix/plugins/wallet/views.py +++ b/src/pretix/plugins/wallet/views.py @@ -5,14 +5,26 @@ 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 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 get_platform_styles, get_platforms +from .styles import AVAILABLE_STYLES, AVAILABLE_PLATFORMS + + +def get_layout_variables(event): + return { + "text": { + varname: {"label": var["label"], "editor_sample": var["editor_sample"]} + for varname, var in get_variables(event).items() + }, + "image": { + varname: {"label": var["label"]} + for varname, var in get_images(event).items() + } + | {"poweredby": {"label": _("pretix-Logo")}}, # TODO: image upload + } # TODO: should this even be a list view? @@ -27,7 +39,7 @@ class LayoutListView(EventPermissionRequiredMixin, ListView): def get_context_data(self, **kwargs: Any) -> dict[str, Any]: ctx = super().get_context_data(**kwargs) - ctx["platforms"] = get_platforms() + ctx["platforms"] = AVAILABLE_PLATFORMS return ctx @@ -38,33 +50,28 @@ class LayoutEditorView(DetailView): pk_url_kwarg = "layout" def get_platform_styles(self): - if self.object.platform not in get_platforms(): + if self.object.platform not in AVAILABLE_STYLES: raise Http404( _("Unknown platform '{platform}'").format(platform=self.object.platform) ) - return get_platform_styles(self.object.platform) + return AVAILABLE_STYLES[self.object.platform] def get_context_data(self, **kwargs) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["styles"] = { - id: style.asdict() for id, style in self.get_platform_styles().items() - } - context["variables"] = { - "text": { - varname: {"label": var["label"], "editor_sample": var["editor_sample"]} - for varname, var in get_variables(self.request.event).items() - }, - "image": { - varname: {"label": var['label']} for varname, var in get_images(self.request.event).items() - } | {"poweredby": {"label": _("pretix-Logo")}} # TODO: image upload + style.identifier: style.asdict() for style in self.get_platform_styles() } + context["variables"] = get_layout_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, platform, event, **kwargs): super().__init__(*args, **kwargs) self.platform = platform @@ -74,7 +81,8 @@ class WalletLayoutCreateForm(forms.ModelForm): self.instance.platform = self.platform self.instance.event = self.event return super().save(*args, **kwargs) - + + class LayoutCreateView(CreateView): template_name = "pretixplugins/wallet/create.html" form_class = WalletLayoutCreateForm @@ -82,19 +90,17 @@ class LayoutCreateView(CreateView): @property def platform(self): - platform = self.kwargs['platform'] - if platform not in get_platforms(): - raise Http404( - _("Unknown platform '{platform}'").format(platform=platform) - ) + platform = self.kwargs["platform"] + if platform not in AVAILABLE_PLATFORMS: + raise Http404(_("Unknown platform '{platform}'").format(platform=platform)) return platform - + def get_form_kwargs(self) -> dict[str, Any]: kwargs = super().get_form_kwargs() - kwargs['platform'] = self.platform - kwargs['event'] = self.request.event + kwargs["platform"] = self.platform + kwargs["event"] = self.request.event return kwargs - + def get_success_url(self) -> str: return reverse( "plugins:wallet:edit", @@ -103,4 +109,4 @@ class LayoutCreateView(CreateView): "event": self.request.event.slug, "layout": self.object.pk, }, - ) \ No newline at end of file + )