pretix-event-analytics 2.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pretix_event_analytics/__init__.py +24 -0
- pretix_event_analytics/_compat.py +32 -0
- pretix_event_analytics/_safety.py +53 -0
- pretix_event_analytics/admin.py +77 -0
- pretix_event_analytics/apps.py +17 -0
- pretix_event_analytics/exporters.py +353 -0
- pretix_event_analytics/forms.py +381 -0
- pretix_event_analytics/locale/README.md +19 -0
- pretix_event_analytics/management/__init__.py +0 -0
- pretix_event_analytics/management/commands/__init__.py +0 -0
- pretix_event_analytics/management/commands/analytics_resync.py +176 -0
- pretix_event_analytics/management/commands/generate_test_data.py +518 -0
- pretix_event_analytics/migrations/0001_initial.py +238 -0
- pretix_event_analytics/migrations/0002_analyticsidentity_and_more.py +135 -0
- pretix_event_analytics/migrations/0003_audit_fixes.py +60 -0
- pretix_event_analytics/migrations/0004_performance_and_integrity.py +42 -0
- pretix_event_analytics/migrations/0005_suite_v2.py +233 -0
- pretix_event_analytics/migrations/0006_people_resolved_at.py +18 -0
- pretix_event_analytics/migrations/0007_sales_annotations.py +30 -0
- pretix_event_analytics/migrations/0008_pace_alerts.py +28 -0
- pretix_event_analytics/migrations/0009_accuracy_2_1.py +93 -0
- pretix_event_analytics/migrations/0010_nationality_bank_country.py +38 -0
- pretix_event_analytics/migrations/__init__.py +0 -0
- pretix_event_analytics/models.py +520 -0
- pretix_event_analytics/services/__init__.py +0 -0
- pretix_event_analytics/services/age_bucketer.py +140 -0
- pretix_event_analytics/services/alerts.py +114 -0
- pretix_event_analytics/services/attendance.py +118 -0
- pretix_event_analytics/services/cohort_service.py +61 -0
- pretix_event_analytics/services/country_resolver.py +261 -0
- pretix_event_analytics/services/hash_service.py +136 -0
- pretix_event_analytics/services/id_country.py +180 -0
- pretix_event_analytics/services/identity_keys.py +72 -0
- pretix_event_analytics/services/ingest.py +172 -0
- pretix_event_analytics/services/legacy.py +138 -0
- pretix_event_analytics/services/normalizer.py +425 -0
- pretix_event_analytics/services/payment_info.py +122 -0
- pretix_event_analytics/services/people.py +722 -0
- pretix_event_analytics/services/predictor.py +84 -0
- pretix_event_analytics/services/repeat_detector.py +121 -0
- pretix_event_analytics/services/reports/__init__.py +0 -0
- pretix_event_analytics/services/reports/audience.py +253 -0
- pretix_event_analytics/services/reports/charts.py +70 -0
- pretix_event_analytics/services/reports/loyalty.py +323 -0
- pretix_event_analytics/services/reports/market.py +53 -0
- pretix_event_analytics/services/reports/operations.py +170 -0
- pretix_event_analytics/services/reports/overview.py +112 -0
- pretix_event_analytics/services/reports/sales.py +491 -0
- pretix_event_analytics/services/reports/scope.py +232 -0
- pretix_event_analytics/services/reports/series.py +62 -0
- pretix_event_analytics/services/reports/tickets.py +143 -0
- pretix_event_analytics/services/resale.py +295 -0
- pretix_event_analytics/services/resync_service.py +173 -0
- pretix_event_analytics/services/van_length_bucketer.py +120 -0
- pretix_event_analytics/services/versioning.py +66 -0
- pretix_event_analytics/shredder.py +79 -0
- pretix_event_analytics/signals.py +277 -0
- pretix_event_analytics/static/pretix_event_analytics/chart.umd.min.js +19 -0
- pretix_event_analytics/static/pretix_event_analytics/dashboard.css +273 -0
- pretix_event_analytics/static/pretix_event_analytics/dashboard.js +368 -0
- pretix_event_analytics/tasks.py +225 -0
- pretix_event_analytics/templates/pretix_event_analytics/_chart.html +27 -0
- pretix_event_analytics/templates/pretix_event_analytics/base.html +132 -0
- pretix_event_analytics/templates/pretix_event_analytics/config.html +184 -0
- pretix_event_analytics/templates/pretix_event_analytics/email/pace_alert.txt +7 -0
- pretix_event_analytics/templates/pretix_event_analytics/pages/audience.html +119 -0
- pretix_event_analytics/templates/pretix_event_analytics/pages/loyalty.html +225 -0
- pretix_event_analytics/templates/pretix_event_analytics/pages/operations.html +89 -0
- pretix_event_analytics/templates/pretix_event_analytics/pages/overview.html +93 -0
- pretix_event_analytics/templates/pretix_event_analytics/pages/resale.html +75 -0
- pretix_event_analytics/templates/pretix_event_analytics/pages/sales.html +203 -0
- pretix_event_analytics/templates/pretix_event_analytics/pages/tickets.html +110 -0
- pretix_event_analytics/templates/pretix_event_analytics/pdf_report.html +132 -0
- pretix_event_analytics/templates/pretix_event_analytics/series_confirm_delete.html +38 -0
- pretix_event_analytics/templates/pretix_event_analytics/series_detail.html +116 -0
- pretix_event_analytics/templates/pretix_event_analytics/series_form.html +65 -0
- pretix_event_analytics/templates/pretix_event_analytics/series_list.html +84 -0
- pretix_event_analytics/templatetags/__init__.py +0 -0
- pretix_event_analytics/templatetags/analytics_tags.py +39 -0
- pretix_event_analytics/urls.py +38 -0
- pretix_event_analytics/views.py +600 -0
- pretix_event_analytics-2.2.1.dist-info/METADATA +354 -0
- pretix_event_analytics-2.2.1.dist-info/RECORD +87 -0
- pretix_event_analytics-2.2.1.dist-info/WHEEL +5 -0
- pretix_event_analytics-2.2.1.dist-info/entry_points.txt +2 -0
- pretix_event_analytics-2.2.1.dist-info/licenses/LICENSE +191 -0
- pretix_event_analytics-2.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from django.utils.translation import gettext_lazy as _
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from pretix.base.plugins import PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PluginConfig # noqa
|
|
5
|
+
except ImportError:
|
|
6
|
+
raise RuntimeError("Please use pretix in INSTALLED_APPS")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PretixPluginMeta:
|
|
10
|
+
name = _("Event Analytics")
|
|
11
|
+
author = "Hexadexa"
|
|
12
|
+
version = "2.2.1"
|
|
13
|
+
visible = True
|
|
14
|
+
restricted = False
|
|
15
|
+
description = _(
|
|
16
|
+
"Analytics suite: sales against previous editions, first-timers and returning people "
|
|
17
|
+
"across every edition, audience, tickets, check-in and refunds — without storing personal data."
|
|
18
|
+
)
|
|
19
|
+
category = "FEATURE"
|
|
20
|
+
# Enabled for the organizer (series pages, series-wide comparisons) and
|
|
21
|
+
# per event (dashboards, ingestion). Event-only plugins lose access to
|
|
22
|
+
# organizer-level signals in upcoming Pretix releases.
|
|
23
|
+
level = PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID
|
|
24
|
+
compatibility = "pretix>=2026.2.0"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compatibility with Pretix's permission model.
|
|
3
|
+
|
|
4
|
+
Pretix 2026.3 replaced boolean team permissions (``can_view_orders``) with
|
|
5
|
+
permission groups (``event.orders:read``). Legacy names still work there but
|
|
6
|
+
are deprecated. ``perm()`` returns the name the running Pretix expects.
|
|
7
|
+
"""
|
|
8
|
+
LEGACY_TO_NEW = {
|
|
9
|
+
"can_view_orders": "event.orders:read",
|
|
10
|
+
"can_change_event_settings": "event.settings.general:write",
|
|
11
|
+
"can_change_organizer_settings": "organizer.settings.general:write",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _has_permission_groups() -> bool:
|
|
16
|
+
try:
|
|
17
|
+
from pretix.base import permissions # noqa: F401 (introduced in 2026.3)
|
|
18
|
+
except ImportError:
|
|
19
|
+
return False
|
|
20
|
+
return True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
NEW_PERMISSIONS = _has_permission_groups()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def perm(legacy_name: str) -> str:
|
|
27
|
+
return LEGACY_TO_NEW[legacy_name] if NEW_PERMISSIONS else legacy_name
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
VIEW_ORDERS = perm("can_view_orders")
|
|
31
|
+
CHANGE_EVENT_SETTINGS = perm("can_change_event_settings")
|
|
32
|
+
CHANGE_ORGANIZER_SETTINGS = perm("can_change_organizer_settings")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Isolation invariant — machine-readable contract.
|
|
3
|
+
|
|
4
|
+
This plugin is a READ-ONLY overlay over Pretix core. It must never write,
|
|
5
|
+
update, or delete rows in the Pretix ticketing tables. All persistence
|
|
6
|
+
happens in the plugin's own models (AnalyticsOrderFact, AnalyticsTicketFact,
|
|
7
|
+
AnalyticsIdentity, EventSeries, EventAnalyticsConfig).
|
|
8
|
+
|
|
9
|
+
If a future feature genuinely needs to write something to the Pretix core
|
|
10
|
+
tables, it must be added to ALLOWED_WRITE_POINTS with a code review note
|
|
11
|
+
explaining why, and any such write must be gated behind an explicit
|
|
12
|
+
context manager so the isolation check remains useful.
|
|
13
|
+
|
|
14
|
+
The companion ``scripts/check_isolation.py`` script greps the plugin source
|
|
15
|
+
for any write-style call targeting the models below and fails if it finds
|
|
16
|
+
one that is not listed in ALLOWED_WRITE_POINTS.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# Pretix core models the plugin legitimately reads from. The checker uses
|
|
20
|
+
# this list as the set of names to watch for write calls against.
|
|
21
|
+
PRETIX_CORE_READ_MODELS = frozenset({
|
|
22
|
+
"Order",
|
|
23
|
+
"OrderPosition",
|
|
24
|
+
"OrderPayment",
|
|
25
|
+
"OrderRefund",
|
|
26
|
+
"Event",
|
|
27
|
+
"Organizer",
|
|
28
|
+
"Item",
|
|
29
|
+
"ItemVariation",
|
|
30
|
+
"Question",
|
|
31
|
+
"QuestionAnswer",
|
|
32
|
+
"Checkin",
|
|
33
|
+
"InvoiceAddress",
|
|
34
|
+
"LogEntry",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
# Write calls we refuse to see in plugin source, as substrings. The checker
|
|
38
|
+
# flags any match of <CoreModel>.objects.<method>(.
|
|
39
|
+
FORBIDDEN_WRITE_METHODS = (
|
|
40
|
+
".save(",
|
|
41
|
+
".delete(",
|
|
42
|
+
".update(",
|
|
43
|
+
".create(",
|
|
44
|
+
".update_or_create(",
|
|
45
|
+
".get_or_create(",
|
|
46
|
+
".bulk_create(",
|
|
47
|
+
".bulk_update(",
|
|
48
|
+
".raw(",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Explicit, reviewed escape hatches. Empty by design — every entry is a
|
|
52
|
+
# conscious exception. Format: (<relative path>, <line number or marker>).
|
|
53
|
+
ALLOWED_WRITE_POINTS = frozenset()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django admin registrations for analytics models.
|
|
3
|
+
Provides a fallback interface for debugging and manual data inspection.
|
|
4
|
+
Day-to-day use is via the Pretix control panel dashboard.
|
|
5
|
+
"""
|
|
6
|
+
from django.contrib import admin
|
|
7
|
+
|
|
8
|
+
from .models import AnalyticsOrderFact, AnalyticsTicketFact, EventAnalyticsConfig, EventSeries
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@admin.register(EventSeries)
|
|
12
|
+
class EventSeriesAdmin(admin.ModelAdmin):
|
|
13
|
+
list_display = ("name", "slug", "organizer", "edition_count", "created_at")
|
|
14
|
+
list_filter = ("organizer",)
|
|
15
|
+
search_fields = ("name", "slug")
|
|
16
|
+
readonly_fields = ("created_at", "updated_at")
|
|
17
|
+
|
|
18
|
+
def edition_count(self, obj):
|
|
19
|
+
return obj.event_configs.count()
|
|
20
|
+
edition_count.short_description = "Editions"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@admin.register(EventAnalyticsConfig)
|
|
24
|
+
class EventAnalyticsConfigAdmin(admin.ModelAdmin):
|
|
25
|
+
list_display = ("event", "series", "edition_year", "home_country", "is_active")
|
|
26
|
+
list_filter = ("series", "edition_year", "is_active")
|
|
27
|
+
search_fields = ("event__slug", "series__name")
|
|
28
|
+
autocomplete_fields = ("series",)
|
|
29
|
+
readonly_fields = ("created_at", "updated_at")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@admin.register(AnalyticsOrderFact)
|
|
33
|
+
class AnalyticsOrderFactAdmin(admin.ModelAdmin):
|
|
34
|
+
list_display = (
|
|
35
|
+
"order_code",
|
|
36
|
+
"event",
|
|
37
|
+
"edition_year",
|
|
38
|
+
"order_datetime",
|
|
39
|
+
"total_gross",
|
|
40
|
+
"country_code",
|
|
41
|
+
"age_range",
|
|
42
|
+
"is_repeat_buyer",
|
|
43
|
+
"repeat_count",
|
|
44
|
+
"predicted_repeat_probability",
|
|
45
|
+
"has_caravan_pass",
|
|
46
|
+
)
|
|
47
|
+
list_filter = (
|
|
48
|
+
"event",
|
|
49
|
+
"edition_year",
|
|
50
|
+
"is_repeat_buyer",
|
|
51
|
+
"is_refunded",
|
|
52
|
+
"has_caravan_pass",
|
|
53
|
+
"country_code",
|
|
54
|
+
"age_range",
|
|
55
|
+
)
|
|
56
|
+
search_fields = ("order_code", "series_slug")
|
|
57
|
+
readonly_fields = (
|
|
58
|
+
"repeat_hash",
|
|
59
|
+
"created_at",
|
|
60
|
+
"updated_at",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@admin.register(AnalyticsTicketFact)
|
|
65
|
+
class AnalyticsTicketFactAdmin(admin.ModelAdmin):
|
|
66
|
+
list_display = (
|
|
67
|
+
"item_name",
|
|
68
|
+
"variation_name",
|
|
69
|
+
"event",
|
|
70
|
+
"price",
|
|
71
|
+
"is_addon",
|
|
72
|
+
"is_caravan_pass",
|
|
73
|
+
"camper_van_length_bucket",
|
|
74
|
+
"age_range",
|
|
75
|
+
)
|
|
76
|
+
list_filter = ("event", "is_addon", "is_caravan_pass", "age_range")
|
|
77
|
+
search_fields = ("item_name", "order_fact__order_code")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.utils.translation import gettext_lazy as _
|
|
3
|
+
|
|
4
|
+
from . import PretixPluginMeta as _PluginMeta
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PluginApp(AppConfig):
|
|
8
|
+
name = "pretix_event_analytics"
|
|
9
|
+
verbose_name = "Event Analytics"
|
|
10
|
+
|
|
11
|
+
# Expose PretixPluginMeta on the AppConfig so Pretix can discover it
|
|
12
|
+
PretixPluginMeta = _PluginMeta
|
|
13
|
+
|
|
14
|
+
def ready(self):
|
|
15
|
+
from . import signals # noqa – registers signal handlers
|
|
16
|
+
from . import shredder # noqa – registers GDPR data shredder
|
|
17
|
+
from . import exporters # noqa – registers Pretix export-menu exporters
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exports.
|
|
3
|
+
|
|
4
|
+
Dashboard downloads (respect the current filter bar):
|
|
5
|
+
orders CSV one row per order fact
|
|
6
|
+
tickets CSV one row per ticket/add-on
|
|
7
|
+
loyalty CSV order codes with their returning status and edition history —
|
|
8
|
+
lets organisers look people up in Pretix without the plugin
|
|
9
|
+
ever storing names or addresses
|
|
10
|
+
PDF printable summary of every dashboard section
|
|
11
|
+
|
|
12
|
+
Pretix "Export" menu integration (CSV/Excel via ListExporter):
|
|
13
|
+
Analytics: order facts / ticket facts
|
|
14
|
+
"""
|
|
15
|
+
import csv
|
|
16
|
+
import re
|
|
17
|
+
from collections import OrderedDict
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
from django import forms
|
|
21
|
+
from django.dispatch import receiver
|
|
22
|
+
from django.http import HttpResponse, StreamingHttpResponse
|
|
23
|
+
from django.template.loader import render_to_string
|
|
24
|
+
from django.utils.translation import gettext as _, gettext_lazy
|
|
25
|
+
|
|
26
|
+
from pretix.base.exporter import ListExporter
|
|
27
|
+
from pretix.base.signals import register_data_exporters
|
|
28
|
+
|
|
29
|
+
from .models import AnalyticsOrderFact, AnalyticsTicketFact
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _safe_filename(slug: str, ext: str) -> str:
|
|
33
|
+
"""
|
|
34
|
+
Sanitize a slug for use in Content-Disposition filenames: strictly
|
|
35
|
+
[A-Za-z0-9_-]+, truncated to 50 chars, plus timestamp and extension.
|
|
36
|
+
"""
|
|
37
|
+
if not isinstance(slug, str) or "\0" in slug or ".." in slug or "/" in slug or "\\" in slug:
|
|
38
|
+
slug = "event"
|
|
39
|
+
clean = re.sub(r"[^A-Za-z0-9_-]", "_", slug)[:50] or "event"
|
|
40
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
41
|
+
safe_ext = re.sub(r"[^A-Za-z0-9]", "", ext)[:8] or "bin"
|
|
42
|
+
return f"analytics_{clean}_{timestamp}.{safe_ext}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _yn(v):
|
|
46
|
+
return "" if v is None else ("yes" if v else "no")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dt(v, tz=None):
|
|
50
|
+
if not v:
|
|
51
|
+
return ""
|
|
52
|
+
return (v.astimezone(tz) if tz else v).strftime("%Y-%m-%d %H:%M:%S")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Row builders (shared by dashboard downloads and Pretix exporters) ─────────
|
|
56
|
+
|
|
57
|
+
ORDER_HEADER = [
|
|
58
|
+
"event", "edition_year", "series", "order_code", "order_datetime", "payment_datetime", "days_before_event",
|
|
59
|
+
"order_status", "total_gross", "total_net", "tax_amount", "fees_total", "refunded_amount", "canceled_at",
|
|
60
|
+
"currency", "ticket_count", "addon_count", "is_group_order", "voucher_used", "payment_provider", "is_refunded",
|
|
61
|
+
"country_code", "city", "postal_code", "is_local_buyer", "age_range", "is_age_confirmed", "language",
|
|
62
|
+
"has_caravan_pass", "camper_van_length_bucket", "is_repeat_buyer", "repeat_from_last_edition", "repeat_count",
|
|
63
|
+
"first_seen_edition_year", "editions_attended", "checkin_completed", "predicted_repeat_probability",
|
|
64
|
+
"country_source", "country_inferred", "country_inferred_source", "travel_country_code",
|
|
65
|
+
"buyer_nationality", "bank_country", "bank_country_source",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def order_rows(qs, tz=None):
|
|
70
|
+
yield ORDER_HEADER
|
|
71
|
+
for f in qs.select_related("event").order_by("event_id", "order_datetime").iterator(chunk_size=1000):
|
|
72
|
+
yield [
|
|
73
|
+
f.event.slug, f.edition_year or "", f.series_slug, f.order_code, _dt(f.order_datetime, tz),
|
|
74
|
+
_dt(f.payment_datetime, tz), "" if f.days_before_event is None else f.days_before_event,
|
|
75
|
+
f.get_order_status_display(), f.total_gross, f.total_net, f.tax_amount, f.fees_total, f.refunded_amount,
|
|
76
|
+
_dt(f.canceled_at, tz), f.currency, f.ticket_count, f.addon_count, _yn(f.is_group_order),
|
|
77
|
+
_yn(f.voucher_used), f.payment_provider, _yn(f.is_refunded), f.country_code, f.city, f.postal_code,
|
|
78
|
+
_yn(f.is_local_buyer), f.age_range, _yn(f.is_age_confirmed), f.language, _yn(f.has_caravan_pass),
|
|
79
|
+
f.camper_van_length_bucket, _yn(f.is_repeat_buyer), _yn(f.repeat_from_last_edition), f.repeat_count,
|
|
80
|
+
f.first_seen_edition_year or "", f.editions_attended, _yn(f.checkin_completed),
|
|
81
|
+
f.predicted_repeat_probability, f.country_source, f.country_inferred, f.country_inferred_source,
|
|
82
|
+
f.travel_country_code, f.nationality, f.bank_country, f.bank_country_source,
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
TICKET_HEADER = [
|
|
87
|
+
"event", "order_code", "order_status", "product", "category", "variation", "is_addon", "price", "net_price",
|
|
88
|
+
"tax_rate", "voucher_code", "voucher_tag", "age_range", "checked_in", "first_checkin_at",
|
|
89
|
+
"attendee_identified", "attendee_is_buyer", "is_returning_attendee", "attendee_previous_editions",
|
|
90
|
+
"attendee_first_seen_year", "match", "returning_incl_probable", "document_country",
|
|
91
|
+
"nationality", "nationality_source",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ticket_rows(qs, tz=None):
|
|
96
|
+
yield TICKET_HEADER
|
|
97
|
+
for t in qs.select_related("event", "order_fact").order_by("event_id", "order_fact_id", "id").iterator(chunk_size=1000):
|
|
98
|
+
yield [
|
|
99
|
+
t.event.slug, t.order_fact.order_code, t.order_fact.get_order_status_display(), t.item_name,
|
|
100
|
+
t.item_category, t.variation_name, _yn(t.is_addon), t.price, t.net_price, t.tax_rate, t.voucher_code,
|
|
101
|
+
t.voucher_tag, t.age_range, _yn(t.checked_in), _dt(t.first_checkin_at, tz), _yn(t.attendee_identified),
|
|
102
|
+
_yn(t.attendee_is_buyer), _yn(t.is_returning_attendee), t.attendee_previous_editions,
|
|
103
|
+
t.attendee_first_seen_year or "", t.attendee_match or "unknown", _yn(t.is_returning_attendee_incl),
|
|
104
|
+
t.document_country, t.nationality, t.nationality_source,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _safe_cell(value):
|
|
112
|
+
"""
|
|
113
|
+
Neutralise spreadsheet formulas. City, postal code, product names and
|
|
114
|
+
voucher tags can come from buyers or staff; a cell such as
|
|
115
|
+
``=HYPERLINK(...)`` would otherwise execute when the CSV is opened.
|
|
116
|
+
"""
|
|
117
|
+
if isinstance(value, str) and value.startswith(_FORMULA_PREFIXES):
|
|
118
|
+
return "'" + value
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def safe_rows(rows):
|
|
123
|
+
for row in rows:
|
|
124
|
+
yield [_safe_cell(v) for v in row]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _Echo:
|
|
128
|
+
def write(self, value):
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _csv_response(rows, filename):
|
|
133
|
+
writer = csv.writer(_Echo())
|
|
134
|
+
response = StreamingHttpResponse((writer.writerow(r) for r in safe_rows(rows)), content_type="text/csv; charset=utf-8")
|
|
135
|
+
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
136
|
+
return response
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── Dashboard downloads ───────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def _scope(request, event):
|
|
142
|
+
from .services.reports.scope import ReportScope
|
|
143
|
+
return ReportScope(request, event)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def export_orders_csv(request, event):
|
|
147
|
+
scope = _scope(request, event)
|
|
148
|
+
return _csv_response(order_rows(scope.orders, scope.tz), _safe_filename(event.slug, "csv"))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def export_tickets_csv(request, event):
|
|
152
|
+
scope = _scope(request, event)
|
|
153
|
+
return _csv_response(ticket_rows(scope.tickets, scope.tz), _safe_filename(f"{event.slug}_tickets", "csv"))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def export_loyalty_csv(request, event):
|
|
157
|
+
"""
|
|
158
|
+
One row per order of this edition with the buyer's status and the list
|
|
159
|
+
of series editions the buyer (as resolved person) took part in.
|
|
160
|
+
"""
|
|
161
|
+
from .services.attendance import load_attendance, short_key
|
|
162
|
+
|
|
163
|
+
scope = _scope(request, event)
|
|
164
|
+
labels_by_person = {}
|
|
165
|
+
if scope.series and scope.can_see_series:
|
|
166
|
+
# Order rows describe the buyer, who is identified by the order e-mail.
|
|
167
|
+
att = load_attendance(scope.organizer_id, scope.series.slug, "buyers")
|
|
168
|
+
years = [e.year for e in att.editions]
|
|
169
|
+
for e in att.editions:
|
|
170
|
+
label = str(e.year) if years.count(e.year) == 1 else f"{e.label} ({e.year})"
|
|
171
|
+
for p in att.sets[e.key]:
|
|
172
|
+
labels_by_person.setdefault(p, []).append(label)
|
|
173
|
+
|
|
174
|
+
def rows():
|
|
175
|
+
yield ["order_code", "buyer_status", "previous_editions", "first_seen", "last_edition_attended",
|
|
176
|
+
"editions_attended", "tickets", "returning_attendees_in_order",
|
|
177
|
+
"returning_attendees_incl_probable", "identified"]
|
|
178
|
+
qs = scope.orders.filter(event=event).prefetch_related("ticket_facts").order_by("order_datetime")
|
|
179
|
+
for f in qs.iterator(chunk_size=500):
|
|
180
|
+
tickets = [t for t in f.ticket_facts.all() if not t.is_addon]
|
|
181
|
+
hist = labels_by_person.get(short_key(f.person_key), []) if f.person_key else []
|
|
182
|
+
yield [
|
|
183
|
+
f.order_code,
|
|
184
|
+
_("returning") if f.is_repeat_buyer else (_("first time") if f.person_key else _("unknown")),
|
|
185
|
+
f.repeat_count, f.first_seen_edition_year or "",
|
|
186
|
+
"yes" if f.repeat_from_last_edition else "no",
|
|
187
|
+
" · ".join(hist), len(tickets), sum(1 for t in tickets if t.is_returning_attendee),
|
|
188
|
+
sum(1 for t in tickets if t.is_returning_attendee_incl),
|
|
189
|
+
"yes" if f.person_key else "no",
|
|
190
|
+
]
|
|
191
|
+
return _csv_response(rows(), _safe_filename(f"{event.slug}_loyalty", "csv"))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def export_winback_csv(request, event):
|
|
195
|
+
"""
|
|
196
|
+
People to win back, as the order codes of their most recent visit:
|
|
197
|
+
|
|
198
|
+
* ``winback`` — at the previous edition, not (yet) at this one;
|
|
199
|
+
* ``lapsed`` — came to an earlier edition, but neither the previous
|
|
200
|
+
one nor this one.
|
|
201
|
+
|
|
202
|
+
Organisers look the codes up in Pretix (or export those orders) to
|
|
203
|
+
contact people; the plugin itself never holds addresses.
|
|
204
|
+
"""
|
|
205
|
+
from django.http import Http404
|
|
206
|
+
|
|
207
|
+
from .models import AnalyticsTicketFact
|
|
208
|
+
from .services.attendance import load_attendance, short_key
|
|
209
|
+
|
|
210
|
+
kind = request.resolver_match.kwargs.get("kind", "winback")
|
|
211
|
+
scope = _scope(request, event)
|
|
212
|
+
if not (scope.series and scope.can_see_series):
|
|
213
|
+
raise Http404()
|
|
214
|
+
att = load_attendance(scope.organizer_id, scope.series.slug, "people")
|
|
215
|
+
keys = [e.key for e in att.editions]
|
|
216
|
+
focus = f"e{event.pk}"
|
|
217
|
+
if focus not in keys or keys.index(focus) == 0:
|
|
218
|
+
raise Http404()
|
|
219
|
+
idx = keys.index(focus)
|
|
220
|
+
earlier = att.editions[:idx]
|
|
221
|
+
prev = att.sets[earlier[-1].key]
|
|
222
|
+
current = att.sets[focus]
|
|
223
|
+
if kind == "lapsed":
|
|
224
|
+
older = set().union(*[att.sets[e.key] for e in earlier[:-1]]) if len(earlier) > 1 else set()
|
|
225
|
+
target = older - prev - current
|
|
226
|
+
else:
|
|
227
|
+
target = prev - current
|
|
228
|
+
|
|
229
|
+
history = {}
|
|
230
|
+
for e in earlier:
|
|
231
|
+
for p in att.sets[e.key]:
|
|
232
|
+
if p in target:
|
|
233
|
+
history.setdefault(p, []).append(e)
|
|
234
|
+
|
|
235
|
+
# Most recent order code per person: the order holding their ticket.
|
|
236
|
+
event_ids = [e.event_id for e in earlier if e.event_id]
|
|
237
|
+
latest = {}
|
|
238
|
+
for code, key, year, own in AnalyticsTicketFact.objects.filter(
|
|
239
|
+
event_id__in=event_ids, is_addon=False, order_fact__order_status="p",
|
|
240
|
+
order_fact__is_refunded=False).exclude(attendee_person_key="").values_list(
|
|
241
|
+
"order_fact__order_code", "attendee_person_key", "order_fact__edition_year",
|
|
242
|
+
"attendee_is_buyer").order_by("order_fact__order_datetime"):
|
|
243
|
+
sk = short_key(key)
|
|
244
|
+
if sk in target:
|
|
245
|
+
latest[sk] = (code, year, "buyer" if own else "ticket holder")
|
|
246
|
+
|
|
247
|
+
years = [e.year for e in att.editions]
|
|
248
|
+
|
|
249
|
+
def label(e):
|
|
250
|
+
return str(e.year) if years.count(e.year) == 1 else f"{e.label} ({e.year})"
|
|
251
|
+
|
|
252
|
+
def rows():
|
|
253
|
+
yield ["last_order_code", "last_edition", "role_in_that_order", "editions_attended", "first_edition",
|
|
254
|
+
"editions"]
|
|
255
|
+
for p in sorted(target, key=lambda p: (-len(history.get(p, [])), p)):
|
|
256
|
+
h = history.get(p, [])
|
|
257
|
+
code, year, role = latest.get(p, ("", h[-1].year if h else "", "imported list only"))
|
|
258
|
+
yield [code, year, role, len(h), h[0].year if h else "", " · ".join(label(e) for e in h)]
|
|
259
|
+
return _csv_response(rows(), _safe_filename(f"{event.slug}_{kind}", "csv"))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def export_resale_csv(request, event):
|
|
263
|
+
"""Tickets that changed hands, as order codes (no names) with channel and counts."""
|
|
264
|
+
from .services.resale import resale_stats
|
|
265
|
+
|
|
266
|
+
tz = event.timezone
|
|
267
|
+
rows_in = resale_stats(event)["rows"]
|
|
268
|
+
|
|
269
|
+
def rows():
|
|
270
|
+
yield ["order_code", "position_id", "channel", "ticketswap_swaps", "manual_name_changes",
|
|
271
|
+
"last_manual_change"]
|
|
272
|
+
for t in rows_in:
|
|
273
|
+
yield [t.order_code, t.position_id or "", t.channel, t.swaps, t.manual, _dt(t.last_manual, tz)]
|
|
274
|
+
return _csv_response(rows(), _safe_filename(f"{event.slug}_resale", "csv"))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def export_pdf(request, event) -> HttpResponse:
|
|
278
|
+
"""Printable report of every dashboard section (WeasyPrint, as used by Pretix)."""
|
|
279
|
+
try:
|
|
280
|
+
import weasyprint
|
|
281
|
+
except ImportError:
|
|
282
|
+
return HttpResponse("PDF export requires WeasyPrint. Install it with: pip install weasyprint",
|
|
283
|
+
status=500, content_type="text/plain")
|
|
284
|
+
|
|
285
|
+
from .services.reports import audience, loyalty, operations, overview, sales, tickets
|
|
286
|
+
|
|
287
|
+
scope = _scope(request, event)
|
|
288
|
+
html = render_to_string("pretix_event_analytics/pdf_report.html", {
|
|
289
|
+
"event": event, "config": scope.config, "scope": scope, "generated_at": datetime.now(scope.tz),
|
|
290
|
+
"currency": scope.currency, "filter_chips": scope.filter_chips(),
|
|
291
|
+
"overview": overview.build(scope), "sales": sales.build(scope), "audience": audience.build(scope),
|
|
292
|
+
"loyalty": loyalty.build(scope), "tickets": tickets.build(scope), "operations": operations.build(scope),
|
|
293
|
+
}, request=request)
|
|
294
|
+
pdf_bytes = weasyprint.HTML(string=html).write_pdf()
|
|
295
|
+
response = HttpResponse(pdf_bytes, content_type="application/pdf")
|
|
296
|
+
response["Content-Disposition"] = f'attachment; filename="{_safe_filename(event.slug, "pdf")}"'
|
|
297
|
+
return response
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ── Pretix Export menu ────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
class _AnalyticsListExporter(ListExporter):
|
|
303
|
+
category = gettext_lazy("Analytics")
|
|
304
|
+
repeatable_read = False
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def additional_form_fields(self):
|
|
308
|
+
return OrderedDict([
|
|
309
|
+
("include_canceled", forms.BooleanField(
|
|
310
|
+
label=gettext_lazy("Include canceled and refunded orders"), required=False)),
|
|
311
|
+
])
|
|
312
|
+
|
|
313
|
+
def _orders(self, form_data):
|
|
314
|
+
qs = AnalyticsOrderFact.objects.filter(event__in=self.events)
|
|
315
|
+
if not form_data.get("include_canceled"):
|
|
316
|
+
qs = qs.filter(order_status="p", is_refunded=False)
|
|
317
|
+
return qs
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class AnalyticsOrderExporter(_AnalyticsListExporter):
|
|
321
|
+
identifier = "pretix_event_analytics_orders"
|
|
322
|
+
verbose_name = gettext_lazy("Analytics: order facts")
|
|
323
|
+
description = gettext_lazy("One row per order with pseudonymous analytics fields "
|
|
324
|
+
"(returning status, purchase timing, geography, age band). No names or e-mails.")
|
|
325
|
+
|
|
326
|
+
def iterate_list(self, form_data):
|
|
327
|
+
return safe_rows(order_rows(self._orders(form_data), self.timezone))
|
|
328
|
+
|
|
329
|
+
def get_filename(self):
|
|
330
|
+
return f"{self.events.first().organizer.slug}_analytics_orders"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class AnalyticsTicketExporter(_AnalyticsListExporter):
|
|
334
|
+
identifier = "pretix_event_analytics_tickets"
|
|
335
|
+
verbose_name = gettext_lazy("Analytics: ticket facts")
|
|
336
|
+
description = gettext_lazy("One row per ticket and add-on with check-in, voucher and returning-attendee fields.")
|
|
337
|
+
|
|
338
|
+
def iterate_list(self, form_data):
|
|
339
|
+
return safe_rows(ticket_rows(AnalyticsTicketFact.objects.filter(order_fact__in=self._orders(form_data)),
|
|
340
|
+
self.timezone))
|
|
341
|
+
|
|
342
|
+
def get_filename(self):
|
|
343
|
+
return f"{self.events.first().organizer.slug}_analytics_tickets"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@receiver(register_data_exporters, dispatch_uid="pretix_analytics_exporter_orders")
|
|
347
|
+
def register_order_exporter(sender, **kwargs):
|
|
348
|
+
return AnalyticsOrderExporter
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@receiver(register_data_exporters, dispatch_uid="pretix_analytics_exporter_tickets")
|
|
352
|
+
def register_ticket_exporter(sender, **kwargs):
|
|
353
|
+
return AnalyticsTicketExporter
|