generated from coop-cloud/example
chore: updates plugins o v2
This commit is contained in:
26
README.md
26
README.md
@ -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
|
||||
|
||||
|
||||
@ -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 *
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
```
|
||||
@ -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
|
||||
@ -1 +0,0 @@
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
[pretix.plugin]
|
||||
attendance_confirm = pretix_attendance_confirm
|
||||
@ -1 +0,0 @@
|
||||
pretix>=2024.3
|
||||
@ -1 +0,0 @@
|
||||
pretix_attendance_confirm
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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/**/*
|
||||
@ -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")]
|
||||
@ -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 *
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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).
|
||||
@ -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
|
||||
@ -1 +0,0 @@
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
[pretix.plugin]
|
||||
selective_export = pretix_selective_export
|
||||
@ -1 +0,0 @@
|
||||
pretix>=2024.3
|
||||
@ -1 +0,0 @@
|
||||
pretix_selective_export
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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();
|
||||
})();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
}
|
||||
)
|
||||
@ -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/**/*
|
||||
|
||||
264
plugins/selective_export_plugin/tests/test_exporter_grouping.py
Normal file
264
plugins/selective_export_plugin/tests/test_exporter_grouping.py
Normal 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
|
||||
116
plugins/selective_export_plugin/tests/test_presets.py
Normal file
116
plugins/selective_export_plugin/tests/test_presets.py
Normal 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")
|
||||
Reference in New Issue
Block a user