From 6b65cb4e33e6fb51723f1e5059912c67c14fc504 Mon Sep 17 00:00:00 2001
From: Kian Cross
Date: Fri, 16 Jan 2026 15:57:04 +0000
Subject: [PATCH] Add daily and cumulative attendee graphs to the order
statistics page (#5792)
The order statistics page previously only showed order-based graphs. This change
adds attendee-based daily and cumulative graphs.
---
.../pretixplugins/statistics/statistics.js | 24 ++++++++++
.../pretixplugins/statistics/index.html | 42 +++++++++++++++++
src/pretix/plugins/statistics/views.py | 46 +++++++++++++++++++
3 files changed, 112 insertions(+)
diff --git a/src/pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js b/src/pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js
index 33c2b3319d..0fdd457057 100644
--- a/src/pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js
+++ b/src/pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js
@@ -19,6 +19,30 @@ $(function () {
fillOpacity: 0.3,
behaveLikeLine: true
});
+ new Morris.Area({
+ element: 'abd_chart',
+ data: JSON.parse($("#abd-data").html()),
+ xkey: 'date',
+ ykeys: ['ordered', 'paid'],
+ labels: [gettext('Attendees (ordered)'), gettext('Attendees (paid)')],
+ lineColors: ['#3b1c4a', '#50a167'],
+ smooth: false,
+ resize: true,
+ fillOpacity: 0.3,
+ behaveLikeLine: true
+ });
+ new Morris.Area({
+ element: 'abt_chart',
+ data: JSON.parse($("#abt-data").html()),
+ xkey: 'date',
+ ykeys: ['ordered', 'paid'],
+ labels: [gettext('Attendees (ordered)'), gettext('Attendees (paid)')],
+ lineColors: ['#3b1c4a', '#50a167'],
+ smooth: false,
+ resize: true,
+ fillOpacity: 0.3,
+ behaveLikeLine: true
+ });
new Morris.Area({
element: 'rev_chart',
data: JSON.parse($("#rev-data").html()),
diff --git a/src/pretix/plugins/statistics/templates/pretixplugins/statistics/index.html b/src/pretix/plugins/statistics/templates/pretixplugins/statistics/index.html
index d6d3601d41..db7f08b89c 100644
--- a/src/pretix/plugins/statistics/templates/pretixplugins/statistics/index.html
+++ b/src/pretix/plugins/statistics/templates/pretixplugins/statistics/index.html
@@ -31,6 +31,46 @@
+
+
+
{% trans "Attendees by day" %}
+
+
+
+
+
+ {% blocktrans trimmed %}
+ Attendees in orders paid in multiple instalments are shown using the date of the
+ final payment. Order dates reflect when the order was first placed; attendees added
+ later via additional order positions still use the original order date. Attendees in
+ placed orders include those from all order states (pending, paid, cancelled, and
+ expired); attendees in paid orders include only those from paid orders and exclude
+ those from cancelled orders.
+ {% endblocktrans %}
+
+
+
+
+
+
+
{% trans "Attendees by time" %}
+
+
+
+
+
+ {% blocktrans trimmed %}
+ Attendees in orders paid in multiple instalments are shown using the date of the
+ final payment. Order dates reflect when the order was first placed; attendees added
+ later via additional order positions still use the original order date. Attendees in
+ placed orders include those from all order states (pending, paid, cancelled, and
+ expired); attendees in paid orders include only those from paid orders and exclude
+ those from cancelled orders.
+ {% endblocktrans %}
+
+
+
+
{% trans "Revenue over time" %}
@@ -177,6 +217,8 @@
{% endif %}
+
+
diff --git a/src/pretix/plugins/statistics/views.py b/src/pretix/plugins/statistics/views.py
index 7eacdd4f9e..17e9b48330 100644
--- a/src/pretix/plugins/statistics/views.py
+++ b/src/pretix/plugins/statistics/views.py
@@ -128,6 +128,52 @@ class IndexView(EventPermissionRequiredMixin, ChartContainingView, TemplateView)
ctx['obd_data'] = json.dumps(data)
cache.set('statistics_obd_data' + ckey, ctx['obd_data'])
+ # Attendees by day/time
+ ctx['abd_data'] = cache.get('statistics_abd_data' + ckey)
+ ctx['abt_data'] = cache.get('statistics_abt_data' + ckey)
+ if not ctx['abd_data'] or not ctx['abt_data']:
+ opqs = OrderPosition.all.filter(order__event=self.request.event, item__admission=True).annotate(
+ payment_date=Subquery(op_date, output_field=DateTimeField())
+ )
+ if subevent:
+ opqs = opqs.filter(subevent=subevent)
+
+ ordered_by_day = {}
+ for p in opqs.values('order__datetime'):
+ day = p['order__datetime'].astimezone(tz).date()
+ ordered_by_day[day] = ordered_by_day.get(day, 0) + 1
+
+ paid_by_day = {}
+ for p in opqs.filter(payment_date__isnull=False, canceled=False, order__status=Order.STATUS_PAID).values('payment_date'):
+ day = p['payment_date'].astimezone(tz).date()
+ paid_by_day[day] = paid_by_day.get(day, 0) + 1
+
+ day_data = []
+ time_data = []
+ for d in dateutil.rrule.rrule(
+ dateutil.rrule.DAILY,
+ dtstart=min(ordered_by_day.keys()) if ordered_by_day else datetime.date.today(),
+ until=max(
+ max(ordered_by_day.keys() if paid_by_day else [datetime.date.today()]),
+ max(paid_by_day.keys() if paid_by_day else [datetime.date(1970, 1, 1)])
+ )):
+ d = d.date()
+ day_data.append({
+ 'date': d.strftime('%Y-%m-%d'),
+ 'ordered': ordered_by_day.get(d, 0),
+ 'paid': paid_by_day.get(d, 0)
+ })
+ time_data.append({
+ 'date': d.strftime('%Y-%m-%d'),
+ 'ordered': (time_data[-1]["ordered"] if time_data else 0) + ordered_by_day.get(d, 0),
+ 'paid': (time_data[-1]["paid"] if time_data else 0) + paid_by_day.get(d, 0)
+ })
+
+ ctx['abd_data'] = json.dumps(day_data)
+ ctx['abt_data'] = json.dumps(time_data)
+ cache.set('statistics_abd_data' + ckey, ctx['abd_data'])
+ cache.set('statistics_abt_data' + ckey, ctx['abt_data'])
+
# Orders by product
ctx['obp_data'] = cache.get('statistics_obp_data' + ckey)
if not ctx['obp_data']: