From 4be74b795549afca9584eb16d5a2fa83003ec638 Mon Sep 17 00:00:00 2001 From: Mira Weller Date: Fri, 16 May 2025 19:29:02 +0200 Subject: [PATCH] extend cart without full page reload --- src/pretix/base/services/cart.py | 4 +- src/pretix/base/views/tasks.py | 6 +- .../pretixpresale/event/checkout_confirm.html | 44 ++++++------- .../pretixpresale/event/fragment_cart.html | 6 +- src/pretix/presale/views/__init__.py | 3 + src/pretix/presale/views/cart.py | 7 +- src/pretix/static/pretixbase/js/asynctask.js | 65 ++++++++++++------- src/pretix/static/pretixpresale/js/ui/cart.js | 27 ++++++-- 8 files changed, 103 insertions(+), 59 deletions(-) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 14099b8c1f..5212b1307d 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -1655,7 +1655,7 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) -def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: +def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> dict: """ Resets the expiry time of a cart to the configured reservation time of this event. Limited to 11x the reservation time. @@ -1672,7 +1672,7 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm.commit() - return cm.num_extended_positions + return {"success": cm.num_extended_positions, "expiry": cm._expiry, "max_expiry_extend": cm._max_expiry_extend} except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/pretix/base/views/tasks.py b/src/pretix/base/views/tasks.py index 8a9e6e9096..d50fae9285 100644 --- a/src/pretix/base/views/tasks.py +++ b/src/pretix/base/views/tasks.py @@ -68,7 +68,7 @@ class AsyncMixin: def get_check_url(self, task_id, ajax): return self.request.path + '?async_id=%s' % task_id + ('&ajax=1' if ajax else '') - def _ajax_response_data(self): + def _ajax_response_data(self, value): return {} def _return_ajax_result(self, res, timeout=.5): @@ -85,7 +85,7 @@ class AsyncMixin: logger.warning('Ignored ResponseError in AsyncResult.get()') except ConnectionError: # Redis probably just restarted, let's just report not ready and retry next time - data = self._ajax_response_data() + data = self._ajax_response_data(None) data.update({ 'async_id': res.id, 'ready': False @@ -93,7 +93,7 @@ class AsyncMixin: return data state, info = res.state, res.info - data = self._ajax_response_data() + data = self._ajax_response_data(info) data.update({ 'async_id': res.id, 'ready': ready, diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html index 42156a81ed..42afcfdfde 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html @@ -11,31 +11,31 @@

{% trans "Review order" %}

{% include "pretixpresale/event/fragment_checkoutflow.html" %}

{% trans "Please review the details below and confirm your order." %}

+
+
+

+ + {% trans "Your cart" %} + + {% trans "Add or remove tickets" %} + +

+ + + {% if cart.minutes_left > 0 or cart.seconds_left > 0 %} + {{ cart.minutes_left|stringformat:"02d" }}:{{ cart.seconds_left|stringformat:"02d" }} + {% else %} + {% trans "Cart expired" %} + {% endif %} + +
+
+ {% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event editable=False %} +
+
{% csrf_token %} -
-
-

- - {% trans "Your cart" %} - - {% trans "Add or remove tickets" %} - -

- - - {% if cart.minutes_left > 0 or cart.seconds_left > 0 %} - {{ cart.minutes_left|stringformat:"02d" }}:{{ cart.seconds_left|stringformat:"02d" }} - {% else %} - {% trans "Cart expired" %} - {% endif %} - -
-
- {% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event editable=False %} -
-
{% if payments %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 02cacd02c4..bcfb67e9b0 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -492,10 +492,10 @@
{% if not cart.is_ordered %} - + {% csrf_token %} - + {% if cart.minutes_left > 0 or cart.seconds_left > 0 %} {% blocktrans trimmed with minutes=cart.minutes_left %} The items in your cart are reserved for you for {{ minutes }} minutes. diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 5b893c3dd6..dc01c52997 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -229,11 +229,13 @@ class CartMixin: try: first_expiry = min(p.expires for p in positions) if positions else now() + max_expiry_extend = min(p.max_extend for p in positions) if positions else now() total_seconds_left = max(first_expiry - now(), timedelta()).total_seconds() minutes_left = int(total_seconds_left // 60) seconds_left = int(total_seconds_left % 60) except AttributeError: first_expiry = None + max_expiry_extend = None minutes_left = None seconds_left = None @@ -250,6 +252,7 @@ class CartMixin: 'minutes_left': minutes_left, 'seconds_left': seconds_left, 'first_expiry': first_expiry, + 'max_expiry_extend': max_expiry_extend, 'is_ordered': bool(order), 'itemcount': sum(c.count for c in positions if not c.addon_to), 'current_selected_payments': [p for p in self.current_selected_payments(total) if p.get('multi_use_supported')] diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index f99eac0436..7f69da6127 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -542,8 +542,11 @@ class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View): task = extend_cart_reservation known_errortypes = ['CartError'] + def _ajax_response_data(self, value): + return value + def get_success_message(self, value): - if value > 0: + if value['success'] > 0: return _('Your cart timeout was extended.') def post(self, request, *args, **kwargs): @@ -561,7 +564,7 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View): def get_success_message(self, value): return _('The products have been successfully added to your cart.') - def _ajax_response_data(self): + def _ajax_response_data(self, value): cart_id = get_or_create_cart_id(self.request) return { 'cart_id': cart_id, diff --git a/src/pretix/static/pretixbase/js/asynctask.js b/src/pretix/static/pretixbase/js/asynctask.js index b2504a3eba..65a19338c2 100644 --- a/src/pretix/static/pretixbase/js/asynctask.js +++ b/src/pretix/static/pretixbase/js/asynctask.js @@ -5,36 +5,58 @@ var async_task_check_url = null; var async_task_old_url = null; var async_task_is_download = false; var async_task_is_long = false; +var async_task_dont_redirect = false; -function async_task_check() { + +var async_task_status_messages = { + long_task_started: gettext( + 'Your request is currently being processed. Depending on the size of your event, this might take up to ' + + 'a few minutes.' + ), + long_task_pending: gettext( + 'Your request has been queued on the server and will soon be ' + + 'processed.' + ), + short_task: gettext( + 'Your request arrived on the server but we still wait for it to be ' + + 'processed. If this takes longer than two minutes, please contact us or go ' + + 'back in your browser and try again.' + ) +}; + +function async_task_schedule_check(context, timeout) { "use strict"; - $.ajax( - { - 'type': 'GET', - 'url': async_task_check_url, - 'success': async_task_check_callback, - 'error': async_task_check_error, - 'context': this, - 'dataType': 'json' - } - ); + async_task_timeout = window.setTimeout(function() { + $.ajax( + { + 'type': 'GET', + 'url': async_task_check_url, + 'success': async_task_check_callback, + 'error': async_task_check_error, + 'context': context, + 'dataType': 'json' + } + ); + }, timeout); } function async_task_on_success(data) { "use strict"; - if (async_task_is_download && data.success) { + if ((async_task_is_download || async_task_dont_redirect) && data.success) { waitingDialog.hide(); if (location.href.indexOf("async_id") !== -1) { history.replaceState({}, "pretix", async_task_old_url); } } - location.href = data.redirect; + if (!async_task_dont_redirect) + location.href = data.redirect; + $(this).trigger('pretix:async-task-success', data); } function async_task_check_callback(data, textStatus, jqXHR) { "use strict"; if (data.ready && data.redirect) { - async_task_on_success(data); + async_task_on_success.call(this, data); return; } else if (typeof data.percentage === "number") { $("#loadingmodal .progress").show(); @@ -55,7 +77,7 @@ function async_task_check_callback(data, textStatus, jqXHR) { } } } - async_task_timeout = window.setTimeout(async_task_check, 250); + async_task_schedule_check(this, 250); if (async_task_is_long) { if (data.started) { @@ -117,7 +139,7 @@ function async_task_check_error(jqXHR, textStatus, errorThrown) { // 500 can be an application error or overload in some cases :( $("#loadingmodal p.status").text(gettext('We currently cannot reach the server, but we keep trying.' + ' Last error code: {code}').replace(/\{code\}/, jqXHR.status)); - async_task_timeout = window.setTimeout(async_task_check, 5000); + async_task_schedule_check(this, 5000); } } } @@ -126,12 +148,12 @@ function async_task_callback(data, jqXHR, status) { "use strict"; $("body").data('ajaxing', false); if (data.redirect) { - async_task_on_success(data); + async_task_on_success.call(this, data); return; } async_task_id = data.async_id; async_task_check_url = data.check_url; - async_task_timeout = window.setTimeout(async_task_check, 100); + async_task_schedule_check(this, 100); if (async_task_is_long) { if (data.started) { @@ -160,9 +182,9 @@ function async_task_callback(data, jqXHR, status) { function async_task_error(jqXHR, textStatus, errorThrown) { "use strict"; $("body").data('ajaxing', false); + waitingDialog.hide(); if (textStatus === "timeout") { alert(gettext("The request took too long. Please try again.")); - waitingDialog.hide(); } else if (jqXHR.responseText.indexOf(' 0) { var respdom = $(jqXHR.responseText); var c = respdom.filter('.container'); @@ -180,18 +202,14 @@ function async_task_error(jqXHR, textStatus, errorThrown) { } else if (c.length > 0) { // This is some kind of 500/404/403 page, show it in an overlay - waitingDialog.hide(); ajaxErrDialog.show(c.first().html()); } else { - waitingDialog.hide(); alert(gettext('An error of type {code} occurred.').replace(/\{code\}/, jqXHR.status)); } } else { if (jqXHR.status >= 400 && jqXHR.status < 500) { - waitingDialog.hide(); alert(gettext('An error of type {code} occurred.').replace(/\{code\}/, jqXHR.status)); } else { - waitingDialog.hide(); alert(gettext('We currently cannot reach the server. Please try again. ' + 'Error code: {code}').replace(/\{code\}/, jqXHR.status)); } @@ -215,6 +233,7 @@ $(function () { } async_task_id = null; async_task_is_download = $(this).is("[data-asynctask-download]"); + async_task_dont_redirect = $(this).is("[data-asynctask-no-redirect]"); async_task_is_long = $(this).is("[data-asynctask-long]"); async_task_old_url = location.href; $("body").data('ajaxing', true); diff --git a/src/pretix/static/pretixpresale/js/ui/cart.js b/src/pretix/static/pretixpresale/js/ui/cart.js index bdb4b14c52..98d72ce4f0 100644 --- a/src/pretix/static/pretixpresale/js/ui/cart.js +++ b/src/pretix/static/pretixpresale/js/ui/cart.js @@ -33,8 +33,9 @@ var cart = { return; } var now = cart._get_now(); - var diff_minutes = Math.floor(cart._deadline.diff(now) / 1000 / 60); - var diff_seconds = Math.floor(cart._deadline.diff(now) / 1000 % 60); + var diff_total_seconds = cart._deadline.diff(now) / 1000; + var diff_minutes = Math.floor(diff_total_seconds / 60); + var diff_seconds = Math.floor(diff_total_seconds % 60); if (diff_minutes < 2 || diff_minutes == 5) $("#cart-deadline").get(0).setAttribute("aria-live", "polite"); else $("#cart-deadline").get(0).removeAttribute("aria-live"); @@ -45,6 +46,7 @@ var cart = { gettext("Cart expired") ); window.clearInterval(cart._deadline_interval); + cart._deadline_interval = null; } else { $("#cart-deadline").text(ngettext( "The items in your cart are reserved for you for one minute.", @@ -56,13 +58,26 @@ var cart = { ); } $("#cart-extend-button").toggle(diff_minutes < 3); + var can_extend_cart = diff_minutes < 3 && (diff_total_seconds < 0 || cart._deadline < cart._max_extend); + $("#cart-extend-button").toggle(can_extend_cart); }, init: function () { "use strict"; - cart._deadline = moment($("#cart-deadline").attr("data-expires")); - cart._deadline_interval = window.setInterval(cart.draw_deadline, 500); cart._calc_offset(); + cart.set_deadline( + $("#cart-deadline").attr("data-expires"), + $("#cart-deadline").attr("data-max-expiry-extend") + ); + }, + + set_deadline: function (expiry, max_extend) { + "use strict"; + cart._expiry_notified = false; + cart._deadline = moment(expiry); + cart._max_extend = moment(max_extend); + if (!cart._deadline_interval) + cart._deadline_interval = window.setInterval(cart.draw_deadline, 500); cart.draw_deadline(); } }; @@ -74,6 +89,10 @@ $(function () { cart.init(); } + $("#cart-extend-form").on("pretix:async-task-success", function(e, data, x, y, z) { + cart.set_deadline(data.expiry, data.max_expiry_extend); + }); + $(".toggle-container").each(function() { var summary = $(".toggle-summary", this); var content = $("> :not(.toggle-summary)", this);