From daec284a54a19a040f1512f5cf32f06c3f41cc1d Mon Sep 17 00:00:00 2001 From: Kara Engelhardt Date: Wed, 30 Sep 2026 16:56:09 +0200 Subject: [PATCH] ticketoutput confirmation_text; is_cacheable --- src/pretix/base/services/tickets.py | 60 ++- src/pretix/base/ticketoutput.py | 13 + src/pretix/base/wallet/base.py | 460 ------------------ .../templates/pretixcontrol/order/index.html | 41 +- src/pretix/control/views/orders.py | 3 +- src/pretix/plugins/wallet/styles/__init__.py | 2 +- src/pretix/plugins/wallet/styles/apple.py | 2 +- src/pretix/plugins/wallet/styles/google.py | 2 +- src/pretix/plugins/wallet/ticketoutput.py | 16 +- .../pretixpresale/event/fragment_cart.html | 5 +- .../event/fragment_downloads.html | 13 +- .../pretixpresale/fragment_modals.html | 11 + src/pretix/presale/views/order.py | 55 ++- src/pretix/static/pretixpresale/js/ui/main.js | 11 + 14 files changed, 178 insertions(+), 516 deletions(-) delete mode 100644 src/pretix/base/wallet/base.py diff --git a/src/pretix/base/services/tickets.py b/src/pretix/base/services/tickets.py index ec5955fa1f..e484905185 100644 --- a/src/pretix/base/services/tickets.py +++ b/src/pretix/base/services/tickets.py @@ -22,7 +22,7 @@ import logging import os from decimal import Decimal - +from datetime import timedelta from django.core.files.base import ContentFile from django.utils.timezone import now from django.utils.translation import gettext as _ @@ -31,7 +31,7 @@ from django_scopes import scopes_disabled from pretix.base.i18n import language from pretix.base.models import ( CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order, - OrderPosition, + OrderPosition, CachedFile ) from pretix.base.services.tasks import EventTask, ProfiledTask from pretix.base.settings import PERSON_NAME_SCHEMES @@ -43,7 +43,7 @@ from django.db import transaction logger = logging.getLogger(__name__) -def generate_orderposition(order_position: int, provider: str): +def generate_orderposition(order_position: int, provider: str, cf: CachedFile | None = None): order_position = OrderPosition.objects.select_related('order', 'order__event').get(id=order_position) with language(order_position.order.locale, order_position.order.event.settings.region): @@ -53,15 +53,22 @@ def generate_orderposition(order_position: int, provider: str): if prov.identifier == provider: filename, ttype, data = prov.generate(order_position) path, ext = os.path.splitext(filename) - for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider): - ct.delete() - ct = CachedTicket.objects.create(order_position=order_position, provider=provider, - extension=ext, type=ttype, file=None) - ct.file.save(filename, ContentFile(data)) - return ct.pk + if cf: + cf.type = ttype + cf.filename = filename + cf.file.save(name=filename, content=ContentFile(data)) + return cf.pk -def generate_order(order: int, provider: str): + if prov.is_cacheable: + for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider): + ct.delete() + ct = CachedTicket.objects.create(order_position=order_position, provider=provider, + extension=ext, type=ttype, file=None) + ct.file.save(filename, ContentFile(data)) + return ct.pk + +def generate_order(order: int, provider: str, cf: CachedFile | None = None): order = Order.objects.select_related('event').get(id=order) with language(order.locale, order.event.settings.region): @@ -71,21 +78,32 @@ def generate_order(order: int, provider: str): if prov.identifier == provider: filename, ttype, data = prov.generate_order(order) path, ext = os.path.splitext(filename) - for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider): - ct.delete() - ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext, - type=ttype, file=None) - ct.file.save(filename, ContentFile(data)) - return ct.pk + if cf: + cf.type = ttype + cf.filename = filename + cf.file.save(name=filename, content=ContentFile(data)) + return cf.pk + + if prov.is_cacheable: + for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider): + ct.delete() + ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext, + type=ttype, file=None) + ct.file.save(filename, ContentFile(data)) + return ct.pk @app.task(base=ProfiledTask) -def generate(model: str, pk: int, provider: str): +def generate(model: str, pk: int, provider: str, cachedfile_pk: int | None = None): with scopes_disabled(): + if cachedfile_pk: + cf = CachedFile.objects.get(pk=cachedfile_pk) + else: + cf = None if model == 'order': - return generate_order(pk, provider) + return generate_order(pk, provider, cf) elif model == 'orderposition': - return generate_orderposition(pk, provider) + return generate_orderposition(pk, provider, cf) class DummyRollbackException(Exception): @@ -95,7 +113,7 @@ def get_preview_position(event): connection = transaction.get_connection() if not connection.in_atomic_block: raise RuntimeError("get_preview_position needs to be called in a rolledback_transaction") - + item = event.items.create(name=_("Sample product"), default_price=Decimal('42.23'), description=_("Sample product description")) item2 = event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40')) @@ -151,7 +169,7 @@ def get_tickets_for_order(order, base_position=None): ] for p in providers: - if not p.is_enabled: + if not p.is_enabled or not p.is_cacheable: continue if p.multi_download_enabled and not base_position: diff --git a/src/pretix/base/ticketoutput.py b/src/pretix/base/ticketoutput.py index 8219cce118..7fdcad41a0 100644 --- a/src/pretix/base/ticketoutput.py +++ b/src/pretix/base/ticketoutput.py @@ -231,6 +231,19 @@ class BaseTicketOutput: """ return 'fa-download' + @property + def confirmation_text(self) -> str | None: + """ + A text the user has to confirm before a pass is generated. + + Returning None means no confirmation screen is shown. + """ + return None + + @property + def is_cacheable(self) -> bool: + return True + @property def preview_allowed(self) -> bool: """ diff --git a/src/pretix/base/wallet/base.py b/src/pretix/base/wallet/base.py deleted file mode 100644 index 0be23dc1fc..0000000000 --- a/src/pretix/base/wallet/base.py +++ /dev/null @@ -1,460 +0,0 @@ -import enum -from typing import Literal, OrderedDict, TypedDict -from i18nfield.strings import LazyI18nString -import jsonschema -from django.core.exceptions import ValidationError -from pretix.base.models import OrderPosition -from ...plugins.wallet.placeholders import WalletPlaceholderRenderer, get_available_context, get_wallet_placeholder_renderer, get_wallet_placeholders -from django import forms -from pretix.api.helpers import handle_file_upload -from django.core.files import File - -class WalletPlatform: - identifier: str - name: str - - -class LayoutContext(TypedDict): - placeholders: dict[str, dict] - placeholder_renderer: WalletPlaceholderRenderer - - -class FieldGroupType(enum.Enum): - PLACEHOLDER = "placeholder" - PREDEFINED = "predefined" - - -class FieldGroupDisplay(enum.Enum): - PLAIN = "plain" - WITH_LABEL = "with_label" - CODE = "code" - - -class FieldGroup: - type: FieldGroupType - identifier: str - name: str - description: str - required: bool = False - - def __init__(self, identifier: str, name: str, description=None, required=False): - self.identifier = identifier - self.name = name - self.required = required - self.description = description or "" - - def layout_schema( - self, - remaining_fields: list["FieldGroup"], - context: LayoutContext, - ) -> dict: - raise NotImplementedError() - - def asdict(self, context: LayoutContext): - return { - "type": self.type.value, - "identifier": self.identifier, - "name": self.name, - "description": self.description, - "required": self.required, - } - - -class FieldContentType(enum.Enum): - IMAGE = "image" - TEXT = "text" - - -class FieldEntryType(enum.Enum): - CUSTOM = "custom" - PLACEHOLDER = "placeholder" - - -class FieldEntry[T]: - type: FieldEntryType - label: LazyI18nString | None - content: T - - def __init__( - self, type: FieldEntryType, content: T, label: LazyI18nString | None = None - ): - self.type = type - self.label = label - self.content = content - - def asdict(self) -> dict: - return { - "type": self.type.value, - "content": self.content, - "label": self.label.data if self.label else None, - } - - -class PlaceholderFieldEntry(FieldEntry[str]): - type = FieldEntryType.PLACEHOLDER - label: LazyI18nString | None - content: str - - def __init__(self, content: str, label: LazyI18nString | None = None): - self.label = label - self.content = content - - -class CustomFieldEntry(FieldEntry[LazyI18nString]): - type: FieldEntryType - label: LazyI18nString | None - content: LazyI18nString - - def asdict(self) -> dict: - return { - "type": self.type.value, - "content": self.content.data, - "label": self.label.data if self.label else None, - } - - -class PredefinedFieldGroup(FieldGroup): - type = FieldGroupType.PREDEFINED - - def layout_schema( - self, - remaining_fields: list["FieldGroup"], - context: LayoutContext, - ): - return {"type": "object", "properties": {"active": {"type": "boolean"}}} - - -class PlaceholderFieldGroup(FieldGroup): - type = FieldGroupType.PLACEHOLDER - content_type: FieldContentType - default_entries: list[FieldEntry] - display: FieldGroupDisplay - min_entries: int | None - max_entries: int | None - context_args: set[ - str - ] # what context arguments are available when rendering this fieldgroup - - def __init__( - self, - identifier: str, - name: str, - content_type: FieldContentType, - description: str = "", - required=False, - default_entries=None, - min_entries=None, - max_entries=None, - display=FieldGroupDisplay.WITH_LABEL, - context_args: set[str] | None = None, - ): - super().__init__(identifier, name, description, required) - self.content_type = content_type - self.default_entries = default_entries or [] - self.min_entries = min_entries - self.max_entries = max_entries - self.display = display - self.context_args = context_args or set() - - if self.required and (self.min_entries is None or self.min_entries < 1): - self.min_entries = 1 - - def asdict(self, context: LayoutContext): - return { - **super().asdict(context), - "content_type": self.content_type.value, - "default_entries": [x.asdict() for x in self.default_entries], - "display": self.display.value, - "min_entries": self.min_entries, - "max_entries": self.max_entries, - "context_args": list(get_available_context(self.context_args, context['placeholder_renderer'].transformations)), - } - - def layout_schema( - self, - remaining_fields: list["FieldGroup"], - context: LayoutContext, - ): - content_type_placeholders = ( - context["placeholders"].get(self.content_type.value, {}).values() - ) - renderer = context['placeholder_renderer'] - available_placeholders = [ - x.identifier - for x in content_type_placeholders - if renderer.is_available(x, self.context_args) - ] - return { - "type": "object", - "properties": { - "entries": self.entries_schema(placeholders=available_placeholders), - "overflow": { - "anyOf": [ - {"type": "null"}, - { - "type": "string", - "enum": [ - f.identifier - for f in remaining_fields - if isinstance(f, PlaceholderFieldGroup) - and f.content_type == self.content_type - ], - }, - ] - }, - }, - "required": ["entries"], - } - - def entries_schema(self, placeholders: list[str]): - baseprops = {} - if self.display == FieldGroupDisplay.WITH_LABEL: - baseprops["label"] = {"$ref": "#/$defs/I18nString"} - - schema = { - "type": "array", - "items": { - "type": "object", - "anyOf": [ - { - "properties": { - **baseprops, - "type": {"const": "placeholder"}, - "content": {"enum": placeholders}, - } - }, - { - "properties": { - **baseprops, - "type": {"const": "custom"}, - "content": {"$ref": "#/$defs/I18nString"}, - } - }, - ], - "required": ["type", "content"], - }, - } - if self.display == FieldGroupDisplay.WITH_LABEL: - schema["items"]["required"].append("label") - if self.min_entries is not None: - schema["minItems"] = self.min_entries - # max_entries is not enforced here, as the layout can have more fields than that (null-fields are removed, rest is overspilled) - return schema - - -class TextFieldGroup(PlaceholderFieldGroup): - content_type = FieldContentType.TEXT - - def __init__(self, **kwargs): - super().__init__(content_type=self.content_type, **kwargs) - - -class ImageFieldGroup(PlaceholderFieldGroup): - content_type = FieldContentType.IMAGE - display = FieldGroupDisplay.PLAIN - - def __init__(self, **kwargs): - super().__init__(content_type=self.content_type, display=self.display, **kwargs) - - -class SettingsField: - identifier: str - label: str - type: Literal["image", "text"] - help_text: str|None - required: bool - - def __init__( - self, - identifier: str, - label: str, - type: Literal["image", "text"] = "text", - help_text = None, - required: bool = False, - ): - self.identifier = identifier - self.label = label - self.type = type - self.help_text = help_text - self.required = required - - def asdict(self): - return { - "identifier": self.identifier, - "label": self.label, - "type": self.type, - "help_text": self.help_text, - "required": self.required, - } - - -class PassStyle: - 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 - fieldgroups: list[FieldGroup] - - @property - def settings(self) -> list[SettingsField]: - return [] - - @property - def preview_layout(self) -> list | None: - return None - - def asdict(self): - context = LayoutContext(placeholders=self.placeholders, placeholder_renderer=get_wallet_placeholder_renderer()) - return { - "identifier": self.identifier, - "name": self.name, - "fieldgroups": [x.asdict(context) for x in self.fieldgroups], - "preview_layout": self.preview_layout, - "settings": [x.asdict() for x in self.settings], - } - - def layout_schema(self): - context = LayoutContext(placeholders=self.placeholders, placeholder_renderer=get_wallet_placeholder_renderer()) - print(f"schema {self.settings=}") - schema = { - "$schema": "https://json-schema.org/draft/2020-12/schema", - # TODO: $id - "title": self.name, - "type": "object", - "properties": { - "fieldgroups": { - "description": "Layout Field Groups", - "type": "object", - "properties": { - group.identifier: group.layout_schema( - context=context, remaining_fields=self.fieldgroups[i:] - ) - for (i, group) in enumerate(self.fieldgroups) - }, - "required": [ - group.identifier for group in self.fieldgroups if group.required - ], - }, - "settings": { - "type": "object", - "properties": { - setting.identifier: {"type": ["string", "null"]} - for setting in self.settings - }, - "required": [ - setting.identifier for setting in self.settings if setting.required - ], - } - }, - "$defs": { - "I18nString": { - "oneOf": [ - {"type": "string"}, - {"type": "object", "additionalProperties": {"type": "string"}}, - ] - } - }, - } - if any(group.required for group in self.fieldgroups): - schema.setdefault("required", []) - schema["required"].append("fieldgroups") - # if any(setting.required for setting in self.settings): - # schema.setdefault("required", []) - # schema["required"].append("settings") - - return schema - - def render_placeholder(self, context, content_type, content): - placeholder = self.placeholders.get(content_type, {}).get(content) - if placeholder: - placeholder_value = context.render_placeholder(placeholder) - if placeholder_value: - return placeholder.label, placeholder_value - - return None, None - - def __init__(self, event, layout = None, file_settings: dict[str, File] | None = None): - self.event = event - self.layout = layout - self.file_settings = file_settings or {} - self.placeholders = get_wallet_placeholders(self.event) - - def validate(self): - schema = self.layout_schema() - try: - jsonschema.validate(self.layout, schema) - except jsonschema.ValidationError as e: - raise ValidationError("Invalid layout: {}".format(str(e))) - - def extract_file_settings(self, request, file_settings): - res = {} - for setting in self.settings: - if setting.type == "image": - if file_settings.get(setting.identifier) == "file:keep": - res[setting.identifier] = "keep" - elif data := file_settings.get(setting.identifier): - res[setting.identifier] = handle_file_upload(data, request.user, getattr(request, "auth", None), {"image/png", "image/jpeg"}) - elif setting.identifier in file_settings: - res[setting.identifier] = None - - return res - - def get_pass_fields(self, op: OrderPosition): - if not self.layout: - raise ValueError("`value` needs to be set") - - context = get_wallet_placeholder_renderer(order_position=op) - - fields = {} - for group in self.fieldgroups: - if isinstance(group, PredefinedFieldGroup): - pass - - elif isinstance(group, PlaceholderFieldGroup): - group_fields = fields.get(group.identifier, []) - if group.identifier in self.layout["fieldgroups"]: - for field in self.layout["fieldgroups"][group.identifier][ - "entries" - ]: - field_entry = {} - if group.display == FieldGroupDisplay.WITH_LABEL: - field_entry["label"] = LazyI18nString(field["label"]) - if field["type"] == FieldEntryType.PLACEHOLDER.value: - label, field_entry["value"] = self.render_placeholder( - context, group.content_type.value, field["content"] - ) - if ( - group.display == FieldGroupDisplay.WITH_LABEL - and not str(field_entry["label"]) - and label - ): - field_entry["label"] = LazyI18nString(label) - - elif field["type"] == FieldEntryType.CUSTOM.value: - field_entry["value"] = LazyI18nString(field["content"]) - if "value" in field_entry and field_entry["value"]: - group_fields.append(field_entry) - if group.min_entries and len(group_fields) < group.min_entries: - raise ValueError( - f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(group_fields)} were provided" - ) - fields[group.identifier] = group_fields[: group.max_entries] - if overflow_group := self.layout["fieldgroups"][group.identifier][ - "overflow" - ]: - fields.setdefault(overflow_group, []) - fields[overflow_group] += group_fields[group.max_entries :] - - else: - raise ValueError("Unknown field group") - return fields - - def group_is_active(self, identifier: str): - if not self.layout: - raise ValueError("`value` needs to be set") - - return self.layout["fieldgroups"].get(identifier, {}).get("active", False) - - def generate(self, op: OrderPosition): - raise NotImplementedError() diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 94eb286b1c..5982e88537 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -10,6 +10,7 @@ {% load l10n %} {% load phone_format %} {% load getitem %} +{% load dialog %} {% block title %} {% blocktrans trimmed with code=order.code %} Order details: {{ code }} @@ -563,15 +564,41 @@
{% if line.generate_ticket %} {% for b in download_buttons %} -
- {% csrf_token %} - -
+ + + {% else %} +
+ {% csrf_token %} + +
+ {% endif %} {% endfor %} {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_downloads.html b/src/pretix/presale/templates/pretixpresale/event/fragment_downloads.html index 682434ae0c..965269085b 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_downloads.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_downloads.html @@ -1,5 +1,6 @@ {% load i18n %} {% load eventurl %} +{% load rich_text %} {% if download_email_required %}
@@ -43,10 +44,11 @@ {% for b in download_buttons %} {% if b.multi %}
+ method="post" data-asynctask data-asynctask-download class="download-btn-form {% if b.javascript_required or b.confirmation_text %}requirejs{% endif %}"> {% csrf_token %}
@@ -62,11 +64,12 @@
{% for b in download_buttons %}
+ method="post" data-asynctask data-asynctask-download + class="download-btn-form {% if b.javascript_required or b.confirmation_text %}requirejs{% endif %}"> {% csrf_token %}
diff --git a/src/pretix/presale/templates/pretixpresale/fragment_modals.html b/src/pretix/presale/templates/pretixpresale/fragment_modals.html index 0ae0d743dd..890786e72a 100644 --- a/src/pretix/presale/templates/pretixpresale/fragment_modals.html +++ b/src/pretix/presale/templates/pretixpresale/fragment_modals.html @@ -147,3 +147,14 @@ {% dialog "dialog-nothing-to-add" label_nothing_to_add description_nothing_to_add icon="exclamation-circle" %} {% enddialog %} + +{% trans "Confirmation required" as confirmation_title %} +{% dialog "confirmationmodal" confirmation_title "" icon="shield" %} + +{% enddialog %} \ No newline at end of file diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index b160b035da..6a570e4b92 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -42,7 +42,10 @@ import os import re from collections import Counter, OrderedDict, defaultdict from decimal import Decimal +from typing import Iterable from urllib.parse import quote +from datetime import timedelta +from uuid import UUID from django import forms from django.conf import settings @@ -61,12 +64,14 @@ from django.utils.timezone import now from django.utils.translation import gettext, gettext_lazy as _ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.generic import ListView, TemplateView, View +from pretixeu.views import reverse from pretix.base.auth import has_event_access_permission from pretix.base.models import ( CachedTicket, Checkin, GiftCard, Invoice, Order, OrderPosition, Quota, TaxRule, ) +from pretix.base.models.base import CachedFile from pretix.base.models.orders import ( CachedCombinedTicket, InvoiceAddress, OrderFee, OrderPayment, OrderRefund, QuestionAnswer, @@ -277,7 +282,8 @@ class TicketPageMixin: 'icon': provider.download_button_icon or 'fa-download', 'identifier': provider.identifier, 'multi': provider.multi_download_enabled, - 'javascript_required': provider.javascript_required + 'javascript_required': provider.javascript_required, + 'confirmation_text': provider.confirmation_text, }) return buttons @@ -1176,9 +1182,6 @@ class AnswerDownload(EventViewMixin, OrderDetailMixin, View): class OrderDownloadMixin: - def get_success_url(self, value): - return self.get_self_url() - @cached_property def output(self): responses = register_ticket_outputs.send(self.request.event) @@ -1212,12 +1215,33 @@ class OrderDownloadMixin: ): return self.error(OrderError(_('Please click the link we sent you via email to download your tickets.'))) - ct = self.get_last_ct() - if ct: + if self.output.is_cacheable and (ct := self.get_last_ct()): return self.success(ct) + + if not self.output.is_cacheable: + cf = CachedFile(web_download=True, date=now(), expires=now() + timedelta(hours=2)) + cf.bind_to_session(request) + cf.save() + else: + cf = None + return self.do('orderposition' if 'position' in kwargs else 'order', self.order_position.pk if 'position' in kwargs else self.order.pk, - self.output.identifier) + self.output.identifier, + cf.pk if cf else None) + + def get_success_url(self, value): + if isinstance(value, UUID): + cf = CachedFile.objects.filter(pk=value).first() + else: + cf = None + if not cf or not cf.allowed_for_session(self.request): + return self.get_self_url() + + if cf.type == 'text/uri-list': + return cf.file.file.read().decode() + else: + return reverse('cachedfile.download', kwargs={"id":str(value)}) def get_success_message(self, value): return "" @@ -1259,14 +1283,15 @@ class OrderDownloadMixin: return redirect(self.get_self_url()) def get_last_ct(self): - if 'position' in self.kwargs: - ct = CachedTicket.objects.filter( - order_position=self.order_position, provider=self.output.identifier, file__isnull=False - ).last() - else: - ct = CachedCombinedTicket.objects.filter( - order=self.order, provider=self.output.identifier, file__isnull=False - ).last() + if self.output.is_cacheable: + if 'position' in self.kwargs: + ct = CachedTicket.objects.filter( + order_position=self.order_position, provider=self.output.identifier, file__isnull=False + ).last() + else: + ct = CachedCombinedTicket.objects.filter( + order=self.order, provider=self.output.identifier, file__isnull=False + ).last() if not ct or not ct.file: return None return ct diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js index 4543b8bc84..98091967b3 100644 --- a/src/pretix/static/pretixpresale/js/ui/main.js +++ b/src/pretix/static/pretixpresale/js/ui/main.js @@ -730,6 +730,17 @@ $(function () { // Prevent double-submit, see also https://github.com/pretix/pretix/issues/5836 $(this).addClass('disabled') }) + document.querySelectorAll('button[data-confirmation-text]').forEach((element) => { + element.addEventListener('click', (evt) => { + evt.preventDefault(); + const onConfirm = () => evt.target.form.submit(); + const confirmationModal = document.getElementById("confirmationmodal"); + confirmationModal.querySelector("#confirmationmodal-description").innerHTML = element.dataset.confirmationText + confirmationModal.querySelector("#confirmationmodal-confirm").addEventListener('click', onConfirm, {'once': true}) + confirmationModal.addEventListener('close', () => confirmationModal.removeEventListener('click', onConfirm)) + confirmationModal.showModal() + }) + }) }) function copy_answers (elements, answers) {