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.
Files changed (87) hide show
  1. pretix_event_analytics/__init__.py +24 -0
  2. pretix_event_analytics/_compat.py +32 -0
  3. pretix_event_analytics/_safety.py +53 -0
  4. pretix_event_analytics/admin.py +77 -0
  5. pretix_event_analytics/apps.py +17 -0
  6. pretix_event_analytics/exporters.py +353 -0
  7. pretix_event_analytics/forms.py +381 -0
  8. pretix_event_analytics/locale/README.md +19 -0
  9. pretix_event_analytics/management/__init__.py +0 -0
  10. pretix_event_analytics/management/commands/__init__.py +0 -0
  11. pretix_event_analytics/management/commands/analytics_resync.py +176 -0
  12. pretix_event_analytics/management/commands/generate_test_data.py +518 -0
  13. pretix_event_analytics/migrations/0001_initial.py +238 -0
  14. pretix_event_analytics/migrations/0002_analyticsidentity_and_more.py +135 -0
  15. pretix_event_analytics/migrations/0003_audit_fixes.py +60 -0
  16. pretix_event_analytics/migrations/0004_performance_and_integrity.py +42 -0
  17. pretix_event_analytics/migrations/0005_suite_v2.py +233 -0
  18. pretix_event_analytics/migrations/0006_people_resolved_at.py +18 -0
  19. pretix_event_analytics/migrations/0007_sales_annotations.py +30 -0
  20. pretix_event_analytics/migrations/0008_pace_alerts.py +28 -0
  21. pretix_event_analytics/migrations/0009_accuracy_2_1.py +93 -0
  22. pretix_event_analytics/migrations/0010_nationality_bank_country.py +38 -0
  23. pretix_event_analytics/migrations/__init__.py +0 -0
  24. pretix_event_analytics/models.py +520 -0
  25. pretix_event_analytics/services/__init__.py +0 -0
  26. pretix_event_analytics/services/age_bucketer.py +140 -0
  27. pretix_event_analytics/services/alerts.py +114 -0
  28. pretix_event_analytics/services/attendance.py +118 -0
  29. pretix_event_analytics/services/cohort_service.py +61 -0
  30. pretix_event_analytics/services/country_resolver.py +261 -0
  31. pretix_event_analytics/services/hash_service.py +136 -0
  32. pretix_event_analytics/services/id_country.py +180 -0
  33. pretix_event_analytics/services/identity_keys.py +72 -0
  34. pretix_event_analytics/services/ingest.py +172 -0
  35. pretix_event_analytics/services/legacy.py +138 -0
  36. pretix_event_analytics/services/normalizer.py +425 -0
  37. pretix_event_analytics/services/payment_info.py +122 -0
  38. pretix_event_analytics/services/people.py +722 -0
  39. pretix_event_analytics/services/predictor.py +84 -0
  40. pretix_event_analytics/services/repeat_detector.py +121 -0
  41. pretix_event_analytics/services/reports/__init__.py +0 -0
  42. pretix_event_analytics/services/reports/audience.py +253 -0
  43. pretix_event_analytics/services/reports/charts.py +70 -0
  44. pretix_event_analytics/services/reports/loyalty.py +323 -0
  45. pretix_event_analytics/services/reports/market.py +53 -0
  46. pretix_event_analytics/services/reports/operations.py +170 -0
  47. pretix_event_analytics/services/reports/overview.py +112 -0
  48. pretix_event_analytics/services/reports/sales.py +491 -0
  49. pretix_event_analytics/services/reports/scope.py +232 -0
  50. pretix_event_analytics/services/reports/series.py +62 -0
  51. pretix_event_analytics/services/reports/tickets.py +143 -0
  52. pretix_event_analytics/services/resale.py +295 -0
  53. pretix_event_analytics/services/resync_service.py +173 -0
  54. pretix_event_analytics/services/van_length_bucketer.py +120 -0
  55. pretix_event_analytics/services/versioning.py +66 -0
  56. pretix_event_analytics/shredder.py +79 -0
  57. pretix_event_analytics/signals.py +277 -0
  58. pretix_event_analytics/static/pretix_event_analytics/chart.umd.min.js +19 -0
  59. pretix_event_analytics/static/pretix_event_analytics/dashboard.css +273 -0
  60. pretix_event_analytics/static/pretix_event_analytics/dashboard.js +368 -0
  61. pretix_event_analytics/tasks.py +225 -0
  62. pretix_event_analytics/templates/pretix_event_analytics/_chart.html +27 -0
  63. pretix_event_analytics/templates/pretix_event_analytics/base.html +132 -0
  64. pretix_event_analytics/templates/pretix_event_analytics/config.html +184 -0
  65. pretix_event_analytics/templates/pretix_event_analytics/email/pace_alert.txt +7 -0
  66. pretix_event_analytics/templates/pretix_event_analytics/pages/audience.html +119 -0
  67. pretix_event_analytics/templates/pretix_event_analytics/pages/loyalty.html +225 -0
  68. pretix_event_analytics/templates/pretix_event_analytics/pages/operations.html +89 -0
  69. pretix_event_analytics/templates/pretix_event_analytics/pages/overview.html +93 -0
  70. pretix_event_analytics/templates/pretix_event_analytics/pages/resale.html +75 -0
  71. pretix_event_analytics/templates/pretix_event_analytics/pages/sales.html +203 -0
  72. pretix_event_analytics/templates/pretix_event_analytics/pages/tickets.html +110 -0
  73. pretix_event_analytics/templates/pretix_event_analytics/pdf_report.html +132 -0
  74. pretix_event_analytics/templates/pretix_event_analytics/series_confirm_delete.html +38 -0
  75. pretix_event_analytics/templates/pretix_event_analytics/series_detail.html +116 -0
  76. pretix_event_analytics/templates/pretix_event_analytics/series_form.html +65 -0
  77. pretix_event_analytics/templates/pretix_event_analytics/series_list.html +84 -0
  78. pretix_event_analytics/templatetags/__init__.py +0 -0
  79. pretix_event_analytics/templatetags/analytics_tags.py +39 -0
  80. pretix_event_analytics/urls.py +38 -0
  81. pretix_event_analytics/views.py +600 -0
  82. pretix_event_analytics-2.2.1.dist-info/METADATA +354 -0
  83. pretix_event_analytics-2.2.1.dist-info/RECORD +87 -0
  84. pretix_event_analytics-2.2.1.dist-info/WHEEL +5 -0
  85. pretix_event_analytics-2.2.1.dist-info/entry_points.txt +2 -0
  86. pretix_event_analytics-2.2.1.dist-info/licenses/LICENSE +191 -0
  87. 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