Metadata-Version: 2.4
Name: pretix-event-analytics
Version: 2.3.0
Summary: Analytics suite for Pretix: sales against previous editions, first-timers and returning people, audience, tickets and check-in — without storing personal data
Home-page: https://github.com/h3x4d3x4/Pretix-Event-Analytics
Author: Hexadexa
Author-email: andrei@hexadexa.dev
License: Apache Software License
Project-URL: Source, https://github.com/h3x4d3x4/Pretix-Event-Analytics
Project-URL: Issues, https://github.com/h3x4d3x4/Pretix-Event-Analytics/issues
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Plugins
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pretix>=2026.2
Requires-Dist: pycountry
Dynamic: license-file

# Pretix Event Analytics

[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
[![Pretix](https://img.shields.io/badge/Pretix-2026.2%2B-purple.svg)](https://pretix.eu) ![Version](https://img.shields.io/badge/version-2.3.0-green.svg)
[![Python](https://img.shields.io/badge/Python-3.10%20%7C%203.11%20%7C%203.12-blue.svg)](https://python.org)

Analytics suite for [Pretix](https://pretix.eu): sales over time and against previous editions, first-timers and returning people across every edition of a recurring event, audience, tickets, check-in and refunds — without storing personal data.

![Overview: this edition against the previous one, and the sales pace of every edition](https://raw.githubusercontent.com/h3x4d3x4/Pretix-Event-Analytics/main/docs/screenshots/overview.png)

| Sales | Loyalty | Audience |
|---|---|---|
| ![Sales](https://raw.githubusercontent.com/h3x4d3x4/Pretix-Event-Analytics/main/docs/screenshots/sales.png) | ![Loyalty](https://raw.githubusercontent.com/h3x4d3x4/Pretix-Event-Analytics/main/docs/screenshots/loyalty.png) | ![Audience](https://raw.githubusercontent.com/h3x4d3x4/Pretix-Event-Analytics/main/docs/screenshots/audience.png) |

<sub>Screenshots show generated demo data.</sub>

## Features

**Seven dashboard pages** (Overview · Sales · Audience · Loyalty · Tickets · Operations · Resale) share one filter bar: order date range, country, age, first-time vs returning buyers, payment method, products, merged editions and canceled/refunded orders.

- **Overview** — revenue, tickets, orders, average order, returning buyers, refunds and check-in, each compared like-for-like with the previous edition *at the same number of days before the event*; first-timer headline; sales pace of every edition; forecast.
- **Sales** — tickets sold **and** revenue as two aligned charts (day / week / month, daily or cumulative), split by product, category, new vs returning or country, by order or payment date. Price-tier changes and your own **moments** ("line-up announced") are marked on the timeline and on the edition comparison. Editions compared on a days-before-event axis with a same-point table and a forecast (with range and optional ticket/revenue targets). Weekday × hour heatmap and how far ahead people buy.
- **Loyalty** — **win-back lists** (people from the last edition not back yet, and lapsed visitors, as order codes), the share of first-timers, how many times people have been before, who came back after a gap, who from the last edition has not (yet) returned, who has been to every edition. Pick **any set of editions** to see how many people attended once, twice, three times…, an overlap matrix, the most common attendance patterns, edition-by-edition flow and whether first-timers come back. Counts *people* (buyers and ticket holders) or buyers only. Segments show who the first-timers are (product, country, age, purchase timing).
- **Audience** — countries (with how each country is known), "travelling from" and ID-document countries when available, and top cities (orders, revenue, average order, returning share), age on the event date, checkout language, payment methods, buyer personas (early bird / regular / last minute), group size, caravans.
- **Tickets** — capacity per quota, product performance (returning share, check-in rate), categories, variations, price tiers, add-on attach rates and who buys them, voucher and discount-code usage.
- **Operations** — payment completion by method (paid vs expired unpaid), check-in rate, no-shows, arrival curve, check-in by product and by first-time/returning; cancellations and refunds over time and by product; opt-in breakdowns of yes/no and multiple-choice questions (e.g. "How did you hear about us?") split by first-time and returning attendees.
- **Pretix dashboard widgets** — returning buyers and first-time attendees on each event's main dashboard.
- **Resale** — one "changed hands" figure combining TicketSwap resales (read from the TicketSwap plugin, when installed) and manual name changes, counted once per ticket; breakdown by channel, product and current holder; per month and per edition; order codes as CSV.
- **Series overview** (organizer level) — every edition side by side, edition flow and first-timer cohorts, plus **import of past attendee lists** — a CSV with name + birth date (people) or any file with e-mail addresses (customers) — so visitors from before Pretix count as returning. Only hashes are kept.
- **Languages** — English, German and Portuguese (Portugal); each user sees their pretix language.
- **Exports** — orders, tickets and "returning buyers by order code" as CSV (respecting filters), a full PDF report, and two exporters in Pretix's own *Export* menu (CSV/Excel).

### How returning people are detected

A **person** is a ticket holder, matched across editions on **name + date of birth** (accents, letter case, word
order, middle names and added second surnames are ignored; different birth dates are always different people).
Paying with the same card or buying from the same e-mail **never** makes two ticket holders the same person —
people buy for friends. Everything is stored only as HMAC-SHA256 hashes.

- **Certain** matches drive every figure.
- **Probable** matches — a ticket without a birth date whose name matches exactly one known person *and* shares an
  e-mail or card with them — are shown only as a secondary "including probable" figure.
- **When in doubt, unknown:** tickets without a usable name + birth date, ambiguous names and placeholder data
  (the same name + birth date on many tickets of one edition) are left out of returning-people figures and shown
  as coverage — never guessed.
- **Customers** (the order e-mail) are a separate view: "returning customers" on the Loyalty page and in the
  Overview tiles.

A group order for five friends counts as five people, and the result is the same no matter in which order editions
were synced.

### Where people come from

Residence (per order) and nationality (per ticket holder) are kept apart.

**Residence, exact** — the first available of: a "Country of residence" question → invoice address → PayPal
address / account → card billing address → an ID document that proves residence (Spanish NIE, UK driving
licence, Belgian national number). The source is stored with the country and shown on the Audience page; only
exact countries are used by filters and other pages. A "travelling from" question is reported separately.

**Residence, derived** — kept in separate fields and switched on on the Audience page:
**inferred** (the same customer's exact country on their other orders, only when they all agree), then
**probable**: the buyer's nationality → the card's issuing bank → the IBAN country → the e-mail's country
domain (`.pt`; generic domains say nothing). Banks and nationality describe the account or the passport, not
where someone lives — on SUTI 2026 the card issuer matched the ID document only 79% of the time.

**Nationality** — per ticket holder: a nationality question (country names and common demonyms such as
"Portuguese"), else an ID document that proves citizenship (Spanish DNI, Portuguese Cartão de Cidadão / civil
number, Italian codice fiscale when born in Italy). Shown in its own panel on the Audience page and in the CSV
exports.

The most accurate data comes from asking: add **"Country of residence"**, **"Nationality"** and **"Which country
are you travelling from?"** (question type *Country*) at checkout, and mention the purpose in your privacy notice.

**ID documents (opt-in):** if checkout asks for an ID number, the issuing country is derived only from national
formats with a valid check digit; each format declares whether it proves nationality or residence
(`id_country.PROVES`). Ambiguous formats and placeholders ("00000000") yield nothing. The number is read in
memory and never stored. Reusing ID numbers for statistics is a new purpose under the GDPR — update your privacy
notice first.

## Requirements

| Component | Version |
|-----------|---------|
| Pretix    | 2026.2 or later (tested on 2026.2.0, 2026.3.1 and 2026.7.0) |
| Python    | 3.10, 3.11, or 3.12 |
| Django    | 4.2 LTS (ships with Pretix) |
| Redis     | Recommended (for caching and Celery task queue) |

## Installation

### From source

```bash
git clone https://github.com/h3x4d3x4/Pretix-Event-Analytics.git
cd Pretix-Event-Analytics

# Install into your Pretix virtualenv
pip install -e .

# Run database migrations
python -m pretix migrate
```

### From pip

```bash
pip install pretix-event-analytics
python -m pretix migrate
```

### Enable the plugin

The plugin works at two levels (Pretix "event + organizer" plugin):

1. **Organizer** → Settings → **Plugins** → enable **Event Analytics** (unlocks the series pages).
2. **Each event** → Settings → **Plugins** → enable **Event Analytics** (dashboards and order processing).

Orders are only processed for events where it is enabled at both levels.

The plugin is auto-discovered via its entry point &mdash; no changes to `INSTALLED_APPS` are needed.

## Quick Start

### 1. Create an Event Series

Navigate to your organizer sidebar &rarr; **Analytics Series** &rarr; **Create series**.

A series groups your recurring editions (e.g. "My Festival" containing 2022, 2023, 2024 editions).

### 2. Configure each Event

Go to **Settings &rarr; Analytics** on each event:

- Assign the event to a series
- Set the edition year
- Optionally set the event country for local-buyer scoring

### 3. Sync existing data

For events with existing orders, trigger a resync — **Resync** on the dashboard, **Resync all editions** on the series page, or from the command line:

```bash
# Every configured event (returning people are resolved once per series at the end)
python -m pretix analytics_resync --all

# One event / one series
python -m pretix analytics_resync --event <organizer>/<event>
python -m pretix analytics_resync --series <organizer>/<series-slug>

# Preview without processing
python -m pretix analytics_resync --all --dry-run
```

Check-ins are always included; the old `--checkin` flag is accepted but no longer needed.

### 4. Optional

- **Past editions:** on the series page, import attendee lists of editions that predate Pretix (a CSV with name + birth date, or a list of e-mail addresses).
- **Questions:** in the event's Analytics settings, choose yes/no or multiple-choice questions to analyse, then resync.
- **Targets & pace alert:** under *Goals & alerts*, set a ticket and/or revenue target to see progress next to the forecast, and optionally have e-mails sent when sales fall a chosen percentage behind the previous edition.

## Theming

The dashboards follow Pretix's own admin look out of the box. Every colour comes from a CSS variable
(`--pa-surface`, `--pa-text`, `--pa-accent`, the chart series `--pa-s1` … `--pa-s7`, the heatmap ramp
`--pa-seq-0` … `--pa-seq-6`, …; the full list with defaults is at the top of
[`dashboard.css`](pretix_event_analytics/static/pretix_event_analytics/dashboard.css)). A theme plugin can
restyle the analytics pages by overriding them on `:root`, e.g. from Pretix's `html_head` control signal:

```css
:root {
  --pa-surface: #1c1917;
  --pa-text: #f5f5f4;
  --pa-accent: #a36e39;
}
```

Variable names are treated as public API: they are not renamed or removed within a major version. Keep the
chart series colour-blind safe and in a fixed order when you change them.

## Architecture

```
Pretix signals (order paid / canceled / changed / modified / reactivated / split, check-in)
        │   thin handlers → Celery tasks (retry + backoff; resync lock respected)
        ▼
services/ingest.write_order ── normalizer (hashing, bucketing) ──► AnalyticsOrderFact
        │                                                         AnalyticsTicketFact (one per position)
        │                                                         AnalyticsIdentity (buyer / attendee)
        │                                                         AnalyticsAnswerFact (opt-in questions)
        ▼
services/people  (debounced, series-wide)  → person keys + exact repeat fields
        ▼
services/attendance  (cached sets per edition)   services/reports/*  (cached per data version)
        ▼
Dashboard pages · series overview · CSV / PDF / Pretix exporters
```

Resync upserts every paid or refunded order through the same `write_order` path and removes stale rows; the table is never emptied, so dashboards keep working during a resync.

## Access Control

| Permission | Access |
|------------|--------|
| `can_view_orders` | View every analytics page of an event, export CSV/PDF |
| `can_change_event_settings` | Configure analytics (series, targets, questions), trigger a resync |
| `can_change_organizer_settings` | Manage series, series overview, import past attendee lists, resync a whole series |

These are standard Pretix team permissions.

## Safety Guarantee: Read-Only Against the Ticketing Database

This plugin is an analytics overlay. It reads Pretix orders, positions,
answers, payments, refunds and check-ins — it **never writes** ticketing
data. Its only write outside its own tables is Pretix's activity log, where
admin actions (settings changes, resyncs, series changes, list imports) are
recorded without personal data. All other persistence happens in the
plugin's own analytics tables (`AnalyticsOrderFact`, `AnalyticsTicketFact`, `AnalyticsIdentity`,
`AnalyticsAnswerFact`, `EventSeries`, `EventAnalyticsConfig`,
`LegacyEdition`, `LegacyIdentity`).

The invariant is enforced two ways:

1. **Static check** — [`scripts/check_isolation.py`](scripts/check_isolation.py)
   greps the plugin source for any `.save()`, `.delete()`, `.create()`,
   `.update()`, `.bulk_create()`, `.bulk_update()`, or `.raw()` call
   targeting a Pretix core model and exits non-zero if it finds one. Run
   it any time; it requires no database and no Django setup.
2. **Signal-path discipline** — order and check-in signal handlers do zero DB work inline;
   they dispatch a Celery task. All task code is read-only against
   `pretix.base.models.*`. Failures in analytics never block, delay, or
   roll back a ticket checkout or payment.

## Privacy & GDPR

- Email addresses, card fingerprints, and other identity signals are
  converted to HMAC-SHA256 hashes — never stored in plain text and not
  reversible.
- Names and birth dates are only kept inside those hashes (for matching
  returning people) and as age buckets (e.g. `25-34`) — never in plain text.
- ID-document numbers (opt-in) are read in memory; only the derived country
  is stored.
- Question answers are only collected for questions an organiser opts in
  to, only for yes/no and multiple-choice questions, and only as the
  chosen option — free text never reaches the analytics tables.
- Imported past attendee lists are hashed in memory; the uploaded file
  and the plain addresses are discarded immediately.
- Exports contain order codes and pseudonymous fields only — never names
  or e-mail addresses.
- The HMAC salt is **required** in production; the plugin refuses to
  operate without it (see *Configuration*). In `DEBUG=True` development
  environments the salt falls back to a `SECRET_KEY`-derived value, with
  a clear log warning.
- A **data shredder** is registered, so organisers can delete all
  analytics data for an event through Pretix's built-in GDPR data
  export / deletion interface.

> **Important:** the salt must be set once and
> never changed. Rotating it invalidates every historical repeat-buyer
> hash — repeat detection silently starts over from scratch. If you have
> to rotate (e.g. after a security incident), run a full resync
> (`analytics_resync --all`) immediately after.

## Configuration

Set the HMAC salt once, before the first sync, in `pretix.cfg`:

```ini
[pretix_event_analytics]
; 32+ random characters. Never change it once data exists.
secret_salt = your-long-random-string-here-32-chars-minimum
```

Alternatives: the environment variable `PRETIX_ANALYTICS_SECRET_SALT`, or a Django setting of the same name in a custom settings module. Without a salt the plugin refuses to process orders in production (`DEBUG=False`).

After upgrading, run `python -m pretix migrate`, `python -m pretix rebuild` (static files) and a resync of all events (`analytics_resync --all`) so existing data gets the new fields.

## Data Model

```
EventSeries (organizer)
  ├─ EventAnalyticsConfig (per event: edition year, home country, active, targets, tracked questions)
  │    └─ AnalyticsOrderFact (per order; all dashboards query facts only)
  │         ├─ AnalyticsTicketFact (per position: product, price, voucher, check-in, attendee status)
  │         │    ├─ AnalyticsIdentity (attendee-level hashes)
  │         │    └─ AnalyticsAnswerFact (opt-in question answers)
  │         └─ AnalyticsIdentity (buyer-level hashes)
  └─ LegacyEdition (imported past edition)
       └─ LegacyIdentity (hashed people / e-mails)
```

## Development

```bash
.venv/bin/pip install pytest pytest-django
.venv/bin/python -m pytest            # real Pretix objects, ~180 tests

# Local demo data: real orders for six editions (local SQLite dev DB only)
PRETIX_CONFIG_FILE=pretix.cfg .venv/bin/python scripts/dev_seed_orders.py --organizer <org> --i-understand-this-writes-orders
PRETIX_CONFIG_FILE=pretix.cfg .venv/bin/python -m pretix analytics_resync --organizer <org> --all
```

## Verifying a build

Before deploying, a couple of quick sanity checks never hurt:

```bash
# 1. Confirm the plugin has not grown any writes to Pretix core tables.
python scripts/check_isolation.py

# 2. Run Django's standard system checks against your Pretix install.
python -m pretix check
```

Both should complete silently with no warnings.

## Roadmap

See [ROADMAP.md](ROADMAP.md).

## Versioning & Changelog

Releases follow [Semantic Versioning](https://semver.org). See
[CHANGELOG.md](CHANGELOG.md) for the history of user-visible changes.

## Security

Please report vulnerabilities privately to andrei@hexadexa.dev — see [SECURITY.md](SECURITY.md).

## License

Apache License 2.0. See [LICENSE](LICENSE) for the full text.
