From d15706f36fedad1cc11b8115bfc0e65966d5f7bd Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 27 Jul 2026 22:51:03 +0200 Subject: [PATCH] Move default statistics to plugin --- doc/development/api/general.rst | 2 +- src/pretix/control/signals.py | 39 +-- .../event/dashboard_partial_waiting.html | 11 + .../templates/pretixcontrol/event/index.html | 52 +--- src/pretix/control/urls.py | 2 +- src/pretix/control/views/dashboards.py | 223 ++---------------- src/pretix/control/views/subevents.py | 2 + src/pretix/plugins/statistics/signals.py | 70 +++++- .../pretixplugins/statistics/dashboard.html | 15 ++ .../static/pretixcontrol/js/ui/dashboard.js | 11 - src/pretix/static/pretixcontrol/js/ui/main.js | 8 +- 11 files changed, 146 insertions(+), 289 deletions(-) create mode 100644 src/pretix/control/templates/pretixcontrol/event/dashboard_partial_waiting.html create mode 100644 src/pretix/plugins/statistics/templates/pretixplugins/statistics/dashboard.html diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index 75bef4474..568b61819 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -83,7 +83,7 @@ Dashboards .. automodule:: pretix.control.signals :no-index: - :members: event_dashboard_widgets, event_dashboard_widgets_override, user_dashboard_widgets, event_dashboard_top + :members: event_dashboard_statistics, user_dashboard_widgets, event_dashboard_top Ticket designs """""""""""""" diff --git a/src/pretix/control/signals.py b/src/pretix/control/signals.py index 183542675..870a9239e 100644 --- a/src/pretix/control/signals.py +++ b/src/pretix/control/signals.py @@ -133,47 +133,14 @@ This signal is sent out to include custom HTML in the top part of the the event Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped. As with all event plugin signals, the ``sender`` keyword argument will contain the event. -An additional keyword argument ``subevent`` *can* contain a sub-event. """ -event_dashboard_widgets = EventPluginSignal() +event_dashboard_statistics = 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 (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) -* url (str, optional, if the full widget should be a link) +This signal is sent out to include statistical content on the event dashboard. +Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped. As with all event plugin signals, the ``sender`` keyword argument will contain the event. -An additional keyword argument ``subevent`` *can* contain a sub-event. - -The keyword argument ``widgets_override_active`` is set to ``True`` when a plugin -has returned content from :py:data:`event_dashboard_widgets_override` and is taking -over the KPI section of the dashboard. Receivers that produce widgets which would -be redundant with such a replacement (e.g. the built-in attendees/revenue/products -KPI tiles) should return an empty list in that case. Receivers that provide widgets -unrelated to the KPI tiles (waitinglist, quotas, check-in lists, shop state, etc.) -should ignore the flag and continue to return their widgets — those will render -below the override block. -""" - -event_dashboard_widgets_override = EventPluginSignal() -""" -This signal allows a plugin to take over the KPI section of the event dashboard -with custom HTML. Receivers should return an HTML string (or an empty string / -``None`` to opt out). If any receiver returns non-empty HTML, that HTML is -rendered at the top of the dashboard, and receivers of -:py:data:`event_dashboard_widgets` are informed via the -``widgets_override_active=True`` keyword argument so they can suppress widgets -that the override replaces. The remaining widgets (those that don't opt out) -still render below the override block. Multiple receivers' return values are -concatenated. - -As with all event plugin signals, the ``sender`` keyword argument will contain -the event. An additional keyword argument ``subevent`` *can* contain a sub-event. """ user_dashboard_widgets = GlobalSignal() diff --git a/src/pretix/control/templates/pretixcontrol/event/dashboard_partial_waiting.html b/src/pretix/control/templates/pretixcontrol/event/dashboard_partial_waiting.html new file mode 100644 index 000000000..93d7b0bb0 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/event/dashboard_partial_waiting.html @@ -0,0 +1,11 @@ +{% load i18n %} +{% load humanize %} +{% if count %} + + {% blocktrans trimmed with number=count|intcomma count count=count %} + {{ number }} person waiting + {% plural %} + {{ number }} persons waiting + {% endblocktrans %} + +{% endif %} \ No newline at end of file diff --git a/src/pretix/control/templates/pretixcontrol/event/index.html b/src/pretix/control/templates/pretixcontrol/event/index.html index 649467baf..dd0cc12b1 100644 --- a/src/pretix/control/templates/pretixcontrol/event/index.html +++ b/src/pretix/control/templates/pretixcontrol/event/index.html @@ -71,16 +71,25 @@ hx-indicator="#warnings_indicator" hx-trigger="load"> + {% if stats %} + {{ stats }} + {% endif %} +

 

+ {% if not request.event.items.exists %} {% include "pretixcontrol/event/dashboard_partial_welcome.html" %} - {% endif %} - - {% if not request.event.has_subevents %} + {% elif not request.event.has_subevents %} {% include "pretixcontrol/event/fragment_timeline.html" %}
+
+
+ +
+

{% trans "Quotas" %}

@@ -124,43 +133,6 @@
{% endif %} - {% if widgets_override and 0 %} - {{ widgets_override|safe }} - {% endif %} - {% if widgets %} -
- {% for w in widgets %} -
- {% if w.url %}{# backwards compatibility #} - - {% if w.lazy %} - - {% else %} - {{ w.content }} - {% endif %} - - {% elif w.link %} - - {% if w.lazy %} - - {% else %} - {{ w.content }} - {% endif %} - - {% else %} -
- {% if w.lazy %} - - {% else %} - {{ w.content }} - {% endif %} -
- {% endif %} -
- {% endfor %} -
- {% endif %} -

 

{% if "event.orders:read" in request.eventpermset or "event.orders:write" in request.eventpermset or "event.settings.general:write" in request.eventpermset or "event.items:write" in request.eventpermset %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index d41e11809..79e2235ae 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -272,10 +272,10 @@ urlpatterns = [ re_path(r'^event/(?P[^/]+)/(?P[^/]+)/', include([ re_path(r'^$', dashboards.event_index, name='event.index'), re_path(r'^qrcode.(?P(png|jpeg|gif|svg))$', event.EventQRCode.as_view(), name='event.qrcode'), - re_path(r'^widgets.json$', dashboards.event_index_widgets_lazy, name='event.index.widgets'), re_path(r'^dashboard/partials/logs$', dashboards.event_index_log_lazy, name='event.index.logs'), re_path(r'^dashboard/partials/warnings$', dashboards.event_index_warnings_lazy, name='event.index.warnings'), re_path(r'^dashboard/partials/quotas$', dashboards.event_index_quotas_lazy, name='event.index.quotas'), + re_path(r'^dashboard/partials/waiting$', dashboards.event_index_waiting_lazy, name='event.index.waiting'), re_path(r'^dashboard/partials/checkin$', dashboards.event_index_checkin_lazy, name='event.index.checkin'), re_path(r'^dashboard/partials/comment$', event.EventComment.as_view(), name='event.index.comment'), re_path(r'^live/$', event.EventLive.as_view(), name='event.live'), diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index f3b979632..d8411ea79 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -33,192 +33,48 @@ # License for the specific language governing permissions and limitations under the License. from datetime import timedelta -from decimal import Decimal from zoneinfo import ZoneInfo -from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.contrib.humanize.templatetags.humanize import intcomma from django.db.models import ( - Count, IntegerField, Max, Min, OuterRef, Prefetch, Q, Subquery, Sum, + Count, IntegerField, Max, Min, OuterRef, Q, Subquery, ) from django.db.models.functions import Coalesce, Greatest -from django.dispatch import receiver from django.http import Http404, JsonResponse from django.shortcuts import render from django.urls import reverse from django.utils.formats import date_format -from django.utils.html import conditional_escape, escape, format_html +from django.utils.html import ( + conditional_escape, escape, format_html, format_html_join, +) from django.utils.timezone import now from django.utils.translation import gettext_lazy as _, ngettext, pgettext -from pretix.base.decimal import round_decimal from pretix.base.models import ( - Item, ItemCategory, ItemVariation, Order, OrderPosition, OrderRefund, - Question, Quota, SubEvent, Voucher, WaitingListEntry, + Item, ItemCategory, Order, OrderRefund, Question, Quota, Voucher, + WaitingListEntry, ) from pretix.base.timeline import timeline_for_event from pretix.control.signals import ( - event_dashboard_widgets, event_dashboard_widgets_override, - user_dashboard_widgets, + event_dashboard_statistics, user_dashboard_widgets, ) from pretix.helpers.daterange import daterange from ...base.models.orders import CancellationRequest from ...base.models.organizer import TeamQuerySet -from ...base.templatetags.money import money_filter from ..logdisplay import OVERVIEW_BANLIST from .utils import prepare_quotas_for_boxes -NUM_WIDGET = '
{num}{text}
' - -@receiver(signal=event_dashboard_widgets) -def base_widgets(sender, subevent=None, lazy=False, widgets_override_active=False, **kwargs): - if widgets_override_active: - return [] - if not lazy: - if subevent: - opqs = OrderPosition.objects.filter(subevent=subevent) - else: - opqs = OrderPosition.objects - - tickc = opqs.filter( - order__event=sender, item__admission=True, - order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING), - ).count() - - paidc = opqs.filter( - order__event=sender, item__admission=True, - order__status=Order.STATUS_PAID, - ).count() - - if subevent: - rev = opqs.filter( - order__event=sender, order__status=Order.STATUS_PAID - ).aggregate( - sum=Sum('price') - )['sum'] or Decimal('0.00') - else: - rev = Order.objects.filter( - event=sender, - status=Order.STATUS_PAID - ).aggregate(sum=Sum('total'))['sum'] or Decimal('0.00') - - return [ +def event_index_waiting_lazy(request, organizer, event): + wles = WaitingListEntry.objects.filter(event=request.event, voucher__isnull=True) + return render( + request, + 'pretixcontrol/event/dashboard_partial_waiting.html', { - 'content': None if lazy else format_html(NUM_WIDGET, num=intcomma(tickc), text=_('Attendees (ordered)')), - 'lazy': 'attendees-ordered', - 'display_size': 'small', - 'priority': 100, - 'url': reverse('control:event.orders', kwargs={ - 'event': sender.slug, - 'organizer': sender.organizer.slug - }) + ('?subevent={}'.format(subevent.pk) if subevent else '') - }, - { - 'content': None if lazy else format_html(NUM_WIDGET, num=intcomma(paidc), text=_('Attendees (paid)')), - 'lazy': 'attendees-paid', - 'display_size': 'small', - 'priority': 100, - 'url': reverse('control:event.orders.overview', kwargs={ - 'event': sender.slug, - 'organizer': sender.organizer.slug - }) + ('?subevent={}'.format(subevent.pk) if subevent else '') - }, - { - 'content': None if lazy else format_html( - NUM_WIDGET, - num=money_filter(round_decimal(rev, sender.currency), sender.currency, hide_currency=True), - text=_('Total revenue ({currency})').format(currency=sender.currency) - ), - 'lazy': 'total-revenue', - 'display_size': 'small', - 'priority': 100, - 'url': reverse('control:event.orders.overview', kwargs={ - 'event': sender.slug, - 'organizer': sender.organizer.slug - }) + ('?subevent={}'.format(subevent.pk) if subevent else '') - }, - ] - - -@receiver(signal=event_dashboard_widgets) -def waitinglist_widgets(sender, subevent=None, lazy=False, **kwargs): - widgets = [] - - wles = WaitingListEntry.objects.filter(event=sender, subevent=subevent, voucher__isnull=True) - if wles.exists(): - if not lazy: - quota_cache = {} - happy = 0 - tuples = wles.values('item', 'variation').order_by().annotate(cnt=Count('id')) - - items = { - i.pk: i for i in sender.items.filter(id__in=[t['item'] for t in tuples]).prefetch_related( - Prefetch('quotas', - to_attr='_subevent_quotas', - queryset=sender.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)), - ) - } - vars = { - i.pk: i for i in ItemVariation.objects.filter( - item__event=sender, id__in=[t['variation'] for t in tuples if t['variation']] - ).prefetch_related( - Prefetch('quotas', - to_attr='_subevent_quotas', - queryset=sender.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)), - ) - } - - for wlt in tuples: - item = items.get(wlt['item']) - variation = vars.get(wlt['variation']) - if not item: - continue - quotas = ( - variation._get_quotas(subevent=subevent) - if variation - else item._get_quotas(subevent=subevent) - ) - row = ( - variation.check_quotas(subevent=subevent, count_waitinglist=False, _cache=quota_cache) - if variation - else item.check_quotas(subevent=subevent, count_waitinglist=False, _cache=quota_cache) - ) - if row[1] is None: - happy += wlt['cnt'] - elif row[1] > 0: - happy += min(wlt['cnt'], row[1]) - for q in quotas: - if q.size is not None: - quota_cache[q.pk] = (quota_cache[q.pk][0], quota_cache[q.pk][1] - min(wlt['cnt'], row[1])) - - widgets.append({ - 'content': None if lazy else format_html( - NUM_WIDGET, num=intcomma(happy), text=_('available to give to people on waiting list') - ), - 'lazy': 'waitinglist-avail', - 'priority': 50, - 'url': reverse('control:event.orders.waitinglist', kwargs={ - 'event': sender.slug, - 'organizer': sender.organizer.slug, - }) - }) - widgets.append({ - 'content': None if lazy else format_html( - NUM_WIDGET, num=intcomma(wles.count()), text=_('total waiting list length') - ), - 'lazy': 'waitinglist-length', - 'display_size': 'small', - 'priority': 50, - 'url': reverse('control:event.orders.waitinglist', kwargs={ - 'event': sender.slug, - 'organizer': sender.organizer.slug, - }) - }) - - return widgets + 'count': wles.count, + } + ) def build_json_response(widgets): @@ -228,33 +84,20 @@ def build_json_response(widgets): def event_index(request, organizer, event): - subevent = None - if request.GET.get("subevent", "") != "" and request.event.has_subevents: - i = request.GET.get("subevent", "") - try: - subevent = request.event.subevents.get(pk=i) - except SubEvent.DoesNotExist: - pass + can_view_orders = request.user.has_event_permission( + request.organizer, + request.event, + 'event.orders:read', + request=request + ) - can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'event.orders:read', - request=request) - - widgets = [] - widgets_override = '' + stats = [] if can_view_orders: - for r, result in event_dashboard_widgets_override.send(sender=request.event, subevent=subevent, request=request): - if result: - widgets_override += result - for r, result in event_dashboard_widgets.send( - sender=request.event, subevent=subevent, lazy=True, - widgets_override_active=bool(widgets_override), - ): - widgets.extend(result) + for r, result in event_dashboard_statistics.send(sender=request.event, request=request): + stats.append(result) ctx = { - 'widgets': rearrange(widgets), - 'widgets_override': widgets_override, - 'subevent': subevent, + 'stats': format_html_join("", "{}", [(s,) for s in stats]), } if not request.event.has_subevents: @@ -273,22 +116,6 @@ def event_index(request, organizer, event): return resp -def event_index_widgets_lazy(request, organizer, event): - subevent = None - if request.GET.get("subevent", "") != "" and request.event.has_subevents: - i = request.GET.get("subevent", "") - try: - subevent = request.event.subevents.get(pk=i) - except SubEvent.DoesNotExist: - pass - - widgets = [] - for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=False): - widgets.extend(result) - - return build_json_response(widgets) - - def event_index_warnings_lazy(request, organizer, event): can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'event.orders:read', request=request) diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index 12022c78c..95e9ca06c 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -564,6 +564,8 @@ class SubEventDetail(EventPermissionRequiredMixin, DetailView): } for t in timeline_for_event(self.request.event, self.object) ] + ctx['today'] = now().astimezone(self.request.event.timezone).date() + ctx['nearly_now'] = now().astimezone(self.request.event.timezone) - timedelta(seconds=20) return super().get_context_data( **kwargs, diff --git a/src/pretix/plugins/statistics/signals.py b/src/pretix/plugins/statistics/signals.py index 8c77d9ff6..80e778ecc 100644 --- a/src/pretix/plugins/statistics/signals.py +++ b/src/pretix/plugins/statistics/signals.py @@ -19,12 +19,21 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +from decimal import Decimal + +from django.contrib.humanize.templatetags.humanize import intcomma +from django.db.models import Sum from django.dispatch import receiver +from django.template.loader import get_template from django.urls import resolve, reverse +from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ +from pretix.base.decimal import round_decimal +from pretix.base.models import Order, OrderPosition from pretix.base.signals import order_paid, order_placed -from pretix.control.signals import nav_event +from pretix.base.templatetags.money import money_filter +from pretix.control.signals import event_dashboard_statistics, nav_event @receiver(nav_event, dispatch_uid="statistics_nav") @@ -58,3 +67,62 @@ def clear_cache(sender, *args, **kwargs): order_placed.connect(clear_cache) order_paid.connect(clear_cache) + + +@receiver(event_dashboard_statistics, dispatch_uid="statistics_event_dashboard_state") +def event_dashboard_stats_receivers(sender, request, **kwargs): + if len(event_dashboard_statistics.receivers) > 1: + # Only render this as a fallback if no other plugins exist + return "" + + opqs = OrderPosition.objects + tickc = opqs.filter( + order__event=sender, item__admission=True, + order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING), + ).count() + paidc = opqs.filter( + order__event=sender, item__admission=True, + order__status=Order.STATUS_PAID, + ).count() + rev = Order.objects.filter( + event=sender, + status=Order.STATUS_PAID + ).aggregate(sum=Sum('total'))['sum'] or Decimal('0.00') + num_widget = '
{num}{text}
' + + widgets = [ + { + 'content': format_html(num_widget, num=intcomma(tickc), text=_('Attendees (ordered)')), + 'display_size': 'small', + 'priority': 100, + 'url': reverse('control:event.orders', kwargs={ + 'event': sender.slug, + 'organizer': sender.organizer.slug + }) + }, + { + 'content': format_html(num_widget, num=intcomma(paidc), text=_('Attendees (paid)')), + 'display_size': 'small', + 'priority': 100, + 'url': reverse('control:event.orders.overview', kwargs={ + 'event': sender.slug, + 'organizer': sender.organizer.slug + }) + }, + { + 'content': format_html( + num_widget, + num=money_filter(round_decimal(rev, sender.currency), sender.currency, hide_currency=True), + text=_('Total revenue ({currency})').format(currency=sender.currency) + ), + 'display_size': 'small', + 'priority': 100, + 'url': reverse('control:event.orders.overview', kwargs={ + 'event': sender.slug, + 'organizer': sender.organizer.slug + }) + }, + ] + template = get_template('pretixplugins/statistics/dashboard.html') + ctx = {'request': request, 'widgets': widgets} + return template.render(ctx) diff --git a/src/pretix/plugins/statistics/templates/pretixplugins/statistics/dashboard.html b/src/pretix/plugins/statistics/templates/pretixplugins/statistics/dashboard.html new file mode 100644 index 000000000..9015d8869 --- /dev/null +++ b/src/pretix/plugins/statistics/templates/pretixplugins/statistics/dashboard.html @@ -0,0 +1,15 @@ +
+ {% for w in widgets %} +
+ {% if w.url %} + + {{ w.content }} + + {% else %} +
+ {{ w.content }} +
+ {% endif %} +
+ {% endfor %} +
\ No newline at end of file diff --git a/src/pretix/static/pretixcontrol/js/ui/dashboard.js b/src/pretix/static/pretixcontrol/js/ui/dashboard.js index 993d833d7..ef1244dc7 100644 --- a/src/pretix/static/pretixcontrol/js/ui/dashboard.js +++ b/src/pretix/static/pretixcontrol/js/ui/dashboard.js @@ -1,16 +1,5 @@ /* global $,gettext */ -$(function () { - if ($('div[data-lazy-id]').length == 0) { - return - } - $.getJSON('widgets.json' + ($('select[name=\'subevent\']').val() ? '?subevent=' + $('select[name=\'subevent\']').val() : ''), function (data) { - $.each(data.widgets, function (k, v) { - $('[data-lazy-id=' + v.lazy + ']').removeClass('widget-lazy-loading') - $('[data-lazy-id=' + v.lazy + '] .widget').html(v.content) - }) - }) -}) $(function () { $('.timeline').each(function () { let $tl = $(this) diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js index 7ebefa0bb..44e95f9a8 100644 --- a/src/pretix/static/pretixcontrol/js/ui/main.js +++ b/src/pretix/static/pretixcontrol/js/ui/main.js @@ -1,4 +1,4 @@ -/*global $, gettext, ngettext, interpolate */ +/*global $, gettext, ngettext, interpolate, htmx */ function formatPrice(price, currency, locale) { if (!window.Intl || !Intl.NumberFormat) return price; @@ -1120,3 +1120,9 @@ $(function () { return $(this).find("button:not([type=button]), input[type=submit]").length > 0; }).areYouSure( {'message': gettext('You have unsaved changes!')}); }); + +htmx.on('htmx:afterSettle', (e) => { + console.log("afterSwap", e) + setup_basics($(e.detail.elt)) + form_handlers($(e.detail.elt)) +})