diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 8f0ff758a0..f14fd1b1b8 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -864,6 +864,9 @@ Generating new secrets Triggers generation of new ``secret`` and ``web_secret`` attributes for both the order and all order positions. + Ticket secrets of order positions that have been used to issue a gift card can not + be changed. Only the link (``web_secret``) will be changed in this case. + **Example request**: .. sourcecode:: http @@ -895,6 +898,9 @@ Generating new secrets Triggers generation of a new ``secret`` and ``web_secret`` attribute for a single order position. + Ticket secrets of order positions that have been used to issue a gift card can not + be changed. Only the link (``web_secret``) will be changed in this case. + **Example request**: .. sourcecode:: http diff --git a/src/pretix/base/migrations/0302_fixup_eventmetaproperties.py b/src/pretix/base/migrations/0302_fixup_eventmetaproperties.py new file mode 100644 index 0000000000..89672216ed --- /dev/null +++ b/src/pretix/base/migrations/0302_fixup_eventmetaproperties.py @@ -0,0 +1,91 @@ +# Generated by Django 5.2.12 on 2026-04-28 11:34 +import logging + +from django.db import IntegrityError, migrations, transaction +from django.db.models import Count, F + +logger = logging.getLogger(__name__) + + +def fix_cross_organizer_eventmetavalues(apps, schema_editor): + EventMetaProperty = apps.get_model("pretixbase", "EventMetaProperty") + EventMetaValue = apps.get_model("pretixbase", "EventMetaValue") + + cross_org_values = EventMetaValue.objects.filter( + event__organizer__pk__ne=F('property__organizer__pk') + ).order_by('event__organizer__slug', 'event__slug') + for emv in cross_org_values: + logger.warning("%s", f"Fixing cross-organizer EventMetaValue: {emv.event.organizer.slug}/{emv.event.slug}") + logger.warning(" %s", f"{emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}") + try: + emv.property = emv.event.organizer.meta_properties.get(name=emv.property.name) + if EventMetaValue.objects.filter(event=emv.event, property=emv.property).exists(): + correct = EventMetaValue.objects.get(event=emv.event, property=emv.property) + if correct.value != emv.value: + logger.warning(" %s", f"WARN: conflicting EventMetaValue with property in correct organizer already exists, deleting the cross-organizer one") + else: + logger.warning(" %s", f"OK: same-value EventMetaValue with property in correct organizer already exists, deleting the cross-organizer one") + logger.warning(" %s", f"keeping: {correct.property.name}({correct.property.id}@{correct.property.organizer.slug}) = {repr(correct.value)}") + emv.delete() + else: + logger.warning(" %s", f"OK: found existing EventMetaProperty in {emv.event.organizer.slug}, updating reference") + logger.warning(" %s", f"after: {emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}") + emv.save(update_fields=["property"]) + except EventMetaProperty.DoesNotExist: + meta_prop = emv.property + meta_prop.pk = None + meta_prop.organizer = emv.event.organizer + meta_prop.filter_public = False + meta_prop.save(force_insert=True) + logger.warning(" %s", f"WARN: found no matching EventMetaProperty, creating") + logger.warning(" %s", f"after: {emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}") + emv.save(update_fields=["property"]) + + +def make_eventmetaproperties_unique(apps, schema_editor): + EventMetaProperty = apps.get_model("pretixbase", "EventMetaProperty") + EventMetaValue = apps.get_model("pretixbase", "EventMetaValue") + + duplicates = EventMetaProperty.objects.values('organizer', 'organizer__slug', 'name').annotate(count=Count('id')).filter(count__gt=1) + for dup in duplicates: + logger.warning("%s", f"Fixup duplicate property {dup['organizer__slug']} {dup['name']}") + props = list(EventMetaProperty.objects.filter(organizer=dup['organizer'], name=dup['name'])) + + target = props[0] + invalid = props[1:] + + try: + with transaction.atomic(): + affected = EventMetaValue.objects.filter( + event__organizer=dup['organizer'], property__in=invalid + ).update( + property=target + ) + logger.warning("%s", f" Switching {affected} value(s) over to {target.name}({target.id}@{target.organizer.slug})") + + except IntegrityError as e: + logger.warning("%s", f" Failed to switch all value(s) over to {target.name}({target.id}@{target.organizer.slug})") + logger.warning("%s", f" {e}") + for prop in invalid: + newname = f'{prop.name}_DUPLICATE_{prop.id}' + logger.warning("%s", f" Renaming {prop.name}({prop.id}@{prop.organizer.slug}) to {newname}({prop.id}@{prop.organizer.slug})") + prop.name = newname + prop.filter_public = False + prop.save() + + else: + for prop in invalid: + logger.warning("%s", f" Deleting {prop.name}({prop.id}@{prop.organizer.slug})") + prop.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0301_reusablemedium_remove_orderposition"), + ] + + operations = [ + migrations.RunPython(fix_cross_organizer_eventmetavalues, migrations.RunPython.noop), + migrations.RunPython(make_eventmetaproperties_unique, migrations.RunPython.noop), + ] diff --git a/src/pretix/base/migrations/0303_alter_eventmetaproperty_unique_together.py b/src/pretix/base/migrations/0303_alter_eventmetaproperty_unique_together.py new file mode 100644 index 0000000000..a16012c2a0 --- /dev/null +++ b/src/pretix/base/migrations/0303_alter_eventmetaproperty_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.12 on 2026-04-28 11:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0302_fixup_eventmetaproperties"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="eventmetaproperty", + unique_together={("organizer", "name")}, + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index a56424e05e..464d534f51 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -1851,6 +1851,7 @@ class EventMetaProperty(LoggedModel): class Meta: ordering = ("position", "name",) + unique_together = ('organizer', 'name') @property def choice_keys(self): diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 79f1229d15..272a263976 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -885,26 +885,6 @@ class Item(LoggedModel): return False return True - def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]: - now_dt = now_dt or time_machine_now() - subevent_item = subevent and subevent.item_overrides.get(self.pk) - if not self.active: - return 'active' - elif self.available_from and self.available_from > now_dt: - return 'available_from' - elif self.available_until and self.available_until < now_dt: - return 'available_until' - elif (self.require_voucher or self.hide_without_voucher) and not has_voucher: - return 'require_voucher' - elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt: - return 'available_from' - elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt: - return 'available_until' - elif self.hidden_if_item_available and self._dependency_available: - return 'hidden_if_item_available' - else: - return None - def _get_quotas(self, ignored_quotas=None, subevent=None): check_quotas = set(getattr( self, '_subevent_quotas', # Utilize cache in product list @@ -1413,22 +1393,6 @@ class ItemVariation(models.Model): return False return True - def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]: - now_dt = now_dt or time_machine_now() - subevent_var = subevent and subevent.var_overrides.get(self.pk) - if not self.active: - return 'active' - elif self.available_from and self.available_from > now_dt: - return 'available_from' - elif self.available_until and self.available_until < now_dt: - return 'available_until' - elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt: - return 'available_from' - elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt: - return 'available_until' - else: - return None - @property def meta_data(self): data = self.item.meta_data diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 9c463eb94d..c8047bbd5c 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -936,7 +936,7 @@ class BasePaymentProvider: """ Will be called if the *event administrator* views the details of a payment. - It should return HTML code containing information regarding the current payment + It should return a SafeString containing HTML code, with information regarding the current payment status and, if applicable, next steps. The default implementation returns an empty string. @@ -961,7 +961,7 @@ class BasePaymentProvider: """ Will be called if the *event administrator* views the details of a refund. - It should return HTML code containing information regarding the current refund + It should return a SafeString containing HTML code, with information regarding the current refund status and, if applicable, next steps. The default implementation returns an empty string. diff --git a/src/pretix/base/secrets.py b/src/pretix/base/secrets.py index da253e531b..42f1b97990 100644 --- a/src/pretix/base/secrets.py +++ b/src/pretix/base/secrets.py @@ -245,6 +245,9 @@ def recv_classic(sender, **kwargs): def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_used=False, force_invalidate=False, save=True): + if position.pk and position.issued_gift_cards.exists(): + return + gen = event.ticket_secret_generator if gen.use_revocation_list and force_invalidate_if_revokation_list_used: force_invalidate = True diff --git a/src/pretix/base/services/cross_selling.py b/src/pretix/base/services/cross_selling.py index f407d2fad1..3166747158 100644 --- a/src/pretix/base/services/cross_selling.py +++ b/src/pretix/base/services/cross_selling.py @@ -29,7 +29,7 @@ from typing import List from django.utils.functional import cached_property from pretix.base.models import CartPosition, ItemCategory, SalesChannel -from pretix.presale.views.event import get_grouped_items +from pretix.presale.productlist import prepare_item_list_for_shop class DummyCategory: @@ -162,7 +162,7 @@ class CrossSellingService: ] def _prepare_items(self, subevent, items_qs, discount_info): - items, _btn = get_grouped_items( + items, _btn = prepare_item_list_for_shop( self.event, subevent=subevent, voucher=None, diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index dc73363301..9d8298bfcc 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -110,6 +110,7 @@ from pretix.celery_app import app from pretix.helpers import OF_SELF from pretix.helpers.models import modelcopy from pretix.helpers.periodic import minimum_interval +from pretix.presale.productlist import prepare_item_list_for_shop from pretix.testutils.middleware import debugflags_var @@ -1599,6 +1600,7 @@ class OrderChangeManager: 'seat_forbidden': gettext_lazy('The selected product does not allow to select a seat.'), 'tax_rule_country_blocked': gettext_lazy('The selected country is blocked by your tax rule.'), 'gift_card_change': gettext_lazy('You cannot change the price of a position that has been used to issue a gift card.'), + 'gift_card_secret': gettext_lazy('You cannot change the ticket secret of a position that has been used to issue a gift card.'), 'max_items_per_product': ngettext_lazy( "You cannot select more than %(max)s item of the product %(product)s.", "You cannot select more than %(max)s items of the product %(product)s.", @@ -1756,6 +1758,9 @@ class OrderChangeManager: self._operations.append(self.RegenerateSecretOperation(position)) def change_ticket_secret(self, position: OrderPosition, new_secret: str): + if position.issued_gift_cards.exists(): + raise OrderError(self.error_messages['gift_card_secret']) + self._operations.append(self.ChangeSecretOperation(position, new_secret)) def change_valid_from(self, position: OrderPosition, new_value: datetime): @@ -1943,13 +1948,18 @@ class OrderChangeManager: :param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values), ``"count"``, and ``"price"``. - :param limit_main_positions: By default, the method works on all methods of the order. If you set this to a + :param limit_main_positions: By default, the method works on all positions of the order. If you set this to a queryset or a list of positions, all other positions and their add-ons will be kept untouched. """ if self._operations: raise ValueError("Setting addons should be the first/only operation") + def _allowed_on_order_sales_channel(item_or_var, order): + return item_or_var.all_sales_channels or ( + order.sales_channel.identifier in (s.identifier for s in item_or_var.limit_sales_channels.all()) + ) + # Prepare containers for min/max check of products item_counts = Counter() for p in self.order.positions.all(): @@ -2043,13 +2053,11 @@ class OrderChangeManager: if not item.is_available() or (variation and not variation.is_available()): raise OrderError(error_messages['unavailable']) - if not item.all_sales_channels: - if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()): - raise OrderError(error_messages['unavailable']) + if not _allowed_on_order_sales_channel(item, self.order): + raise OrderError(error_messages['unavailable']) - if variation and not variation.all_sales_channels: - if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()): - raise OrderError(error_messages['unavailable']) + if variation and not _allowed_on_order_sales_channel(variation, self.order): + raise OrderError(error_messages['unavailable']) if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(): raise OrderError(error_messages['not_for_sale']) @@ -2097,6 +2105,36 @@ class OrderChangeManager: ) item_counts[item] += 1 + def _addon_is_available(a): + # If an item is no longer available due to time, it should usually also be no longer + # user-removable, because e.g. the stock has already been ordered. + # We always set voucher=None because that's what's done when generating the form in + # OrderChangeMixin (vouchers for addons are not supported). + # This also prevents accidental removal through the UI because a hidden product will no longer + # be part of the input. + if not _allowed_on_order_sales_channel(a.item, self.order) or ( + a.variation and not _allowed_on_order_sales_channel(a.variation, self.order) + ): + return False + + items, _ = prepare_item_list_for_shop( + self.order.event, + channel=self.order.sales_channel, + subevent=a.subevent, + voucher=None, + base_qs=Item.objects.filter(pk=a.item.pk), + allow_addons=True + ) + if (not items) or items[0].current_unavailability_reason: + return False + + if a.variation: + variations = [var for var in items[0].available_variations if var.pk == a.variation.pk] + if (not variations) or variations[0].current_unavailability_reason: + return False + + return True + # Detect removed add-ons and create RemoveOperations for cp, al in list(current_addons.items()): for k, v in al.items(): @@ -2106,22 +2144,7 @@ class OrderChangeManager: for a in current_addons[cp][k][:current_num - input_num]: if a.canceled: continue - is_unavailable = ( - # If an item is no longer available due to time, it should usually also be no longer - # user-removable, because e.g. the stock has already been ordered. - # We always pass has_voucher=True because if a product now requires a voucher, it usually does - # not mean it should be unremovable for others. - # This also prevents accidental removal through the UI because a hidden product will no longer - # be part of the input. - (a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent)) - or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel)) - or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent) - or ( - not a.item.all_sales_channels and - not a.item.limit_sales_channels.contains(self.order.sales_channel) - ) - ) - if is_unavailable: + if not _addon_is_available(a): # "Re-select" add-on selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1 continue diff --git a/src/pretix/control/signals.py b/src/pretix/control/signals.py index f3b68cc856..57f87109a0 100644 --- a/src/pretix/control/signals.py +++ b/src/pretix/control/signals.py @@ -141,7 +141,7 @@ event_dashboard_widgets = EventPluginSignal() This signal is sent out to include widgets in the event dashboard. Receivers should return a list of dictionaries, where each dictionary can have the keys: -* content (str, containing HTML) +* content (SafeString, containing HTML) * display_size (str, one of "full" (whole row), "big" (half a row) or "small" (quarter of a row). May be ignored on small displays, default is "small") * priority (int, used for ordering, higher comes first, default is 1) @@ -158,7 +158,7 @@ Arguments: 'user' This signal is sent out to include widgets in the personal user dashboard. Receivers should return a list of dictionaries, where each dictionary can have the keys: -* content (str, containing HTML) +* content (SafeString, containing HTML) * display_size (str, one of "full" (whole row), "big" (half a row) or "small" (quarter of a row). May be ignored on small displays, default is "small") * priority (int, used for ordering, higher comes first, default is 1) diff --git a/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html b/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html index 760769b3a5..d5370a45a7 100644 --- a/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html +++ b/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html @@ -3,6 +3,7 @@ {% load i18n %} {% load static %} {% load compress %} +{% load escapejson %} {% block content %}
{% if jsondata %} {% endif %} {% compress js %} diff --git a/src/pretix/control/templates/pretixcontrol/dashboard.html b/src/pretix/control/templates/pretixcontrol/dashboard.html index 7dace39370..cfc2a90460 100644 --- a/src/pretix/control/templates/pretixcontrol/dashboard.html +++ b/src/pretix/control/templates/pretixcontrol/dashboard.html @@ -28,7 +28,7 @@ {% if w.lazy %} {% else %} - {{ w.content|safe }} + {{ w.content }} {% endif %} @@ -51,7 +51,7 @@ {% if w.lazy %} {% else %} - {{ w.content|safe }} + {{ w.content }} {% endif %} @@ -72,7 +72,7 @@ {% if w.lazy %} {% else %} - {{ w.content|safe }} + {{ w.content }} {% endif %} @@ -94,7 +94,7 @@ {% if w.lazy %} {% else %} - {{ w.content|safe }} + {{ w.content }} {% endif %} {% else %} @@ -102,7 +102,7 @@ {% if w.lazy %} {% else %} - {{ w.content|safe }} + {{ w.content }} {% endif %} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/event/index.html b/src/pretix/control/templates/pretixcontrol/event/index.html index f1fd140be9..a8d04040de 100644 --- a/src/pretix/control/templates/pretixcontrol/event/index.html +++ b/src/pretix/control/templates/pretixcontrol/event/index.html @@ -106,7 +106,7 @@ {% if w.lazy %} {% else %} - {{ w.content|safe }} + {{ w.content }} {% endif %} {% elif w.link %} @@ -114,7 +114,7 @@ {% if w.lazy %} {% else %} - {{ w.content|safe }} + {{ w.content }} {% endif %} {% else %} @@ -122,7 +122,7 @@ {% if w.lazy %} {% else %} - {{ w.content|safe }} + {{ w.content }} {% endif %} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/event/payment.html b/src/pretix/control/templates/pretixcontrol/event/payment.html index 12e89a8649..fef1ae3593 100644 --- a/src/pretix/control/templates/pretixcontrol/event/payment.html +++ b/src/pretix/control/templates/pretixcontrol/event/payment.html @@ -12,7 +12,7 @@| {{ provider.verbose_name }} | @@ -56,7 +56,7 @@
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %} - + {% trans "Enable additional payment plugins" %} |
diff --git a/src/pretix/control/templates/pretixcontrol/event/plugins.html b/src/pretix/control/templates/pretixcontrol/event/plugins.html
index 2c3d6543db..54d7e71659 100644
--- a/src/pretix/control/templates/pretixcontrol/event/plugins.html
+++ b/src/pretix/control/templates/pretixcontrol/event/plugins.html
@@ -28,6 +28,7 @@
|||