diff --git a/README.md b/README.md index e5228a3..728e103 100644 --- a/README.md +++ b/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//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//pretix-plugins:1.0.0` -- `git.coopcloud.tech//pretix-plugins:latest` ### Manual ```bash -docker build -t git.coopcloud.tech//pretix-plugins:1.0.0 . -docker push git.coopcloud.tech//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 diff --git a/plugins/attendance_confirm_plugin/MANIFEST.in b/plugins/attendance_confirm_plugin/MANIFEST.in index 863544d..c63813b 100644 --- a/plugins/attendance_confirm_plugin/MANIFEST.in +++ b/plugins/attendance_confirm_plugin/MANIFEST.in @@ -1,4 +1,2 @@ -recursive-include -pretix_attendance_confirm/templates * -recursive-include -pretix_attendance_confirm/static * \ No newline at end of file +recursive-include pretix_attendance_confirm/templates * +recursive-include pretix_attendance_confirm/static * diff --git a/plugins/attendance_confirm_plugin/README.md b/plugins/attendance_confirm_plugin/README.md index f11afc2..5ecf7a1 100644 --- a/plugins/attendance_confirm_plugin/README.md +++ b/plugins/attendance_confirm_plugin/README.md @@ -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. diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/PKG-INFO b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/PKG-INFO deleted file mode 100644 index 842f146..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/PKG-INFO +++ /dev/null @@ -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 -``` diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/SOURCES.txt b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/SOURCES.txt deleted file mode 100644 index 82fddab..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/SOURCES.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/dependency_links.txt b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/entry_points.txt b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/entry_points.txt deleted file mode 100644 index b8d0423..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[pretix.plugin] -attendance_confirm = pretix_attendance_confirm diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/requires.txt b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/requires.txt deleted file mode 100644 index 213e748..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/requires.txt +++ /dev/null @@ -1 +0,0 @@ -pretix>=2024.3 diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/top_level.txt b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/top_level.txt deleted file mode 100644 index 49828ba..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -pretix_attendance_confirm diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/__init__.cpython-313.pyc b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 3f52e75..0000000 Binary files a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/apps.cpython-313.pyc b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index fc0017a..0000000 Binary files a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/apps.cpython-313.pyc and /dev/null differ diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/forms.cpython-313.pyc b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/forms.cpython-313.pyc deleted file mode 100644 index eac2392..0000000 Binary files a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/forms.cpython-313.pyc and /dev/null differ diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/signals.cpython-313.pyc b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/signals.cpython-313.pyc deleted file mode 100644 index de9a955..0000000 Binary files a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/signals.cpython-313.pyc and /dev/null differ diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/urls.cpython-313.pyc b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/urls.cpython-313.pyc deleted file mode 100644 index 99c767b..0000000 Binary files a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/urls.cpython-313.pyc and /dev/null differ diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/views.cpython-313.pyc b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/views.cpython-313.pyc deleted file mode 100644 index 50aa190..0000000 Binary files a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/views.cpython-313.pyc and /dev/null differ diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/views.py b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/views.py index 3b53af1..0981251 100644 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/views.py +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/views.py @@ -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), + ), + ) diff --git a/plugins/attendance_confirm_plugin/setup.cfg b/plugins/attendance_confirm_plugin/setup.cfg index 157dcbf..311cf1b 100644 --- a/plugins/attendance_confirm_plugin/setup.cfg +++ b/plugins/attendance_confirm_plugin/setup.cfg @@ -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/**/* \ No newline at end of file diff --git a/plugins/attendance_confirm_plugin/tests/test_recipient_grouping.py b/plugins/attendance_confirm_plugin/tests/test_recipient_grouping.py new file mode 100644 index 0000000..f705327 --- /dev/null +++ b/plugins/attendance_confirm_plugin/tests/test_recipient_grouping.py @@ -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")] diff --git a/plugins/selective_export_plugin/MANIFEST.in b/plugins/selective_export_plugin/MANIFEST.in index fb93042..86f86e5 100644 --- a/plugins/selective_export_plugin/MANIFEST.in +++ b/plugins/selective_export_plugin/MANIFEST.in @@ -1,4 +1,2 @@ -recursive-include -pretix_selective_export/templates * -recursive-include -pretix_selective_export/static * \ No newline at end of file +recursive-include pretix_selective_export/templates * +recursive-include pretix_selective_export/static * diff --git a/plugins/selective_export_plugin/README.md b/plugins/selective_export_plugin/README.md index b8d3c05..3f53267 100644 --- a/plugins/selective_export_plugin/README.md +++ b/plugins/selective_export_plugin/README.md @@ -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) diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/PKG-INFO b/plugins/selective_export_plugin/pretix_selective_export.egg-info/PKG-INFO deleted file mode 100644 index ee1c9dc..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/PKG-INFO +++ /dev/null @@ -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). diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/SOURCES.txt b/plugins/selective_export_plugin/pretix_selective_export.egg-info/SOURCES.txt deleted file mode 100644 index 6d08c8f..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/SOURCES.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/dependency_links.txt b/plugins/selective_export_plugin/pretix_selective_export.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/entry_points.txt b/plugins/selective_export_plugin/pretix_selective_export.egg-info/entry_points.txt deleted file mode 100644 index 971b755..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[pretix.plugin] -selective_export = pretix_selective_export diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/requires.txt b/plugins/selective_export_plugin/pretix_selective_export.egg-info/requires.txt deleted file mode 100644 index 213e748..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/requires.txt +++ /dev/null @@ -1 +0,0 @@ -pretix>=2024.3 diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/top_level.txt b/plugins/selective_export_plugin/pretix_selective_export.egg-info/top_level.txt deleted file mode 100644 index 64d650c..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -pretix_selective_export diff --git a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/__init__.cpython-313.pyc b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 674c1e1..0000000 Binary files a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/apps.cpython-313.pyc b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index 60bcc7d..0000000 Binary files a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/apps.cpython-313.pyc and /dev/null differ diff --git a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/exporter.cpython-313.pyc b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/exporter.cpython-313.pyc deleted file mode 100644 index 3707a2f..0000000 Binary files a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/exporter.cpython-313.pyc and /dev/null differ diff --git a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/signals.cpython-313.pyc b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/signals.cpython-313.pyc deleted file mode 100644 index e6d8fc9..0000000 Binary files a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/signals.cpython-313.pyc and /dev/null differ diff --git a/plugins/selective_export_plugin/pretix_selective_export/exporter.py b/plugins/selective_export_plugin/pretix_selective_export/exporter.py index 47fd4d4..4d85149 100644 --- a/plugins/selective_export_plugin/pretix_selective_export/exporter.py +++ b/plugins/selective_export_plugin/pretix_selective_export/exporter.py @@ -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 diff --git a/plugins/selective_export_plugin/pretix_selective_export/presets.py b/plugins/selective_export_plugin/pretix_selective_export/presets.py new file mode 100644 index 0000000..adb330b --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export/presets.py @@ -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 diff --git a/plugins/selective_export_plugin/pretix_selective_export/signals.py b/plugins/selective_export_plugin/pretix_selective_export/signals.py index 425e947..f42f8fe 100644 --- a/plugins/selective_export_plugin/pretix_selective_export/signals.py +++ b/plugins/selective_export_plugin/pretix_selective_export/signals.py @@ -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, + ) diff --git a/plugins/selective_export_plugin/pretix_selective_export/static/pretixplugins/selective_export/presets.js b/plugins/selective_export_plugin/pretix_selective_export/static/pretixplugins/selective_export/presets.js index 4d4b981..6e230e3 100644 --- a/plugins/selective_export_plugin/pretix_selective_export/static/pretixplugins/selective_export/presets.js +++ b/plugins/selective_export_plugin/pretix_selective_export/static/pretixplugins/selective_export/presets.js @@ -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(); })(); diff --git a/plugins/selective_export_plugin/pretix_selective_export/templates/pretixplugins/selective_export/control_preset.html b/plugins/selective_export_plugin/pretix_selective_export/templates/pretixplugins/selective_export/control_preset.html index 1ac7df6..0ed6d18 100644 --- a/plugins/selective_export_plugin/pretix_selective_export/templates/pretixplugins/selective_export/control_preset.html +++ b/plugins/selective_export_plugin/pretix_selective_export/templates/pretixplugins/selective_export/control_preset.html @@ -1,13 +1,3 @@ -{% load i18n static %} - +{% load static %} +{{ preset_config|json_script:"selective-export-preset-config" }} diff --git a/plugins/selective_export_plugin/pretix_selective_export/urls.py b/plugins/selective_export_plugin/pretix_selective_export/urls.py new file mode 100644 index 0000000..007e973 --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export/urls.py @@ -0,0 +1,18 @@ +from django.urls import re_path + +from . import views + +app_name = "selective_export" + +urlpatterns = [ + re_path( + r"^control/event/(?P[^/]+)/(?P[^/]+)/selective-export/presets/save/$", + views.EventPresetSaveView.as_view(), + name="event_preset_save", + ), + re_path( + r"^control/organizer/(?P[^/]+)/selective-export/presets/save/$", + views.OrganizerPresetSaveView.as_view(), + name="organizer_preset_save", + ), +] diff --git a/plugins/selective_export_plugin/pretix_selective_export/views.py b/plugins/selective_export_plugin/pretix_selective_export/views.py new file mode 100644 index 0000000..88f1cfa --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export/views.py @@ -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), + } + ) diff --git a/plugins/selective_export_plugin/setup.cfg b/plugins/selective_export_plugin/setup.cfg index 5d44e9e..011aae9 100644 --- a/plugins/selective_export_plugin/setup.cfg +++ b/plugins/selective_export_plugin/setup.cfg @@ -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/**/* diff --git a/plugins/selective_export_plugin/tests/test_exporter_grouping.py b/plugins/selective_export_plugin/tests/test_exporter_grouping.py new file mode 100644 index 0000000..b51d9c2 --- /dev/null +++ b/plugins/selective_export_plugin/tests/test_exporter_grouping.py @@ -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 diff --git a/plugins/selective_export_plugin/tests/test_presets.py b/plugins/selective_export_plugin/tests/test_presets.py new file mode 100644 index 0000000..923d0ea --- /dev/null +++ b/plugins/selective_export_plugin/tests/test_presets.py @@ -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")