chore: updates plugins o v2

This commit is contained in:
2026-04-27 09:25:29 +02:00
parent 161c9b980c
commit bffdc5e62d
40 changed files with 1269 additions and 274 deletions

View File

@ -33,7 +33,7 @@ In your app `.env` file (managed by abra):
```bash
COMPOSE_FILE=compose.yml:compose.plugin.yml
PRETIX_PLUGIN_IMAGE=git.coopcloud.tech/<your-org>/pretix-plugins:1.0.0
PRETIX_PLUGIN_IMAGE=git.coopcloud.tech/coop-cloud/pretix-plugins:1.0.0
```
If `PRETIX_PLUGIN_IMAGE` is not set, the standard `pretix/standalone` image
@ -64,24 +64,12 @@ Comment out or remove a line to exclude a plugin from the image.
## Building and publishing the image
### Automatic (recommended)
Push a Git tag to trigger the Gitea Actions workflow:
```bash
git tag 1.0.0
git push origin 1.0.0
```
The CI pipeline will build and push:
- `git.coopcloud.tech/<your-org>/pretix-plugins:1.0.0`
- `git.coopcloud.tech/<your-org>/pretix-plugins:latest`
### Manual
```bash
docker build -t git.coopcloud.tech/<your-org>/pretix-plugins:1.0.0 .
docker push git.coopcloud.tech/<your-org>/pretix-plugins:1.0.0
docker build -t git.coopcloud.tech/coop-cloud/pretix-plugins:1.0.0 .
docker push git.coopcloud.tech/coop-cloud/pretix-plugins:1.0.0
```
---
@ -111,14 +99,6 @@ plugins/my-new-plugin/
See `plugins/attendance_confirm_plugin/` for a fully working reference implementation.
### Plugin checklist before opening a PR
- [ ] `setup.py` contains a valid `pretix.plugin` entry point
- [ ] `apps.py` contains `AppConfig` with a `PretixPluginMeta` inner class
- [ ] Plugin installs cleanly with `pip install ./plugins/my-new-plugin/`
- [ ] No hard-coded credentials or environment-specific configuration
---
## Repository structure

View File

@ -1,4 +1,2 @@
recursive-include
pretix_attendance_confirm/templates *
recursive-include
pretix_attendance_confirm/static *
recursive-include pretix_attendance_confirm/templates *
recursive-include pretix_attendance_confirm/static *

View File

@ -12,8 +12,27 @@ Features:
- Placeholders: {attendee_name} and {event_name}.
- Sending is disabled until the event has ended.
Participant grouping:
- Recipients are grouped per participant within an order instead of strictly per
`OrderPosition`.
- This avoids duplicate confirmations when one participant is represented by
multiple positions, e.g. because of multiple modules/products or add-ons.
- Distinct participants in the same order remain separate if their attendee
identity differs.
- Known limitation: if two different people in the same order have the exact
same full attendee name and no attendee email, they are currently treated as
one recipient group.
## Install
```
pip install -e /path/to/attendance_confirm_plugin
```
Docker note: the path must be the container path, not your host path. If you
mount this repo into the container, you can run either of these:
```
pip install -e /pretix/src/attendance_confirm_plugin
pip install -e ./attendance_confirm_plugin
```
Use the one that matches where the repo is mounted in your image.

View File

@ -1,28 +0,0 @@
Metadata-Version: 2.4
Name: pretix-attendance-confirm
Version: 0.1.0
Summary: Attendance confirmation email plugin for pretix
Author: Ez for mITs
License: AGPL-3.0-or-later
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Plugins
Classifier: Framework :: Django
Classifier: License :: OSI Approved :: GNU Affero General Public License v3
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Office/Business
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: pretix>=2024.3
# pretix-attendance-confirm (draft)
pretix plugin to send attendance confirmation emails to checked-in attendees.
Emails are sent per attendee (attendee email first, order email as fallback) and can
be customized per event.
## Install
```
pip install -e /path/to/attendance_confirm_plugin
```

View File

@ -1,15 +0,0 @@
README.md
pyproject.toml
setup.cfg
pretix_attendance_confirm/__init__.py
pretix_attendance_confirm/apps.py
pretix_attendance_confirm/forms.py
pretix_attendance_confirm/signals.py
pretix_attendance_confirm/urls.py
pretix_attendance_confirm/views.py
pretix_attendance_confirm.egg-info/PKG-INFO
pretix_attendance_confirm.egg-info/SOURCES.txt
pretix_attendance_confirm.egg-info/dependency_links.txt
pretix_attendance_confirm.egg-info/entry_points.txt
pretix_attendance_confirm.egg-info/requires.txt
pretix_attendance_confirm.egg-info/top_level.txt

View File

@ -1,2 +0,0 @@
[pretix.plugin]
attendance_confirm = pretix_attendance_confirm

View File

@ -1,3 +1,5 @@
from collections import OrderedDict
from dataclasses import dataclass
from email.utils import formataddr
from django.conf import settings
@ -11,7 +13,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from pretix.base.models import Checkin, OrderPosition
from pretix.base.services.mail import clean_sender_name, mail_send, mail_send_task
from pretix.base.services.mail import clean_sender_name, mail_send_task
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.helpers.format import SafeFormatter, format_map
@ -37,6 +39,14 @@ DEFAULT_MESSAGE = _(
)
@dataclass(frozen=True)
class RecipientGroup:
key: str
positions: tuple
primary_position: object
checked_in: bool
class SendConfirmationsView(EventPermissionRequiredMixin, FormView):
template_name = "pretixplugins/attendance_confirm/send.html"
permission = "can_change_orders"
@ -74,20 +84,22 @@ class SendConfirmationsView(EventPermissionRequiredMixin, FormView):
"subevent",
).order_by("order__code", "positionid")
def build_recipient_choices(self, positions, checked_in_ids):
def build_recipient_choices(self, groups):
choices = []
for position in positions:
recipient = position.attendee_email or position.order.email
for group in groups:
position = group.primary_position
recipient = self.get_group_recipient(group)
invoice_address = self.get_order_invoice_address(position.order)
attendee_name = (
position.attendee_name_cached
or getattr(position.order.invoice_address, "name_cached", "")
or getattr(invoice_address, "name_cached", "")
or recipient
or _("Unbekannt")
)
email_label = recipient or _("keine E-Mail")
status = _("eingecheckt") if position.pk in checked_in_ids else _("nicht eingecheckt")
status = _("eingecheckt") if group.checked_in else _("nicht eingecheckt")
label = f"{attendee_name} - {email_label} - {position.order.code} - {status}"
choices.append((str(position.pk), label))
choices.append((group.key, label))
return choices
def get_initial(self):
@ -100,14 +112,18 @@ class SendConfirmationsView(EventPermissionRequiredMixin, FormView):
kwargs = super().get_form_kwargs()
positions = list(self.get_all_positions())
checked_in_ids = set(self.get_checked_in_positions().values_list("pk", flat=True))
kwargs["recipient_choices"] = self.build_recipient_choices(positions, checked_in_ids)
kwargs["recipient_initial"] = [str(pk) for pk in checked_in_ids]
groups = self.group_positions_by_participant(positions, checked_in_ids)
kwargs["recipient_choices"] = self.build_recipient_choices(groups)
kwargs["recipient_initial"] = [group.key for group in groups if group.checked_in]
return kwargs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["checked_in_count"] = self.get_checked_in_positions().count()
ctx["total_count"] = self.get_all_positions().count()
positions = list(self.get_all_positions())
checked_in_ids = set(self.get_checked_in_positions().values_list("pk", flat=True))
groups = self.group_positions_by_participant(positions, checked_in_ids)
ctx["checked_in_count"] = sum(1 for group in groups if group.checked_in)
ctx["total_count"] = len(groups)
ctx["event_end"] = self.get_event_end()
ctx["event_ended"] = bool(ctx["event_end"] and ctx["event_end"] <= now())
return ctx
@ -121,21 +137,28 @@ class SendConfirmationsView(EventPermissionRequiredMixin, FormView):
self.request.event.settings.set(SETTINGS_SUBJECT, form.cleaned_data["subject"])
self.request.event.settings.set(SETTINGS_MESSAGE, form.cleaned_data["message"])
positions = list(self.get_all_positions())
checked_in_ids = set(self.get_checked_in_positions().values_list("pk", flat=True))
selected_ids = set(form.cleaned_data.get("recipients") or [])
positions = self.get_all_positions().filter(pk__in=selected_ids)
if not positions.exists():
groups = [
group for group in self.group_positions_by_participant(positions, checked_in_ids)
if group.key in selected_ids
]
if not groups:
messages.error(self.request, _("Es wurden keine Empfänger ausgewählt."))
return self.get(self.request, *self.args, **self.kwargs)
sent = 0
failed = 0
for position in positions.iterator():
recipient = position.attendee_email or position.order.email
for group in groups:
position = group.primary_position
recipient = self.get_group_recipient(group)
if not recipient:
continue
invoice_address = self.get_order_invoice_address(position.order)
attendee_name = (
position.attendee_name_cached
or getattr(position.order.invoice_address, "name_cached", "")
or getattr(invoice_address, "name_cached", "")
or recipient
)
context = {
@ -184,3 +207,75 @@ class SendConfirmationsView(EventPermissionRequiredMixin, FormView):
"organizer": self.request.event.organizer.slug,
"event": self.request.event.slug,
})
def group_positions_by_participant(self, positions, checked_in_ids):
grouped = OrderedDict()
positions_by_pk = {position.pk: position for position in positions}
for position in positions:
key = self._participant_group_key(position, positions_by_pk)
grouped.setdefault(key, []).append(position)
return [
RecipientGroup(
key=key,
positions=tuple(grouped_positions),
primary_position=self._get_primary_position(grouped_positions),
checked_in=any(position.pk in checked_in_ids for position in grouped_positions),
)
for key, grouped_positions in grouped.items()
]
def get_group_recipient(self, group):
for position in group.positions:
recipient = position.attendee_email or position.order.email
if recipient:
return recipient
return ""
def get_order_invoice_address(self, order):
descriptor = type(order).__dict__.get("invoice_address")
try:
return order.invoice_address
except descriptor.RelatedObjectDoesNotExist:
return None
def _participant_group_key(self, position, positions_by_pk):
identity = self._position_identity(position)
if identity:
return "participant:" + "|".join(identity)
if position.addon_to_id:
parent = positions_by_pk.get(position.addon_to_id)
if parent:
parent_identity = self._position_identity(parent)
if parent_identity:
return "participant:" + "|".join(parent_identity)
return f"addon:{position.addon_to_id}"
return f"position:{position.pk}"
def _position_identity(self, position):
email = self._normalize_identity_value(getattr(position, "attendee_email", ""))
name = self._normalize_identity_value(getattr(position, "attendee_name_cached", ""))
if email:
return ("email", email, name)
if name:
return ("name", name)
return None
def _normalize_identity_value(self, value):
if value is None:
return ""
return " ".join(str(value).strip().lower().split())
def _get_primary_position(self, positions):
return min(
positions,
key=lambda position: (
1 if getattr(position, "addon_to_id", None) else 0,
getattr(position, "positionid", 0),
getattr(position, "pk", 0),
),
)

View File

@ -23,6 +23,11 @@ install_requires =
pretix>=2024.3
python_requires = >=3.10
[options.package_data]
pretix_attendance_confirm =
templates/**/*
static/**/*
[options.packages.find]
exclude =
tests
@ -31,8 +36,3 @@ exclude =
[options.entry_points]
pretix.plugin =
attendance_confirm = pretix_attendance_confirm
[options.package_data]
pretix_attendance_confirm =
templates/**/*
static/**/*

View File

@ -0,0 +1,123 @@
from types import SimpleNamespace
from pretix_attendance_confirm.views import SendConfirmationsView
def make_view():
return SendConfirmationsView()
def make_position(
pk,
order,
positionid,
attendee_name="",
attendee_email="",
addon_to_id=None,
):
return SimpleNamespace(
pk=pk,
order=order,
order_id=order.pk,
positionid=positionid,
attendee_name_cached=attendee_name,
attendee_email=attendee_email,
addon_to_id=addon_to_id,
)
def test_collapses_same_participant_with_multiple_positions():
view = make_view()
order = SimpleNamespace(pk=10, code="ORD1", email="order@example.org", invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org"),
make_position(2, order, 2, attendee_name="Alex Example", attendee_email="alex@example.org"),
]
groups = view.group_positions_by_participant(positions, {1})
assert len(groups) == 1
assert [position.pk for position in groups[0].positions] == [1, 2]
assert groups[0].checked_in is True
def test_keeps_distinct_participants_in_same_order_separate():
view = make_view()
order = SimpleNamespace(pk=10, code="ORD1", email="order@example.org", invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org"),
make_position(2, order, 2, attendee_name="Blair Example", attendee_email="blair@example.org"),
]
groups = view.group_positions_by_participant(positions, set())
assert len(groups) == 2
assert [position.pk for position in groups[0].positions] == [1]
assert [position.pk for position in groups[1].positions] == [2]
def test_groups_addon_without_identity_into_parent_participant():
view = make_view()
order = SimpleNamespace(pk=10, code="ORD1", email="order@example.org", invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org"),
make_position(2, order, 2, addon_to_id=1),
]
groups = view.group_positions_by_participant(positions, set())
assert len(groups) == 1
assert [position.pk for position in groups[0].positions] == [1, 2]
def test_prefers_attendee_email_over_order_email_for_group_recipient():
view = make_view()
order = SimpleNamespace(pk=10, code="ORD1", email="order@example.org", invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org"),
make_position(2, order, 2, attendee_name="Alex Example"),
]
group = view.group_positions_by_participant(positions, set())[0]
assert view.get_group_recipient(group) == "alex@example.org"
def test_falls_back_to_order_email_when_group_has_no_attendee_email():
view = make_view()
order = SimpleNamespace(pk=10, code="ORD1", email="order@example.org", invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example"),
make_position(2, order, 2, attendee_name="Alex Example"),
]
group = view.group_positions_by_participant(positions, set())[0]
assert view.get_group_recipient(group) == "order@example.org"
def test_build_recipient_choices_handles_missing_invoice_address_relation():
view = make_view()
class MissingInvoiceDescriptor:
class RelatedObjectDoesNotExist(Exception):
pass
def __get__(self, instance, owner):
raise self.RelatedObjectDoesNotExist()
class MissingInvoiceOrder:
pk = 10
code = "ORD1"
email = "order@example.org"
invoice_address = MissingInvoiceDescriptor()
order = MissingInvoiceOrder()
positions = [
make_position(1, order, 1, attendee_name="", attendee_email=""),
]
groups = view.group_positions_by_participant(positions, set())
choices = view.build_recipient_choices(groups)
assert choices == [("position:1", "order@example.org - order@example.org - ORD1 - nicht eingecheckt")]

View File

@ -1,4 +1,2 @@
recursive-include
pretix_selective_export/templates *
recursive-include
pretix_selective_export/static *
recursive-include pretix_selective_export/templates *
recursive-include pretix_selective_export/static *

View File

@ -10,7 +10,26 @@ Notes:
- Field selection includes model fields, related labels, invoice fields (when
invoice addresses exist), and question answers (when answers exist).
- Presets: save/load field selections directly on the export form without reloading.
Presets are stored in the browser (localStorage) per URL/event.
Presets are stored in pretix settings and shared with all users who can access
the same export page.
Participant row grouping:
- Export rows are grouped per participant within an order, not emitted strictly per `OrderPosition`.
- This avoids duplicate-looking rows when one participant is represented by multiple positions,
e.g. one booking with two modules/products or a main ticket plus add-ons.
- Positions are collapsed when they clearly refer to the same participant in the same order.
The grouping primarily uses attendee email and attendee full name.
- Distinct participants in the same order remain separate if their attendee identity differs.
- Add-ons without their own attendee identity are grouped into the parent participant row.
- Position-level values that differ across grouped rows are preserved as joined values using ` | `
instead of silently dropping one of them.
- Known limitation: if two different people in the same order have the exact same full attendee
name and no attendee email, the export currently treats them as one participant row.
Column ordering:
- Selected fields can be reordered in the export form.
- The chosen order controls the exported column order.
- Presets store both the selected fields and their explicit order.
## Setup (pretix + plugin)

View File

@ -1,63 +0,0 @@
Metadata-Version: 2.4
Name: pretix-selective-export
Version: 0.1.0
Summary: Selective field export plugin for pretix
Author: Ez for mITs
License: AGPL-3.0-or-later
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Plugins
Classifier: Framework :: Django
Classifier: License :: OSI Approved :: GNU Affero General Public License v3
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Office/Business
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: pretix>=2024.3
# pretix-selective-export (draft)
pretix exporter plugin that lets you pick exactly which fields to export.
It supports event-level exports as well as organizer-level exports that aggregate
all events into one file.
Notes:
- Activate the plugin at the organizer level to use multi-event exports.
- Activate it for an event to use event-level exports.
- Field selection includes model fields, related labels, invoice fields (when
invoice addresses exist), and question answers (when answers exist).
## Setup (pretix + plugin)
These steps assume a local pretix checkout and Python 3.10+.
1) Create and activate a virtualenv:
```
python3 -m venv .venv
source .venv/bin/activate
```
2) Install pretix (editable) and its dev requirements:
```
git clone https://github.com/pretix/pretix.git
cd pretix
pip install -U pip
pip install -e ".[dev]"
```
3) Install this plugin (editable):
```
pip install -e /path/to/selective_export_plugin
```
4) Enable the plugin in pretix:
- Add `pretix_selective_export` to the `PLUGINS` list in your pretix config.
5) Run pretix and use the exporter:
- Start pretix using the standard dev-server command for your checkout. Common options are:
```
python src/manage.py runserver
```
- If that doesn't work in your setup, follow the upstream pretix development setup instructions and use their start command.
- For event-level exports: enable the plugin on the event and go to the export page.
- For organizer-level exports: enable the plugin on the organizer and use the organizer export page (single aggregated file).

View File

@ -1,13 +0,0 @@
README.md
pyproject.toml
setup.cfg
pretix_selective_export/__init__.py
pretix_selective_export/apps.py
pretix_selective_export/exporter.py
pretix_selective_export/signals.py
pretix_selective_export.egg-info/PKG-INFO
pretix_selective_export.egg-info/SOURCES.txt
pretix_selective_export.egg-info/dependency_links.txt
pretix_selective_export.egg-info/entry_points.txt
pretix_selective_export.egg-info/requires.txt
pretix_selective_export.egg-info/top_level.txt

View File

@ -1,2 +0,0 @@
[pretix.plugin]
selective_export = pretix_selective_export

View File

@ -1 +0,0 @@
pretix_selective_export

View File

@ -9,7 +9,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, is_aware, make_naive
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.exporter import ListExporter, OrganizerLevelExportMixin
from pretix.base.exporter import ListExporter
from pretix.base.models import Event, InvoiceAddress, Order, OrderPosition, Question
@ -21,6 +21,7 @@ class FieldDefinition:
@dataclass(frozen=True)
class RowContext:
positions: tuple
position: object
order: object
event: object
@ -28,7 +29,7 @@ class RowContext:
answers: dict
class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
class SelectiveFieldExporter(ListExporter):
identifier = "selective_field_export"
verbose_name = _("Order data (selectable fields)")
category = pgettext_lazy("export_category", "Order data")
@ -38,6 +39,13 @@ class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
def additional_form_fields(self):
fields = OrderedDict(
[
(
"field_order",
forms.CharField(
required=False,
widget=forms.HiddenInput,
),
),
(
"fields",
forms.MultipleChoiceField(
@ -50,20 +58,6 @@ class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
),
]
)
if self.is_multievent and self.organizer:
events_qs = self.organizer.events.all()
fields["events"] = forms.ModelMultipleChoiceField(
label=_("Events"),
help_text=_("Unselect events to exclude them from the export."),
queryset=events_qs,
widget=forms.CheckboxSelectMultiple(
attrs={
"class": "scrolling-multiple-choice",
}
),
initial=list(events_qs),
required=False,
)
return fields
def get_filename(self):
@ -74,8 +68,7 @@ class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
return "orders-selective"
def iterate_list(self, form_data):
selected = form_data.get("fields") or []
selected_defs = [self.field_definitions[key] for key in selected if key in self.field_definitions]
selected_defs = self._get_selected_field_definitions(form_data)
yield [field.label for field in selected_defs]
@ -100,19 +93,88 @@ class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
yield self.ProgressSetTotal(total=qs.count())
for position in qs.iterator():
order = position.order
event = order.event if order else None
invoice = getattr(order, "invoice_address", None) if order else None
answers = {a.question_id: a for a in position.answers.all()}
context = RowContext(
position=position,
for context in self._iter_grouped_contexts(qs):
yield [self._format_value(field.getter(context)) for field in selected_defs]
def _iter_grouped_contexts(self, qs):
current_order_id = None
current_positions = []
for position in qs.iterator(chunk_size=500):
if current_order_id is not None and position.order_id != current_order_id:
yield from self._build_grouped_contexts(current_positions)
current_positions = []
current_order_id = position.order_id
current_positions.append(position)
if current_positions:
yield from self._build_grouped_contexts(current_positions)
def _build_grouped_contexts(self, positions):
grouped = OrderedDict()
positions_by_pk = {position.pk: position for position in positions}
for position in positions:
key = self._participant_group_key(position, positions_by_pk)
grouped.setdefault(key, []).append(position)
for grouped_positions in grouped.values():
primary_position = self._get_primary_position(grouped_positions)
order = primary_position.order
answers = {}
for position in grouped_positions:
for answer in position.answers.all():
answers.setdefault(answer.question_id, []).append(answer)
yield RowContext(
positions=tuple(grouped_positions),
position=primary_position,
order=order,
event=event,
invoice=invoice,
event=order.event if order else None,
invoice=self._get_order_invoice_address(order) if order else None,
answers=answers,
)
yield [self._format_value(field.getter(context)) for field in selected_defs]
def _participant_group_key(self, position, positions_by_pk):
identity = self._position_identity(position)
if identity:
return ("participant",) + identity
if position.addon_to_id:
parent = positions_by_pk.get(position.addon_to_id)
if parent:
parent_identity = self._position_identity(parent)
if parent_identity:
return ("participant",) + parent_identity
return ("addon", position.addon_to_id)
return ("position", position.pk)
def _position_identity(self, position):
if position is None:
return None
email = self._normalize_identity_value(getattr(position, "attendee_email", ""))
name = self._normalize_identity_value(getattr(position, "attendee_name_cached", ""))
if email:
return ("email", email, name)
if name:
return ("name", name)
return None
def _normalize_identity_value(self, value):
if value is None:
return ""
return " ".join(str(value).strip().lower().split())
def _get_primary_position(self, positions):
return min(
positions,
key=lambda position: (
1 if getattr(position, "addon_to_id", None) else 0,
getattr(position, "positionid", 0),
getattr(position, "pk", 0),
),
)
def _format_value(self, value):
if value is None:
@ -132,6 +194,12 @@ class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
return getattr(obj, field.attname)
return getattr(obj, field.name)
def _get_grouped_model_field_value(self, ctx, field):
return self._collapse_group_values(
self._get_model_field_value(position, field)
for position in ctx.positions
)
def _get_related_label(self, obj, field):
if obj is None:
return ""
@ -140,6 +208,39 @@ class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
return ""
return str(related)
def _get_grouped_related_label(self, ctx, field):
return self._collapse_group_values(
self._get_related_label(position, field)
for position in ctx.positions
)
def _get_order_invoice_address(self, order):
descriptor = type(order).__dict__.get("invoice_address")
try:
return order.invoice_address
except descriptor.RelatedObjectDoesNotExist:
return None
def _collapse_group_values(self, values):
collected = []
seen = set()
for value in values:
normalized = self._format_value(value)
if normalized in ("", None):
continue
marker = json.dumps(normalized, sort_keys=True, default=str)
if marker in seen:
continue
seen.add(marker)
collected.append(normalized)
if not collected:
return ""
if len(collected) == 1:
return collected[0]
return " | ".join(str(value) for value in collected)
@cached_property
def _has_invoices(self):
return InvoiceAddress.objects.filter(order__event__in=self.events).exists()
@ -169,13 +270,7 @@ class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
obj_getter=lambda ctx: ctx.order,
)
self._add_order_derived_fields(fields)
self._add_model_fields(
fields,
prefix="position",
label_prefix=_("Position"),
model=OrderPosition,
obj_getter=lambda ctx: ctx.position,
)
self._add_position_model_fields(fields)
self._add_position_derived_fields(fields)
if self._has_invoices:
self._add_model_fields(
@ -211,19 +306,28 @@ class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
fields,
"position.item_label",
_("Position: item (label)"),
lambda ctx: str(getattr(ctx.position, "item", "") or ""),
lambda ctx: self._collapse_group_values(
str(getattr(position, "item", "") or "")
for position in ctx.positions
),
)
self._add_field(
fields,
"position.variation_label",
_("Position: variation (label)"),
lambda ctx: str(getattr(ctx.position, "variation", "") or ""),
lambda ctx: self._collapse_group_values(
str(getattr(position, "variation", "") or "")
for position in ctx.positions
),
)
self._add_field(
fields,
"position.subevent_label",
_("Position: subevent (label)"),
lambda ctx: str(getattr(ctx.position, "subevent", "") or ""),
lambda ctx: self._collapse_group_values(
str(getattr(position, "subevent", "") or "")
for position in ctx.positions
),
)
def _add_question_fields(self, fields):
@ -238,12 +342,16 @@ class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
)
def _get_question_answer(self, ctx, qid):
answer = ctx.answers.get(qid)
if not answer:
answers = ctx.answers.get(qid) or []
if not answers:
return ""
if answer.question.type == Question.TYPE_FILE:
return answer.backend_file_url or answer.frontend_file_url or answer.to_string()
return answer.to_string()
values = []
for answer in answers:
if answer.question.type == Question.TYPE_FILE:
values.append(answer.backend_file_url or answer.frontend_file_url or answer.to_string())
else:
values.append(answer.to_string())
return self._collapse_group_values(values)
def _add_model_fields(self, fields, prefix, label_prefix, model, obj_getter):
for field in model._meta.fields:
@ -265,6 +373,26 @@ class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
lambda ctx, f=field, og=obj_getter: self._get_related_label(og(ctx), f),
)
def _add_position_model_fields(self, fields):
for field in OrderPosition._meta.fields:
key = f"position.{field.name}"
label = f"{_('Position')}: {field.verbose_name}"
self._add_field(
fields,
key,
label,
lambda ctx, f=field: self._get_grouped_model_field_value(ctx, f),
)
if field.is_relation and (field.many_to_one or field.one_to_one):
label_key = f"position.{field.name}_label"
label_label = f"{_('Position')}: {field.verbose_name} (label)"
self._add_field(
fields,
label_key,
label_label,
lambda ctx, f=field: self._get_grouped_related_label(ctx, f),
)
def _add_field(self, fields, key, label, getter):
fields[key] = FieldDefinition(label=label, getter=getter)
@ -278,11 +406,42 @@ class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter):
]
return [key for key in preferred if key in self.field_definitions]
def _get_selected_field_definitions(self, form_data):
selected = form_data.get("fields") or []
ordered_keys = self._get_ordered_selected_keys(selected, form_data.get("field_order"))
return [self.field_definitions[key] for key in ordered_keys if key in self.field_definitions]
def _get_ordered_selected_keys(self, selected_keys, raw_field_order):
selected_keys = [key for key in selected_keys if key in self.field_definitions]
if not raw_field_order:
return selected_keys
try:
ordered_keys = json.loads(raw_field_order)
except (TypeError, ValueError):
return selected_keys
if not isinstance(ordered_keys, list):
return selected_keys
selected_set = set(selected_keys)
seen = set()
result = []
for key in ordered_keys:
if not isinstance(key, str) or key not in selected_set or key in seen:
continue
seen.add(key)
result.append(key)
for key in selected_keys:
if key not in seen:
result.append(key)
return result
def _get_selected_events(self, form_data):
if self.is_multievent and self.organizer:
events = Event.objects.filter(organizer=self.organizer)
else:
events = self.events
events = self.events
selected = form_data.get("events") if form_data else None
if not selected:
return events

View File

@ -0,0 +1,78 @@
from collections import OrderedDict
SETTINGS_KEY = "pretix_selective_export_presets"
MAX_PRESETS = 100
MAX_NAME_LENGTH = 120
MAX_VALUES_PER_LIST = 500
def normalize_preset_name(name):
if name is None:
return ""
return str(name).strip()[:MAX_NAME_LENGTH]
def normalize_preset_data(data):
if not isinstance(data, dict):
return {"fields": [], "fieldOrder": [], "events": []}
return {
"fields": _normalize_string_list(data.get("fields")),
"fieldOrder": _normalize_string_list(data.get("fieldOrder")),
"events": _normalize_string_list(data.get("events")),
}
def normalize_presets(raw_presets):
if not isinstance(raw_presets, dict):
return {}
presets = OrderedDict()
for raw_name, raw_data in raw_presets.items():
name = normalize_preset_name(raw_name)
if not name or name in presets:
continue
presets[name] = normalize_preset_data(raw_data)
if len(presets) >= MAX_PRESETS:
break
return dict(presets)
def get_presets(settings_holder):
raw = settings_holder.settings.get(SETTINGS_KEY, as_type=dict, default={})
return normalize_presets(raw)
def save_preset(settings_holder, name, preset_data):
normalized_name = normalize_preset_name(name)
if not normalized_name:
raise ValueError("Preset name is required.")
presets = OrderedDict(normalize_presets(get_presets(settings_holder)))
presets[normalized_name] = normalize_preset_data(preset_data)
while len(presets) > MAX_PRESETS:
presets.popitem(last=False)
settings_holder.settings.set(SETTINGS_KEY, dict(presets))
return normalized_name, presets[normalized_name]
def _normalize_string_list(values):
if not isinstance(values, list):
return []
normalized = []
seen = set()
for value in values:
text = str(value).strip()
if not text or text in seen:
continue
seen.add(text)
normalized.append(text)
if len(normalized) >= MAX_VALUES_PER_LIST:
break
return normalized

View File

@ -10,6 +10,7 @@ from pretix.base.signals import (
from pretix.control.signals import html_page_start, nav_event, nav_organizer
from .exporter import SelectiveFieldExporter
from .presets import get_presets
@receiver(register_data_exporters, dispatch_uid="selective_field_exporter")
@ -25,7 +26,7 @@ def register_multievent_data_exporter(sender, **kwargs):
@receiver(nav_event, dispatch_uid="selective_export_nav_event")
def nav_event_selective_export(sender, request=None, **kwargs):
url = resolve(request.path_info)
if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders', request=request):
if not request.user.has_event_permission(request.organizer, request.event, 'event.orders:read', request=request):
return []
export_url = reverse('control:event.orders.export', kwargs={
'organizer': request.event.organizer.slug,
@ -48,7 +49,9 @@ def nav_event_selective_export(sender, request=None, **kwargs):
@receiver(nav_organizer, dispatch_uid="selective_export_nav_organizer")
def nav_organizer_selective_export(sender, request=None, **kwargs):
url = resolve(request.path_info)
if not request.user.has_organizer_permission(request.organizer, 'can_view_orders', request=request):
if not request.user.get_events_with_permission('event.orders:read', request=request).filter(
organizer=request.organizer
).exists():
return []
export_url = reverse('control:organizer.export', kwargs={
'organizer': request.organizer.slug,
@ -76,5 +79,43 @@ def selective_export_html_page_start(sender, request=None, **kwargs):
identifier = request.GET.get("identifier") or request.GET.get("exporter")
if identifier != SelectiveFieldExporter.identifier:
return ""
if url.url_name == "event.orders.export":
save_url = reverse(
"plugins:pretix_selective_export:event_preset_save",
kwargs={
"organizer": request.event.organizer.slug,
"event": request.event.slug,
},
)
presets = get_presets(request.event)
else:
save_url = reverse(
"plugins:pretix_selective_export:organizer_preset_save",
kwargs={
"organizer": request.organizer.slug,
},
)
presets = get_presets(request.organizer)
template = get_template("pretixplugins/selective_export/control_preset.html")
return template.render({})
return template.render(
{
"preset_config": {
"title": str(_("Presets")),
"selectPlaceholder": str(_("Select a preset")),
"load": str(_("Load")),
"namePlaceholder": str(_("Preset name")),
"save": str(_("Save preset")),
"nameRequired": str(_("Please enter a preset name.")),
"confirmOverwrite": str(_("Overwrite existing preset?")),
"saveFailed": str(_("Saving the preset failed.")),
"fieldOrderTitle": str(_("Column order")),
"fieldOrderHelp": str(_("Selected fields appear in export order. Use the arrows to move them.")),
"moveUp": str(_("Move up")),
"moveDown": str(_("Move down")),
"sharedHelp": str(_("Presets are shared for all users with access to this export.")),
"saveUrl": save_url,
"initialPresets": presets,
}
},
request,
)

View File

@ -1,7 +1,7 @@
(function () {
"use strict";
const configEl = document.getElementById("selective-export-preset-i18n");
const configEl = document.getElementById("selective-export-preset-config");
if (!configEl) {
return;
}
@ -28,25 +28,6 @@
return;
}
const storageKey = `pretix_selective_export_presets:${window.location.pathname}`;
const readPresets = function () {
try {
const raw = window.localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : {};
} catch (e) {
return {};
}
};
const writePresets = function (presets) {
try {
window.localStorage.setItem(storageKey, JSON.stringify(presets));
} catch (e) {
// Ignore storage errors (e.g. private mode).
}
};
const getCheckedValues = function (name) {
return Array.from(form.querySelectorAll(`input[name="${name}"]:checked`)).map((input) => input.value);
};
@ -115,6 +96,14 @@
controls.appendChild(loadGroup);
controls.appendChild(saveGroup);
if (i18n.sharedHelp) {
const help = document.createElement("p");
help.className = "help-block";
help.style.marginTop = "6px";
help.textContent = i18n.sharedHelp;
controls.appendChild(help);
}
wrapper.appendChild(label);
wrapper.appendChild(controls);
@ -152,12 +141,169 @@
}
};
let presets = readPresets();
let presets = i18n.initialPresets || {};
refreshOptions(presets);
const formPrefix = exporterInput.value;
const fieldsName = `${formPrefix}-fields`;
const fieldOrderName = `${formPrefix}-field_order`;
const eventsName = `${formPrefix}-events`;
const fieldInputs = Array.from(form.querySelectorAll(`input[name="${fieldsName}"]`));
const fieldOrderInput = form.querySelector(`input[name="${fieldOrderName}"]`);
if (!fieldInputs.length || !fieldOrderInput) {
return;
}
const fieldLabels = new Map(
fieldInputs.map((input) => {
const label = input.closest("label");
return [input.value, label ? label.textContent.trim() : input.value];
})
);
const readFieldOrder = function () {
try {
const parsed = JSON.parse(fieldOrderInput.value || "[]");
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
};
const setFieldOrder = function (values) {
fieldOrderInput.value = JSON.stringify(values);
};
const getSelectedFieldValues = function () {
return fieldInputs.filter((input) => input.checked).map((input) => input.value);
};
const syncFieldOrder = function (preferredOrder) {
const selected = getSelectedFieldValues();
const selectedSet = new Set(selected);
const ordered = [];
const seen = new Set();
(preferredOrder || []).forEach((value) => {
if (selectedSet.has(value) && !seen.has(value)) {
seen.add(value);
ordered.push(value);
}
});
selected.forEach((value) => {
if (!seen.has(value)) {
seen.add(value);
ordered.push(value);
}
});
setFieldOrder(ordered);
return ordered;
};
const buildFieldOrderUI = function () {
const wrapper = document.createElement("div");
wrapper.className = "form-group";
const label = document.createElement("label");
label.className = "col-md-3 control-label";
label.textContent = i18n.fieldOrderTitle || "Column order";
const controls = document.createElement("div");
controls.className = "col-md-9";
const help = document.createElement("p");
help.className = "help-block";
help.textContent = i18n.fieldOrderHelp || "Selected fields appear in export order. Use the arrows to move them.";
const list = document.createElement("ul");
list.className = "list-group";
list.style.marginBottom = "0";
controls.appendChild(help);
controls.appendChild(list);
wrapper.appendChild(label);
wrapper.appendChild(controls);
return { wrapper, list };
};
const fieldOrderUI = buildFieldOrderUI();
const fieldContainer = fieldInputs[0].closest(".form-group");
if (fieldContainer) {
fieldContainer.insertAdjacentElement("afterend", fieldOrderUI.wrapper);
} else {
fieldset.appendChild(fieldOrderUI.wrapper);
}
const renderFieldOrder = function () {
const ordered = syncFieldOrder(readFieldOrder());
fieldOrderUI.list.innerHTML = "";
ordered.forEach((value, index) => {
const item = document.createElement("li");
item.className = "list-group-item";
const text = document.createElement("span");
text.textContent = fieldLabels.get(value) || value;
const buttons = document.createElement("span");
buttons.className = "pull-right";
const upButton = document.createElement("button");
upButton.type = "button";
upButton.className = "btn btn-default btn-xs";
upButton.textContent = "↑";
upButton.title = i18n.moveUp || "Move up";
upButton.disabled = index === 0;
upButton.addEventListener("click", function () {
const current = readFieldOrder();
const currentIndex = current.indexOf(value);
if (currentIndex <= 0) {
return;
}
current.splice(currentIndex, 1);
current.splice(currentIndex - 1, 0, value);
setFieldOrder(current);
renderFieldOrder();
});
const downButton = document.createElement("button");
downButton.type = "button";
downButton.className = "btn btn-default btn-xs";
downButton.textContent = "↓";
downButton.title = i18n.moveDown || "Move down";
downButton.style.marginLeft = "4px";
downButton.disabled = index === ordered.length - 1;
downButton.addEventListener("click", function () {
const current = readFieldOrder();
const currentIndex = current.indexOf(value);
if (currentIndex === -1 || currentIndex >= current.length - 1) {
return;
}
current.splice(currentIndex, 1);
current.splice(currentIndex + 1, 0, value);
setFieldOrder(current);
renderFieldOrder();
});
buttons.appendChild(upButton);
buttons.appendChild(downButton);
item.appendChild(text);
item.appendChild(buttons);
fieldOrderUI.list.appendChild(item);
});
fieldOrderUI.wrapper.style.display = ordered.length ? "" : "none";
};
fieldInputs.forEach((input) => {
input.addEventListener("change", function () {
renderFieldOrder();
});
});
loadBtn.addEventListener("click", function () {
const name = select.value;
@ -167,6 +313,8 @@
const preset = presets[name];
setCheckedValues(fieldsName, preset.fields || []);
setCheckedValues(eventsName, preset.events || []);
setFieldOrder(Array.isArray(preset.fieldOrder) ? preset.fieldOrder : []);
renderFieldOrder();
});
saveBtn.addEventListener("click", function () {
@ -178,12 +326,40 @@
if (presets[name] && !window.confirm(i18n.confirmOverwrite || "Overwrite existing preset?")) {
return;
}
presets[name] = {
const payload = {
fields: getCheckedValues(fieldsName),
fieldOrder: readFieldOrder(),
events: getCheckedValues(eventsName),
};
writePresets(presets);
refreshOptions(presets, name);
nameInput.value = "";
saveBtn.disabled = true;
window.fetch(i18n.saveUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": form.querySelector('input[name="csrfmiddlewaretoken"]')?.value || "",
},
credentials: "same-origin",
body: JSON.stringify({
name: name,
preset: payload,
}),
}).then(function (response) {
return response.json().then(function (data) {
if (!response.ok) {
throw new Error(data.error || i18n.saveFailed || "Saving the preset failed.");
}
return data;
});
}).then(function (data) {
presets = data.presets || presets;
refreshOptions(presets, data.name || name);
nameInput.value = "";
}).catch(function (error) {
window.alert(error.message || i18n.saveFailed || "Saving the preset failed.");
}).finally(function () {
saveBtn.disabled = false;
});
});
renderFieldOrder();
})();

View File

@ -1,13 +1,3 @@
{% load i18n static %}
<script id="selective-export-preset-i18n" type="application/json">
{
"title": "{% trans "Presets" %}",
"selectPlaceholder": "{% trans "Select a preset" %}",
"load": "{% trans "Load" %}",
"namePlaceholder": "{% trans "Preset name" %}",
"save": "{% trans "Save preset" %}",
"nameRequired": "{% trans "Please enter a preset name." %}",
"confirmOverwrite": "{% trans "Overwrite existing preset?" %}"
}
</script>
{% load static %}
{{ preset_config|json_script:"selective-export-preset-config" }}
<script defer src="{% static "pretixplugins/selective_export/presets.js" %}"></script>

View File

@ -0,0 +1,18 @@
from django.urls import re_path
from . import views
app_name = "selective_export"
urlpatterns = [
re_path(
r"^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/selective-export/presets/save/$",
views.EventPresetSaveView.as_view(),
name="event_preset_save",
),
re_path(
r"^control/organizer/(?P<organizer>[^/]+)/selective-export/presets/save/$",
views.OrganizerPresetSaveView.as_view(),
name="organizer_preset_save",
),
]

View File

@ -0,0 +1,50 @@
import json
from django.core.exceptions import PermissionDenied
from django.http import JsonResponse
from django.views import View
from pretix.control.permissions import (
EventPermissionRequiredMixin,
OrganizerPermissionRequiredMixin,
)
from .presets import get_presets, save_preset
class EventPresetSaveView(EventPermissionRequiredMixin, View):
permission = "event.orders:read"
def post(self, request, *args, **kwargs):
return _save_preset_response(request.event, request)
class OrganizerPresetSaveView(OrganizerPermissionRequiredMixin, View):
permission = None
def post(self, request, *args, **kwargs):
if not request.user.get_events_with_permission("event.orders:read", request=request).filter(
organizer=request.organizer
).exists():
raise PermissionDenied("You do not have permission to view this content.")
return _save_preset_response(request.organizer, request)
def _save_preset_response(settings_holder, request):
try:
payload = json.loads(request.body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return JsonResponse({"error": "Invalid JSON payload."}, status=400)
try:
name, preset = save_preset(settings_holder, payload.get("name"), payload.get("preset"))
except ValueError as exc:
return JsonResponse({"error": str(exc)}, status=400)
return JsonResponse(
{
"name": name,
"preset": preset,
"presets": get_presets(settings_holder),
}
)

View File

@ -23,6 +23,11 @@ install_requires =
pretix>=2024.3
python_requires = >=3.10
[options.package_data]
pretix_selective_export =
templates/**/*
static/**/*
[options.packages.find]
exclude =
tests
@ -31,8 +36,3 @@ exclude =
[options.entry_points]
pretix.plugin =
selective_export = pretix_selective_export
[options.package_data]
pretix_selective_export =
templates/**/*
static/**/*

View File

@ -0,0 +1,264 @@
from types import SimpleNamespace
from pretix_selective_export.exporter import FieldDefinition, SelectiveFieldExporter
class AnswersList:
def __init__(self, answers=None):
self._answers = answers or []
def all(self):
return list(self._answers)
def make_exporter():
return SelectiveFieldExporter.__new__(SelectiveFieldExporter)
def make_position(
pk,
order,
positionid,
attendee_name="",
attendee_email="",
item="",
addon_to_id=None,
answers=None,
):
return SimpleNamespace(
pk=pk,
order=order,
order_id=order.pk,
positionid=positionid,
attendee_name_cached=attendee_name,
attendee_email=attendee_email,
item=item,
variation="",
subevent="",
addon_to_id=addon_to_id,
answers=AnswersList(answers),
)
def make_answer(question_id, value, question_type="text"):
question = SimpleNamespace(type=question_type)
return SimpleNamespace(
question_id=question_id,
question=question,
backend_file_url="",
frontend_file_url="",
to_string=lambda: value,
)
def test_collapses_same_participant_with_multiple_positions():
exporter = make_exporter()
order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org", item="Module A"),
make_position(2, order, 2, attendee_name="Alex Example", attendee_email="alex@example.org", item="Module B"),
]
contexts = list(exporter._build_grouped_contexts(positions))
assert len(contexts) == 1
assert [position.pk for position in contexts[0].positions] == [1, 2]
assert exporter._collapse_group_values(position.item for position in contexts[0].positions) == "Module A | Module B"
def test_keeps_distinct_participants_in_same_order_separate():
exporter = make_exporter()
order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org", item="Module A"),
make_position(2, order, 2, attendee_name="Blair Example", attendee_email="blair@example.org", item="Module B"),
]
contexts = list(exporter._build_grouped_contexts(positions))
assert len(contexts) == 2
assert [position.pk for position in contexts[0].positions] == [1]
assert [position.pk for position in contexts[1].positions] == [2]
def test_groups_addon_without_identity_into_parent_participant():
exporter = make_exporter()
order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org", item="Main ticket"),
make_position(2, order, 2, item="Addon", addon_to_id=1),
]
contexts = list(exporter._build_grouped_contexts(positions))
assert len(contexts) == 1
assert [position.pk for position in contexts[0].positions] == [1, 2]
def test_merges_question_answers_from_grouped_positions():
exporter = make_exporter()
order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", answers=[make_answer(5, "Module A")]),
make_position(2, order, 2, attendee_name="Alex Example", answers=[make_answer(5, "Module B")]),
]
context = list(exporter._build_grouped_contexts(positions))[0]
assert exporter._get_question_answer(context, 5) == "Module A | Module B"
def test_collapses_same_name_without_email_when_no_better_identity_exists():
exporter = make_exporter()
order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", item="Module A"),
make_position(2, order, 2, attendee_name="Alex Example", item="Module B"),
]
contexts = list(exporter._build_grouped_contexts(positions))
assert len(contexts) == 1
assert [position.pk for position in contexts[0].positions] == [1, 2]
def test_keeps_same_name_separate_when_emails_differ():
exporter = make_exporter()
order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex.one@example.org"),
make_position(2, order, 2, attendee_name="Alex Example", attendee_email="alex.two@example.org"),
]
contexts = list(exporter._build_grouped_contexts(positions))
assert len(contexts) == 2
assert [position.pk for position in contexts[0].positions] == [1]
assert [position.pk for position in contexts[1].positions] == [2]
def test_keeps_same_email_separate_when_names_differ():
exporter = make_exporter()
order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", attendee_email="shared@example.org"),
make_position(2, order, 2, attendee_name="A. Example", attendee_email="shared@example.org"),
]
contexts = list(exporter._build_grouped_contexts(positions))
assert len(contexts) == 2
assert [position.pk for position in contexts[0].positions] == [1]
assert [position.pk for position in contexts[1].positions] == [2]
def test_keeps_positions_without_identity_separate():
exporter = make_exporter()
order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None)
positions = [
make_position(1, order, 1, item="Module A"),
make_position(2, order, 2, item="Module B"),
]
contexts = list(exporter._build_grouped_contexts(positions))
assert len(contexts) == 2
assert [position.pk for position in contexts[0].positions] == [1]
assert [position.pk for position in contexts[1].positions] == [2]
def test_keeps_addon_with_own_identity_separate_from_parent():
exporter = make_exporter()
order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None)
positions = [
make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org", item="Main ticket"),
make_position(2, order, 2, attendee_name="Blair Example", attendee_email="blair@example.org", item="Addon", addon_to_id=1),
]
contexts = list(exporter._build_grouped_contexts(positions))
assert len(contexts) == 2
assert [position.pk for position in contexts[0].positions] == [1]
assert [position.pk for position in contexts[1].positions] == [2]
def test_primary_position_prefers_non_addon_row():
exporter = make_exporter()
order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None)
positions = [
make_position(2, order, 2, attendee_name="Alex Example", attendee_email="alex@example.org", item="Addon", addon_to_id=1),
make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org", item="Main ticket"),
]
context = list(exporter._build_grouped_contexts(positions))[0]
assert context.position.pk == 1
def test_collapse_group_values_deduplicates_repeated_values():
exporter = make_exporter()
assert exporter._collapse_group_values(["Module A", "Module A", "", "Module B"]) == "Module A | Module B"
def test_field_order_reorders_selected_fields():
exporter = make_exporter()
exporter.field_definitions = {
"event.name": FieldDefinition(label="Event", getter=lambda ctx: ""),
"order.email": FieldDefinition(label="Email", getter=lambda ctx: ""),
"position.attendee_name_cached": FieldDefinition(label="Name", getter=lambda ctx: ""),
}
ordered = exporter._get_ordered_selected_keys(
["event.name", "order.email", "position.attendee_name_cached"],
'["position.attendee_name_cached", "event.name"]',
)
assert ordered == ["position.attendee_name_cached", "event.name", "order.email"]
def test_field_order_ignores_unknown_duplicate_and_unselected_keys():
exporter = make_exporter()
exporter.field_definitions = {
"event.name": FieldDefinition(label="Event", getter=lambda ctx: ""),
"order.email": FieldDefinition(label="Email", getter=lambda ctx: ""),
"position.attendee_name_cached": FieldDefinition(label="Name", getter=lambda ctx: ""),
}
ordered = exporter._get_ordered_selected_keys(
["event.name", "order.email"],
'["order.email", "missing", "order.email", "position.attendee_name_cached"]',
)
assert ordered == ["order.email", "event.name"]
def test_field_order_falls_back_to_selection_order_on_invalid_json():
exporter = make_exporter()
exporter.field_definitions = {
"event.name": FieldDefinition(label="Event", getter=lambda ctx: ""),
"order.email": FieldDefinition(label="Email", getter=lambda ctx: ""),
}
ordered = exporter._get_ordered_selected_keys(
["event.name", "order.email"],
"not-json",
)
assert ordered == ["event.name", "order.email"]
def test_get_order_invoice_address_handles_missing_relation():
exporter = make_exporter()
class MissingInvoiceDescriptor:
class RelatedObjectDoesNotExist(Exception):
pass
def __get__(self, instance, owner):
raise self.RelatedObjectDoesNotExist()
class MissingInvoiceOrder:
invoice_address = MissingInvoiceDescriptor()
assert exporter._get_order_invoice_address(MissingInvoiceOrder()) is None

View File

@ -0,0 +1,116 @@
from types import SimpleNamespace
from pretix_selective_export.presets import (
SETTINGS_KEY,
get_presets,
normalize_presets,
save_preset,
)
class DummySettings:
def __init__(self, initial=None):
self._data = dict(initial or {})
def get(self, key, as_type=None, default=None):
return self._data.get(key, default)
def set(self, key, value):
self._data[key] = value
def make_holder(initial=None):
return SimpleNamespace(settings=DummySettings(initial))
def test_normalize_presets_discards_invalid_entries():
normalized = normalize_presets(
{
" Useful ": {
"fields": [" order.code ", "order.code", "", 12],
"fieldOrder": ["position.attendee_name_cached", "position.attendee_name_cached"],
"events": [" TE1 ", ""],
},
"": {"fields": ["ignored"]},
"Other": "invalid",
}
)
assert normalized == {
"Useful": {
"fields": ["order.code", "12"],
"fieldOrder": ["position.attendee_name_cached"],
"events": ["TE1"],
},
"Other": {
"fields": [],
"fieldOrder": [],
"events": [],
},
}
def test_save_preset_persists_to_settings_holder():
holder = make_holder()
name, preset = save_preset(
holder,
" Shared preset ",
{
"fields": ["order.code", "position.attendee_name_cached"],
"fieldOrder": ["position.attendee_name_cached", "order.code"],
"events": ["TE1"],
},
)
assert name == "Shared preset"
assert preset["fields"] == ["order.code", "position.attendee_name_cached"]
assert get_presets(holder) == {
"Shared preset": {
"fields": ["order.code", "position.attendee_name_cached"],
"fieldOrder": ["position.attendee_name_cached", "order.code"],
"events": ["TE1"],
}
}
assert holder.settings.get(SETTINGS_KEY)["Shared preset"]["events"] == ["TE1"]
def test_save_preset_overwrites_existing_name():
holder = make_holder(
{
SETTINGS_KEY: {
"Shared preset": {
"fields": ["order.code"],
"fieldOrder": ["order.code"],
"events": [],
}
}
}
)
save_preset(
holder,
"Shared preset",
{
"fields": ["position.attendee_name_cached"],
"fieldOrder": ["position.attendee_name_cached"],
"events": ["TE1"],
},
)
assert get_presets(holder)["Shared preset"] == {
"fields": ["position.attendee_name_cached"],
"fieldOrder": ["position.attendee_name_cached"],
"events": ["TE1"],
}
def test_save_preset_requires_non_blank_name():
holder = make_holder()
try:
save_preset(holder, " ", {"fields": []})
except ValueError as exc:
assert str(exc) == "Preset name is required."
else:
raise AssertionError("Expected ValueError for blank preset name")