diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 8139990..0000000 --- a/.drone.yml +++ /dev/null @@ -1,39 +0,0 @@ ---- -kind: pipeline -name: deploy to swarm-test.autonomic.zone -steps: - - name: deployment - image: git.coopcloud.tech/coop-cloud/stack-ssh-deploy:latest - settings: - host: swarm-test.autonomic.zone - stack: example_com # UPDATE ME - generate_secrets: true - purge: true - deploy_key: - from_secret: drone_ssh_swarm_test - networks: - - proxy - environment: - DOMAIN: example.swarm-test.autonomic.zone # UPDATE ME - STACK_NAME: example_com # UPDATE ME - LETS_ENCRYPT_ENV: staging - # Also set any config versions from abra.sh -trigger: - branch: - - main ---- -kind: pipeline -name: generate recipe catalogue -steps: - - name: release a new version - image: plugins/downstream - settings: - server: https://build.coopcloud.tech - token: - from_secret: drone_abra-bot_token - fork: true - repositories: - - toolshed/auto-recipes-catalogue-json - -trigger: - event: tag diff --git a/.env.sample b/.env.sample deleted file mode 100644 index c079a31..0000000 --- a/.env.sample +++ /dev/null @@ -1,8 +0,0 @@ -TYPE={{ .Name }} - -DOMAIN={{ .Name }}.example.com - -## Domain aliases -#EXTRA_DOMAINS=', `www.{{ .Name }}.example.com`' - -LETS_ENCRYPT_ENV=production diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 7a6353d..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.envrc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eeba00f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM pretix/standalone:stable + +USER root + +# Copy the entire repo so local plugin paths in plugins.txt resolve correctly +COPY . /tmp/bundle/ + +# Install all plugins listed in plugins.txt. +# Lines starting with # and empty lines are ignored. +# Local paths (./plugins/foo/) are resolved relative to /tmp/bundle/. +RUN cd /tmp/bundle && \ + grep -v '^\s*#' plugins.txt | grep -v '^\s*$' | \ + xargs pip3 install && \ + rm -rf /tmp/bundle + +USER pretixuser + +# Rebuild static files and asset pipeline after plugin installation +RUN cd /pretix/src && make production \ No newline at end of file diff --git a/README.md b/README.md index aa78cfe..e5228a3 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,143 @@ -# {{ .Name }} +# Pretix Plugins -{{ .Description }} +A community-maintained mono-repository of custom pretix plugins and tooling to +build a bundled Docker image for use with the +[Co-op Cloud pretix recipe](https://git.coopcloud.tech/coop-cloud/pretix). - +--- -* **Category**: {{ .Category }} -* **Status**: {{ .Status }} -* **Image**: {{ .Image }} -* **Healthcheck**: {{ .Healthcheck }} -* **Backups**: {{ .Backups }} -* **Email**: {{ .Email }} -* **Tests**: {{ .Tests }} -* **SSO**: {{ .SSO }} +## How it works - +``` +plugins/ ← plugin source code lives here (Python packages) + my-plugin/ + pretix_my_plugin/ + setup.py +plugins.txt ← select which plugins go into the Docker image +Dockerfile ← builds the image from plugins.txt +.gitea/workflows/ ← CI: automatically builds & pushes image on every tag +``` -## Quick start +Each plugin is a standard Python package inside `plugins/`. The `plugins.txt` +file controls which of those plugins (and optionally any PyPI or external +plugins) are installed into the final Docker image. -* `abra app new {{ .Name }} --secrets` -* `abra app config ` -* `abra app deploy ` +The resulting image is a drop-in replacement for `pretix/standalone` and is +used via the optional `compose.plugin.yml` override in the pretix recipe. -For more, see [`docs.coopcloud.tech`](https://docs.coopcloud.tech). +--- + +## Using the image in your pretix deployment + +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 +``` + +If `PRETIX_PLUGIN_IMAGE` is not set, the standard `pretix/standalone` image +is used — operators who do not need plugins are not affected at all. + +--- + +## Selecting plugins + +Edit `plugins.txt` to choose which plugins are included in your image. +Each non-empty, non-comment line is passed directly to `pip install`. + +``` +# Local plugins from this repo (recommended for custom plugins): +./plugins/attendance_confirm_plugin/ +./plugins/selective_export_plugin/ + +# Community plugins from PyPI: +pretix-pages + +# External plugins via Git URL: +git+https://git.coopcloud.tech/other-org/pretix-some-plugin.git@1.0.0 +``` + +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 +``` + +--- + +## Contributing a plugin + +1. Create a new directory under `plugins/` named after your plugin: + ``` + plugins/my-new-plugin/ + ``` + +2. Add a valid pretix plugin Python package inside it (see structure below). + +3. Add it to `plugins.txt` if you want it included in the default image build. + +4. Open a pull request. + +### Required plugin structure + +``` +plugins/my-new-plugin/ +├── pretix_my_new_plugin/ +│ ├── __init__.py +│ └── apps.py ← AppConfig + PretixPluginMeta required +└── setup.py ← entry_points for pretix.plugin required +``` + +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 + +``` +pretix-plugin-catalogue/ +├── README.md +├── Dockerfile ← builds the bundle image +├── plugins.txt ← controls which plugins are included +├── .gitea/ +│ └── workflows/ +│ └── docker.yml ← CI: build & push on tag +└── plugins/ + └── example-plugin/ ← reference plugin implementation + ├── pretix_example_plugin/ + │ ├── __init__.py + │ └── apps.py + └── setup.py +``` + +## Credits + +The plugins attendance_confirm_plugin and selective_export_plugin were developed by make IT social (https://makeitsocial.net/). Thanks! \ No newline at end of file diff --git a/abra.sh b/abra.sh deleted file mode 100755 index 13b5452..0000000 --- a/abra.sh +++ /dev/null @@ -1,2 +0,0 @@ -# Set any config versions here -# Docs: https://docs.coopcloud.tech/maintainers/handbook/#manage-configs diff --git a/compose.yml b/compose.yml deleted file mode 100644 index 5e77224..0000000 --- a/compose.yml +++ /dev/null @@ -1,39 +0,0 @@ ---- -services: - app: - image: nginx:1.27.5 - networks: - - proxy - deploy: - restart_policy: - condition: on-failure - labels: - - "traefik.enable=true" - - "traefik.http.services.${STACK_NAME}.loadbalancer.server.port=80" - - "traefik.http.routers.${STACK_NAME}.rule=Host(`${DOMAIN}`${EXTRA_DOMAINS})" - - "traefik.http.routers.${STACK_NAME}.entrypoints=web-secure" - - "traefik.http.routers.${STACK_NAME}.tls.certresolver=${LETS_ENCRYPT_ENV}" - ## Edit the following line if you are using one, but not both, "Redirect" sections below - #- "traefik.http.routers.${STACK_NAME}.middlewares=${STACK_NAME}-redirectscheme,${STACK_NAME}-redirecthostname" - ## Redirect from EXTRA_DOMAINS to DOMAIN - # - "traefik.http.middlewares.${STACK_NAME}-redirecthostname.redirectregex.regex=^http[s]?://([^/]*)/(.*)" - # - "traefik.http.middlewares.${STACK_NAME}-redirecthostname.redirectregex.replacement=https://${DOMAIN}/$${2}" - # - "traefik.http.middlewares.${STACK_NAME}-redirecthostname.redirectregex.permanent=true" - ## Redirect HTTP to HTTPS - # - "traefik.http.middlewares.${STACK_NAME}-redirectscheme.redirectscheme.scheme=https" - # - "traefik.http.middlewares.${STACK_NAME}-redirectscheme.redirectscheme.permanent=true" - ## When you're ready for release, run "abra recipe sync " to set this - - "coop-cloud.${STACK_NAME}.version=" - ## Enable backups: https://docs.coopcloud.tech/maintainers/handbook/#how-do-i-configure-backuprestore - # - "backupbot.backup=true" - # - "backupbot.backup.path=/some/path" - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost"] - interval: 30s - timeout: 10s - retries: 10 - start_period: 1m - -networks: - proxy: - external: true diff --git a/plugins.txt b/plugins.txt new file mode 100644 index 0000000..daa5278 --- /dev/null +++ b/plugins.txt @@ -0,0 +1,26 @@ +# plugins.txt +# ============ +# One plugin per line. pip install syntax is fully supported. +# Comment out or remove a line to exclude a plugin from the image. +# +# Supported formats: +# Local plugin from this repo: +# ./plugins/my-plugin/ +# +# Plugin from PyPI: +# pretix-pages +# +# Plugin from a Git repository at a specific tag: +# git+https://git.coopcloud.tech/my-org/pretix-my-plugin.git@1.0.0 +# +# After editing this file, build a new image: +# git tag 1.x.0 && git push origin 1.x.0 +# ============ + +# --- Local plugins (source code in this repo) --- +./plugins/attendance_confirm_plugin +./selective_export_plugin + +# --- Community plugins from PyPI (uncomment to enable) --- +#pretix-pages +#pretix-fontpack-free \ No newline at end of file diff --git a/plugins/attendance_confirm_plugin/MANIFEST.in b/plugins/attendance_confirm_plugin/MANIFEST.in new file mode 100644 index 0000000..863544d --- /dev/null +++ b/plugins/attendance_confirm_plugin/MANIFEST.in @@ -0,0 +1,4 @@ +recursive-include +pretix_attendance_confirm/templates * +recursive-include +pretix_attendance_confirm/static * \ No newline at end of file diff --git a/plugins/attendance_confirm_plugin/README.md b/plugins/attendance_confirm_plugin/README.md new file mode 100644 index 0000000..f11afc2 --- /dev/null +++ b/plugins/attendance_confirm_plugin/README.md @@ -0,0 +1,19 @@ +# pretix-attendance-confirm (draft) + +pretix plugin to send attendance confirmation emails to attendees. +Emails are sent per attendee (attendee email first, order email as fallback) and can +be customized per event. + +Features: +- Recipient list with checkboxes: checked-in attendees are preselected, you can + deselect or manually include non-checked-in attendees. +- Presets: save/load subject and body directly on the form (no page reload). + Presets are stored in the browser (localStorage) per URL/event. +- Placeholders: {attendee_name} and {event_name}. +- Sending is disabled until the event has ended. + +## Install + +``` +pip install -e /path/to/attendance_confirm_plugin +``` 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 new file mode 100644 index 0000000..842f146 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/PKG-INFO @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..82fddab --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/SOURCES.txt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/dependency_links.txt @@ -0,0 +1 @@ + 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 new file mode 100644 index 0000000..b8d0423 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[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 new file mode 100644 index 0000000..213e748 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/requires.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..49828ba --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/top_level.txt @@ -0,0 +1 @@ +pretix_attendance_confirm diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__init__.py b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__init__.py new file mode 100644 index 0000000..ed80f0a --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__init__.py @@ -0,0 +1 @@ +default_app_config = "pretix_attendance_confirm.apps.PretixAttendanceConfirmApp" 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 new file mode 100644 index 0000000..3f52e75 Binary files /dev/null and b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/__init__.cpython-313.pyc 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 new file mode 100644 index 0000000..fc0017a Binary files /dev/null and b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/apps.cpython-313.pyc 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 new file mode 100644 index 0000000..eac2392 Binary files /dev/null and b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/forms.cpython-313.pyc 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 new file mode 100644 index 0000000..de9a955 Binary files /dev/null and b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/signals.cpython-313.pyc 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 new file mode 100644 index 0000000..99c767b Binary files /dev/null and b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/urls.cpython-313.pyc 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 new file mode 100644 index 0000000..50aa190 Binary files /dev/null and b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/views.cpython-313.pyc differ diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/apps.py b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/apps.py new file mode 100644 index 0000000..bb85ef0 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/apps.py @@ -0,0 +1,22 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + +from pretix.base.plugins import PLUGIN_LEVEL_EVENT + + +class PretixAttendanceConfirmApp(AppConfig): + name = "pretix_attendance_confirm" + label = "attendance_confirm" + verbose_name = _("Attendance confirmations") + + class PretixPluginMeta: + name = _("Attendance confirmations") + author = _("Ez for mITs") + version = "0.1.0" + category = "FEATURE" + description = _("Send attendance confirmation emails to checked-in attendees.") + compatibility = "pretix>=2024.3" + level = PLUGIN_LEVEL_EVENT + + def ready(self): + from . import signals # NOQA diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/forms.py b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/forms.py new file mode 100644 index 0000000..1e1585e --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/forms.py @@ -0,0 +1,33 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + + +class ConfirmationMailForm(forms.Form): + def __init__(self, *args, recipient_choices=None, recipient_initial=None, **kwargs): + super().__init__(*args, **kwargs) + if recipient_choices is not None: + self.fields["recipients"] = forms.MultipleChoiceField( + label=_("Empfänger*innen"), + required=False, + choices=recipient_choices, + widget=forms.CheckboxSelectMultiple, + help_text=_( + "Vorausgewählt sind eingecheckte Teilnehmende. Du kannst einzelne abwählen " + "oder nicht eingecheckte hinzufügen." + ), + ) + if recipient_initial is not None: + self.initial["recipients"] = recipient_initial + + subject = forms.CharField( + label=_("Betreff der E-Mail"), + max_length=255, + ) + message = forms.CharField( + label=_("Text der E-Mail"), + widget=forms.Textarea(attrs={"rows": 10}), + help_text=_( + "Platzhalter: {attendee_name} (Name der teilnehmenden Person) und {event_name} " + "(Name der Veranstaltung)." + ), + ) diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/signals.py b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/signals.py new file mode 100644 index 0000000..22c4216 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/signals.py @@ -0,0 +1,41 @@ +from django.dispatch import receiver +from django.template.loader import get_template +from django.urls import resolve, reverse +from django.utils.translation import gettext_lazy as _ + +from pretix.control.signals import html_page_start, nav_event + + +@receiver(nav_event, dispatch_uid="attendance_confirm_nav_event") +def nav_event_attendance_confirm(sender, request=None, **kwargs): + url = resolve(request.path_info) + if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request): + return [] + return [ + { + 'label': _('Teilnahmebestätigungen'), + 'url': reverse('plugins:attendance_confirm:send', kwargs={ + 'organizer': request.event.organizer.slug, + 'event': request.event.slug, + }), + 'parent': reverse('control:event.orders', kwargs={ + 'organizer': request.event.organizer.slug, + 'event': request.event.slug, + }), + 'active': (url.namespace == 'plugins:attendance_confirm'), + 'icon': 'envelope', + } + ] + + +@receiver(html_page_start, dispatch_uid="attendance_confirm_html_page_start") +def attendance_confirm_html_page_start(sender, request=None, **kwargs): + if request is None: + request = sender + if not request: + return "" + url = resolve(request.path_info) + if url.namespace != 'plugins:attendance_confirm' or url.url_name != 'send': + return "" + template = get_template("pretixplugins/attendance_confirm/control_preset.html") + return template.render({}) diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/static/pretixplugins/attendance_confirm/presets.js b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/static/pretixplugins/attendance_confirm/presets.js new file mode 100644 index 0000000..2135645 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/static/pretixplugins/attendance_confirm/presets.js @@ -0,0 +1,166 @@ +(function () { + "use strict"; + + const configEl = document.getElementById("attendance-confirm-preset-i18n"); + if (!configEl) { + return; + } + + let i18n = {}; + try { + i18n = JSON.parse(configEl.textContent || "{}"); + } catch (e) { + i18n = {}; + } + + const form = document.querySelector("form"); + if (!form) { + return; + } + + const subjectInput = form.querySelector("input[name=subject]"); + const messageInput = form.querySelector("textarea[name=message]"); + if (!subjectInput || !messageInput) { + return; + } + + const content = document.querySelector("#page-wrapper .container-fluid") || form.parentElement; + if (!content) { + return; + } + + const storageKey = `pretix_attendance_confirm_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. + } + }; + + const buildPresetUI = 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.title || "Voreinstellungen"; + + const controls = document.createElement("div"); + controls.className = "col-md-9"; + + const loadGroup = document.createElement("div"); + loadGroup.className = "input-group"; + + const select = document.createElement("select"); + select.className = "form-control"; + + const loadBtnWrap = document.createElement("span"); + loadBtnWrap.className = "input-group-btn"; + + const loadBtn = document.createElement("button"); + loadBtn.type = "button"; + loadBtn.className = "btn btn-default"; + loadBtn.textContent = i18n.load || "Laden"; + + loadBtnWrap.appendChild(loadBtn); + loadGroup.appendChild(select); + loadGroup.appendChild(loadBtnWrap); + + const saveGroup = document.createElement("div"); + saveGroup.className = "input-group"; + saveGroup.style.marginTop = "6px"; + + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.className = "form-control"; + nameInput.placeholder = i18n.namePlaceholder || "Name der Voreinstellung"; + + const saveBtnWrap = document.createElement("span"); + saveBtnWrap.className = "input-group-btn"; + + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "btn btn-default"; + saveBtn.textContent = i18n.save || "Voreinstellung speichern"; + + saveBtnWrap.appendChild(saveBtn); + saveGroup.appendChild(nameInput); + saveGroup.appendChild(saveBtnWrap); + + controls.appendChild(loadGroup); + controls.appendChild(saveGroup); + + wrapper.appendChild(label); + wrapper.appendChild(controls); + + return { wrapper, select, loadBtn, nameInput, saveBtn }; + }; + + const { wrapper, select, loadBtn, nameInput, saveBtn } = buildPresetUI(); + + form.prepend(wrapper); + + const refreshOptions = function (presets, selectedName) { + select.innerHTML = ""; + + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = i18n.selectPlaceholder || "Voreinstellung wählen"; + select.appendChild(placeholder); + + Object.keys(presets) + .sort() + .forEach((name) => { + const option = document.createElement("option"); + option.value = name; + option.textContent = name; + select.appendChild(option); + }); + + if (selectedName) { + select.value = selectedName; + } + }; + + let presets = readPresets(); + refreshOptions(presets); + + loadBtn.addEventListener("click", function () { + const name = select.value; + if (!name || !presets[name]) { + return; + } + const preset = presets[name]; + subjectInput.value = preset.subject || ""; + messageInput.value = preset.message || ""; + }); + + saveBtn.addEventListener("click", function () { + const name = nameInput.value.trim(); + if (!name) { + window.alert(i18n.nameRequired || "Bitte einen Namen eingeben."); + return; + } + if (presets[name] && !window.confirm(i18n.confirmOverwrite || "Bestehende Voreinstellung überschreiben?")) { + return; + } + presets[name] = { + subject: subjectInput.value, + message: messageInput.value, + }; + writePresets(presets); + refreshOptions(presets, name); + nameInput.value = ""; + }); +})(); diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/templates/pretixplugins/attendance_confirm/control_preset.html b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/templates/pretixplugins/attendance_confirm/control_preset.html new file mode 100644 index 0000000..b8874a7 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/templates/pretixplugins/attendance_confirm/control_preset.html @@ -0,0 +1,13 @@ +{% load i18n static %} + + diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/templates/pretixplugins/attendance_confirm/send.html b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/templates/pretixplugins/attendance_confirm/send.html new file mode 100644 index 0000000..8a715b9 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/templates/pretixplugins/attendance_confirm/send.html @@ -0,0 +1,35 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} + +{% block title %}{% trans "Teilnahmebestätigungen" %}{% endblock %} + +{% block content %} +

{% trans "Teilnahmebestätigungen" %}

+ + {% if not event_ended %} +
+ {% trans "Diese Veranstaltung ist noch nicht vorbei. Der Versand ist erst nach dem Ende möglich." %} +
+ {% endif %} + +

+ {% blocktrans count counter=checked_in_count %} + 1 eingecheckte Person ist vorausgewählt. + {% plural %} + {{ counter }} eingecheckte Personen sind vorausgewählt. + {% endblocktrans %} + {% blocktrans count counter=total_count %} + Insgesamt gibt es 1 Person in der Teilnehmerliste. + {% plural %} + Insgesamt gibt es {{ counter }} Personen in der Teilnehmerliste. + {% endblocktrans %} +

+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/urls.py b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/urls.py new file mode 100644 index 0000000..e7b4874 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/urls.py @@ -0,0 +1,13 @@ +from django.urls import re_path + +from . import views + +app_name = "attendance_confirm" + +urlpatterns = [ + re_path( + r'^control/event/(?P[^/]+)/(?P[^/]+)/attendance-confirm/send/$', + views.SendConfirmationsView.as_view(), + name='send' + ), +] diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/views.py b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/views.py new file mode 100644 index 0000000..3b53af1 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/views.py @@ -0,0 +1,186 @@ +from email.utils import formataddr + +from django.conf import settings +from django.contrib import messages +from django.db.models import Max +from django.db.models.functions import Coalesce +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.timezone import now +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.control.permissions import EventPermissionRequiredMixin +from pretix.helpers.format import SafeFormatter, format_map + +from .forms import ConfirmationMailForm + +SETTINGS_SUBJECT = "attendance_confirm_subject" +SETTINGS_MESSAGE = "attendance_confirm_message" + +DEFAULT_SUBJECT = _("Ihre Teilnahmebestätigung für {event_name}") +DEFAULT_MESSAGE = _( + "Hallo {attendee_name},\n\n" + "danke, dass du bei {event_name} dabei warst.\n\n" + "Du kannst folgende Platzhalter verwenden:\n" + "- {attendee_name}: Name der teilnehmenden Person (falls vorhanden)\n" + "- {event_name}: Name der Veranstaltung\n\n" + "Voreinstellungen: Speichere Betreff und Text als Voreinstellung. " + "Mit „Laden“ kannst du eine gespeicherte Voreinstellung jederzeit ohne Seitenwechsel anwenden. " + "Beim Laden werden Betreff und Text im Formular ersetzt.\n\n" + "Versand: Beim Klick auf „Teilnahmebestätigungen senden“ geht eine E-Mail pro eingecheckter " + "Person raus – zuerst an die Teilnehmer-E-Mail, sonst an die Bestell-E-Mail. " + "Wenn eine Veranstaltung noch läuft, ist der Versand deaktiviert.\n\n" + "Viele Grüße" +) + + +class SendConfirmationsView(EventPermissionRequiredMixin, FormView): + template_name = "pretixplugins/attendance_confirm/send.html" + permission = "can_change_orders" + form_class = ConfirmationMailForm + + def get_event_end(self): + if self.request.event.has_subevents: + return self.request.event.subevents.aggregate( + last_end=Coalesce(Max("date_to"), Max("date_from")) + )["last_end"] + return self.request.event.date_to or self.request.event.date_from + + def get_checked_in_positions(self): + checked_in_ids = Checkin.objects.filter( + list__event=self.request.event, + successful=True, + type=Checkin.TYPE_ENTRY, + ).values_list("position_id", flat=True).distinct() + return OrderPosition.objects.filter( + pk__in=checked_in_ids, + order__event=self.request.event, + ).select_related( + "order", + "order__invoice_address", + ) + + def get_all_positions(self): + return OrderPosition.objects.filter( + order__event=self.request.event, + ).select_related( + "order", + "order__invoice_address", + "item", + "variation", + "subevent", + ).order_by("order__code", "positionid") + + def build_recipient_choices(self, positions, checked_in_ids): + choices = [] + for position in positions: + recipient = position.attendee_email or position.order.email + attendee_name = ( + position.attendee_name_cached + or getattr(position.order.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") + label = f"{attendee_name} - {email_label} - {position.order.code} - {status}" + choices.append((str(position.pk), label)) + return choices + + def get_initial(self): + return { + "subject": self.request.event.settings.get(SETTINGS_SUBJECT, DEFAULT_SUBJECT), + "message": self.request.event.settings.get(SETTINGS_MESSAGE, DEFAULT_MESSAGE), + } + + def get_form_kwargs(self): + 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] + 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() + ctx["event_end"] = self.get_event_end() + ctx["event_ended"] = bool(ctx["event_end"] and ctx["event_end"] <= now()) + return ctx + + def form_valid(self, form): + event_end = self.get_event_end() + if not event_end or event_end > now(): + messages.error(self.request, _("Diese Veranstaltung ist noch nicht vorbei.")) + return self.get(self.request, *self.args, **self.kwargs) + + self.request.event.settings.set(SETTINGS_SUBJECT, form.cleaned_data["subject"]) + self.request.event.settings.set(SETTINGS_MESSAGE, form.cleaned_data["message"]) + + selected_ids = set(form.cleaned_data.get("recipients") or []) + positions = self.get_all_positions().filter(pk__in=selected_ids) + if not positions.exists(): + 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 + if not recipient: + continue + attendee_name = ( + position.attendee_name_cached + or getattr(position.order.invoice_address, "name_cached", "") + or recipient + ) + context = { + "attendee_name": attendee_name, + "event_name": str(self.request.event.name), + } + subject = format_map(form.cleaned_data["subject"], context, mode=SafeFormatter.MODE_RICH_TO_PLAIN) + message = format_map(form.cleaned_data["message"], context, mode=SafeFormatter.MODE_RICH_TO_PLAIN) + sender_email = self.request.event.settings.get("mail_from") or settings.MAIL_FROM + sender_name = clean_sender_name( + self.request.event.settings.mail_from_name or str(self.request.event.name) + ) + mail_kwargs = { + "to": [recipient], + "subject": subject, + "body": message, + "html": None, + "sender": formataddr((sender_name, sender_email)), + "event": self.request.event.id, + "order": position.order_id, + "position": position.id, + } + try: + result = mail_send_task.apply(kwargs=mail_kwargs, throw=True) + result.get(propagate=True) + sent += 1 + except Exception: + failed += 1 + + if failed: + messages.error( + self.request, + _("Es wurden {sent} Bestätigungen gesendet, aber {failed} konnten nicht zugestellt werden.").format( + sent=sent, failed=failed + ), + ) + else: + messages.success( + self.request, + _("Es wurden {count} Teilnahmebestätigungen gesendet.").format(count=sent), + ) + return redirect(self.get_success_url()) + + def get_success_url(self): + return reverse("plugins:attendance_confirm:send", kwargs={ + "organizer": self.request.event.organizer.slug, + "event": self.request.event.slug, + }) diff --git a/plugins/attendance_confirm_plugin/pyproject.toml b/plugins/attendance_confirm_plugin/pyproject.toml new file mode 100644 index 0000000..4a85092 --- /dev/null +++ b/plugins/attendance_confirm_plugin/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/plugins/attendance_confirm_plugin/setup.cfg b/plugins/attendance_confirm_plugin/setup.cfg new file mode 100644 index 0000000..157dcbf --- /dev/null +++ b/plugins/attendance_confirm_plugin/setup.cfg @@ -0,0 +1,38 @@ +[metadata] +name = pretix-attendance-confirm +version = 0.1.0 +description = Attendance confirmation email plugin for pretix +long_description = file: README.md +long_description_content_type = text/markdown +author = Ez for mITs +license = AGPL-3.0-or-later +license_files = LICENSE +classifiers = + Development Status :: 3 - Alpha + Environment :: Plugins + Framework :: Django + License :: OSI Approved :: GNU Affero General Public License v3 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Topic :: Office/Business + +[options] +packages = find: +include_package_data = True +install_requires = + pretix>=2024.3 +python_requires = >=3.10 + +[options.packages.find] +exclude = + tests + tests.* + +[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/selective_export_plugin/MANIFEST.in b/plugins/selective_export_plugin/MANIFEST.in new file mode 100644 index 0000000..fb93042 --- /dev/null +++ b/plugins/selective_export_plugin/MANIFEST.in @@ -0,0 +1,4 @@ +recursive-include +pretix_selective_export/templates * +recursive-include +pretix_selective_export/static * \ No newline at end of file diff --git a/plugins/selective_export_plugin/README.md b/plugins/selective_export_plugin/README.md new file mode 100644 index 0000000..b8d3c05 --- /dev/null +++ b/plugins/selective_export_plugin/README.md @@ -0,0 +1,48 @@ +# 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). +- Presets: save/load field selections directly on the export form without reloading. + Presets are stored in the browser (localStorage) per URL/event. + +## 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/PKG-INFO b/plugins/selective_export_plugin/pretix_selective_export.egg-info/PKG-INFO new file mode 100644 index 0000000..ee1c9dc --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export.egg-info/PKG-INFO @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..6d08c8f --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export.egg-info/SOURCES.txt @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export.egg-info/dependency_links.txt @@ -0,0 +1 @@ + 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 new file mode 100644 index 0000000..971b755 --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[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 new file mode 100644 index 0000000..213e748 --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export.egg-info/requires.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..64d650c --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export.egg-info/top_level.txt @@ -0,0 +1 @@ +pretix_selective_export diff --git a/plugins/selective_export_plugin/pretix_selective_export/__init__.py b/plugins/selective_export_plugin/pretix_selective_export/__init__.py new file mode 100644 index 0000000..f351ef2 --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export/__init__.py @@ -0,0 +1 @@ +default_app_config = "pretix_selective_export.apps.PretixSelectiveExportApp" 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 new file mode 100644 index 0000000..674c1e1 Binary files /dev/null and b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/__init__.cpython-313.pyc 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 new file mode 100644 index 0000000..60bcc7d Binary files /dev/null and b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/apps.cpython-313.pyc 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 new file mode 100644 index 0000000..3707a2f Binary files /dev/null and b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/exporter.cpython-313.pyc 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 new file mode 100644 index 0000000..e6d8fc9 Binary files /dev/null and b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/signals.cpython-313.pyc differ diff --git a/plugins/selective_export_plugin/pretix_selective_export/apps.py b/plugins/selective_export_plugin/pretix_selective_export/apps.py new file mode 100644 index 0000000..cfb73d2 --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export/apps.py @@ -0,0 +1,21 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + +from pretix.base.plugins import PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID + + +class PretixSelectiveExportApp(AppConfig): + name = "pretix_selective_export" + verbose_name = _("Selective export") + + class PretixPluginMeta: + name = _("Selective export") + author = _("Ez for mITs") + version = "0.1.0" + description = _("Export order data with a selectable set of fields.") + category = "FORMAT" + compatibility = "pretix>=2024.3" + level = PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID + + def ready(self): + from . import signals # NOQA diff --git a/plugins/selective_export_plugin/pretix_selective_export/exporter.py b/plugins/selective_export_plugin/pretix_selective_export/exporter.py new file mode 100644 index 0000000..47fd4d4 --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export/exporter.py @@ -0,0 +1,293 @@ +import json +import datetime +from collections import OrderedDict +from dataclasses import dataclass + +from django import forms +from django.db import models +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.models import Event, InvoiceAddress, Order, OrderPosition, Question + + +@dataclass(frozen=True) +class FieldDefinition: + label: str + getter: object + + +@dataclass(frozen=True) +class RowContext: + position: object + order: object + event: object + invoice: object + answers: dict + + +class SelectiveFieldExporter(OrganizerLevelExportMixin, ListExporter): + identifier = "selective_field_export" + verbose_name = _("Order data (selectable fields)") + category = pgettext_lazy("export_category", "Order data") + description = _("Export order and attendee data with a selectable set of fields.") + + @property + def additional_form_fields(self): + fields = OrderedDict( + [ + ( + "fields", + forms.MultipleChoiceField( + label=_("Fields to export"), + help_text=_("Select the fields that should be included in the export."), + widget=forms.CheckboxSelectMultiple, + choices=[(k, f.label) for k, f in self.field_definitions.items()], + initial=self._default_selected_fields(), + ), + ), + ] + ) + 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): + if self.event: + return f"orders-{self.event.slug}-selective" + if self.organizer: + return f"orders-{self.organizer.slug}-selective" + 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] + + yield [field.label for field in selected_defs] + + events = self._get_selected_events(form_data) + qs = ( + OrderPosition.objects.filter(order__event__in=events) + .select_related( + "order", + "order__event", + "order__invoice_address", + "item", + "variation", + "subevent", + ) + .prefetch_related( + "answers", + "answers__question", + "answers__options", + ) + .order_by("order__datetime", "order__code", "positionid") + ) + + 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, + order=order, + event=event, + invoice=invoice, + answers=answers, + ) + yield [self._format_value(field.getter(context)) for field in selected_defs] + + def _format_value(self, value): + if value is None: + return "" + if isinstance(value, (datetime.datetime, datetime.time)) and is_aware(value): + return make_naive(value, get_current_timezone()) + if isinstance(value, (dict, list)): + return json.dumps(value, sort_keys=True) + if isinstance(value, models.Model): + return str(value) + return value + + def _get_model_field_value(self, obj, field): + if obj is None: + return "" + if field.is_relation and (field.many_to_one or field.one_to_one): + return getattr(obj, field.attname) + return getattr(obj, field.name) + + def _get_related_label(self, obj, field): + if obj is None: + return "" + related = getattr(obj, field.name, None) + if related is None: + return "" + return str(related) + + @cached_property + def _has_invoices(self): + return InvoiceAddress.objects.filter(order__event__in=self.events).exists() + + @cached_property + def _questions_with_answers(self): + return list( + Question.objects.filter( + event__in=self.events, + answers__orderposition__order__event__in=self.events, + ) + .select_related("event") + .distinct() + .order_by("event__slug", "pk") + ) + + @cached_property + def field_definitions(self): + fields = OrderedDict() + + self._add_event_fields(fields) + self._add_model_fields( + fields, + prefix="order", + label_prefix=_("Order"), + model=Order, + 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_derived_fields(fields) + if self._has_invoices: + self._add_model_fields( + fields, + prefix="invoice", + label_prefix=_("Invoice address"), + model=InvoiceAddress, + obj_getter=lambda ctx: ctx.invoice, + ) + self._add_question_fields(fields) + + return fields + + def _add_event_fields(self, fields): + self._add_field(fields, "event.id", _("Event: ID"), lambda ctx: getattr(ctx.event, "id", "")) + self._add_field(fields, "event.slug", _("Event: slug"), lambda ctx: getattr(ctx.event, "slug", "")) + self._add_field(fields, "event.name", _("Event: name"), lambda ctx: str(getattr(ctx.event, "name", ""))) + self._add_field(fields, "event.timezone", _("Event: timezone"), lambda ctx: getattr(ctx.event, "timezone", "")) + self._add_field(fields, "event.currency", _("Event: currency"), lambda ctx: getattr(ctx.event, "currency", "")) + self._add_field(fields, "event.date_from", _("Event: start date"), lambda ctx: getattr(ctx.event, "date_from", "")) + self._add_field(fields, "event.date_to", _("Event: end date"), lambda ctx: getattr(ctx.event, "date_to", "")) + + def _add_order_derived_fields(self, fields): + self._add_field( + fields, + "order.status_display", + _("Order: status (label)"), + lambda ctx: ctx.order.get_status_display() if ctx.order else "", + ) + + def _add_position_derived_fields(self, fields): + self._add_field( + fields, + "position.item_label", + _("Position: item (label)"), + lambda ctx: str(getattr(ctx.position, "item", "") or ""), + ) + self._add_field( + fields, + "position.variation_label", + _("Position: variation (label)"), + lambda ctx: str(getattr(ctx.position, "variation", "") or ""), + ) + self._add_field( + fields, + "position.subevent_label", + _("Position: subevent (label)"), + lambda ctx: str(getattr(ctx.position, "subevent", "") or ""), + ) + + def _add_question_fields(self, fields): + for q in self._questions_with_answers: + label = f"{q.event.slug}: {q.question}" if q.event_id else str(q.question) + key = f"question:{q.pk}" + self._add_field( + fields, + key, + label, + lambda ctx, qid=q.pk: self._get_question_answer(ctx, qid), + ) + + def _get_question_answer(self, ctx, qid): + answer = ctx.answers.get(qid) + if not answer: + 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() + + def _add_model_fields(self, fields, prefix, label_prefix, model, obj_getter): + for field in model._meta.fields: + key = f"{prefix}.{field.name}" + label = f"{label_prefix}: {field.verbose_name}" + self._add_field( + fields, + key, + label, + lambda ctx, f=field, og=obj_getter: self._get_model_field_value(og(ctx), f), + ) + if field.is_relation and (field.many_to_one or field.one_to_one): + label_key = f"{prefix}.{field.name}_label" + label_label = f"{label_prefix}: {field.verbose_name} (label)" + self._add_field( + fields, + label_key, + label_label, + lambda ctx, f=field, og=obj_getter: self._get_related_label(og(ctx), f), + ) + + def _add_field(self, fields, key, label, getter): + fields[key] = FieldDefinition(label=label, getter=getter) + + def _default_selected_fields(self): + preferred = [ + "event.name", + "order.email", + "order.phone", + "order.status_display", + "position.attendee_name_cached", + ] + return [key for key in preferred if key in self.field_definitions] + + 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 + selected = form_data.get("events") if form_data else None + if not selected: + return events + ids = [ + e.pk if hasattr(e, "pk") else e + for e in selected + ] + return events.filter(pk__in=ids) diff --git a/plugins/selective_export_plugin/pretix_selective_export/signals.py b/plugins/selective_export_plugin/pretix_selective_export/signals.py new file mode 100644 index 0000000..425e947 --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export/signals.py @@ -0,0 +1,80 @@ +from django.dispatch import receiver +from django.template.loader import get_template +from django.urls import resolve, reverse +from django.utils.translation import gettext_lazy as _ + +from pretix.base.signals import ( + register_data_exporters, + register_multievent_data_exporters, +) +from pretix.control.signals import html_page_start, nav_event, nav_organizer + +from .exporter import SelectiveFieldExporter + + +@receiver(register_data_exporters, dispatch_uid="selective_field_exporter") +def register_data_exporter(sender, **kwargs): + return SelectiveFieldExporter + + +@receiver(register_multievent_data_exporters, dispatch_uid="selective_field_exporter_multievent") +def register_multievent_data_exporter(sender, **kwargs): + return SelectiveFieldExporter + + +@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): + return [] + export_url = reverse('control:event.orders.export', kwargs={ + 'organizer': request.event.organizer.slug, + 'event': request.event.slug, + }) + f"?identifier={SelectiveFieldExporter.identifier}" + return [ + { + 'label': _('Selective export'), + 'url': export_url, + 'parent': reverse('control:event.orders', kwargs={ + 'organizer': request.event.organizer.slug, + 'event': request.event.slug, + }), + 'active': (url.url_name == 'event.orders.export'), + 'icon': 'download', + } + ] + + +@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): + return [] + export_url = reverse('control:organizer.export', kwargs={ + 'organizer': request.organizer.slug, + }) + f"?identifier={SelectiveFieldExporter.identifier}" + return [ + { + 'label': _('Selective export'), + 'url': export_url, + 'parent': export_url, + 'active': (url.url_name == 'organizer.export'), + 'icon': 'download', + } + ] + + +@receiver(html_page_start, dispatch_uid="selective_export_html_page_start") +def selective_export_html_page_start(sender, request=None, **kwargs): + if request is None: + request = sender + if not request: + return "" + url = resolve(request.path_info) + if url.url_name not in ("event.orders.export", "organizer.export"): + return "" + identifier = request.GET.get("identifier") or request.GET.get("exporter") + if identifier != SelectiveFieldExporter.identifier: + return "" + template = get_template("pretixplugins/selective_export/control_preset.html") + return template.render({}) 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 new file mode 100644 index 0000000..4d4b981 --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export/static/pretixplugins/selective_export/presets.js @@ -0,0 +1,189 @@ +(function () { + "use strict"; + + const configEl = document.getElementById("selective-export-preset-i18n"); + if (!configEl) { + return; + } + + let i18n = {}; + try { + i18n = JSON.parse(configEl.textContent || "{}"); + } catch (e) { + i18n = {}; + } + + const form = document.querySelector('form[data-asynctask]'); + if (!form) { + return; + } + + const exporterInput = form.querySelector('input[name="exporter"]'); + if (!exporterInput || exporterInput.value !== "selective_field_export") { + return; + } + + const fieldset = form.querySelector("fieldset"); + if (!fieldset) { + 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); + }; + + const setCheckedValues = function (name, values) { + const inputs = form.querySelectorAll(`input[name="${name}"]`); + if (!inputs.length) { + return; + } + const allowed = new Set(values || []); + inputs.forEach((input) => { + input.checked = allowed.has(input.value); + }); + }; + + const buildPresetUI = 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.title || "Presets"; + + const controls = document.createElement("div"); + controls.className = "col-md-9"; + + const loadGroup = document.createElement("div"); + loadGroup.className = "input-group"; + + const select = document.createElement("select"); + select.className = "form-control"; + + const loadBtnWrap = document.createElement("span"); + loadBtnWrap.className = "input-group-btn"; + + const loadBtn = document.createElement("button"); + loadBtn.type = "button"; + loadBtn.className = "btn btn-default"; + loadBtn.textContent = i18n.load || "Load"; + + loadBtnWrap.appendChild(loadBtn); + loadGroup.appendChild(select); + loadGroup.appendChild(loadBtnWrap); + + const saveGroup = document.createElement("div"); + saveGroup.className = "input-group"; + saveGroup.style.marginTop = "6px"; + + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.className = "form-control"; + nameInput.placeholder = i18n.namePlaceholder || "Preset name"; + + const saveBtnWrap = document.createElement("span"); + saveBtnWrap.className = "input-group-btn"; + + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "btn btn-default"; + saveBtn.textContent = i18n.save || "Save preset"; + + saveBtnWrap.appendChild(saveBtn); + saveGroup.appendChild(nameInput); + saveGroup.appendChild(saveBtnWrap); + + controls.appendChild(loadGroup); + controls.appendChild(saveGroup); + + wrapper.appendChild(label); + wrapper.appendChild(controls); + + return { wrapper, select, loadBtn, nameInput, saveBtn }; + }; + + const { wrapper, select, loadBtn, nameInput, saveBtn } = buildPresetUI(); + + const legend = fieldset.querySelector("legend"); + if (legend) { + legend.insertAdjacentElement("afterend", wrapper); + } else { + fieldset.prepend(wrapper); + } + + const refreshOptions = function (presets, selectedName) { + select.innerHTML = ""; + + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = i18n.selectPlaceholder || "Select a preset"; + select.appendChild(placeholder); + + Object.keys(presets) + .sort() + .forEach((name) => { + const option = document.createElement("option"); + option.value = name; + option.textContent = name; + select.appendChild(option); + }); + + if (selectedName) { + select.value = selectedName; + } + }; + + let presets = readPresets(); + refreshOptions(presets); + + const formPrefix = exporterInput.value; + const fieldsName = `${formPrefix}-fields`; + const eventsName = `${formPrefix}-events`; + + loadBtn.addEventListener("click", function () { + const name = select.value; + if (!name || !presets[name]) { + return; + } + const preset = presets[name]; + setCheckedValues(fieldsName, preset.fields || []); + setCheckedValues(eventsName, preset.events || []); + }); + + saveBtn.addEventListener("click", function () { + const name = nameInput.value.trim(); + if (!name) { + window.alert(i18n.nameRequired || "Please enter a preset name."); + return; + } + if (presets[name] && !window.confirm(i18n.confirmOverwrite || "Overwrite existing preset?")) { + return; + } + presets[name] = { + fields: getCheckedValues(fieldsName), + events: getCheckedValues(eventsName), + }; + writePresets(presets); + refreshOptions(presets, name); + nameInput.value = ""; + }); +})(); 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 new file mode 100644 index 0000000..1ac7df6 --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export/templates/pretixplugins/selective_export/control_preset.html @@ -0,0 +1,13 @@ +{% load i18n static %} + + diff --git a/plugins/selective_export_plugin/pyproject.toml b/plugins/selective_export_plugin/pyproject.toml new file mode 100644 index 0000000..4a85092 --- /dev/null +++ b/plugins/selective_export_plugin/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/plugins/selective_export_plugin/setup.cfg b/plugins/selective_export_plugin/setup.cfg new file mode 100644 index 0000000..5d44e9e --- /dev/null +++ b/plugins/selective_export_plugin/setup.cfg @@ -0,0 +1,38 @@ +[metadata] +name = pretix-selective-export +version = 0.1.0 +description = Selective field export plugin for pretix +long_description = file: README.md +long_description_content_type = text/markdown +author = Ez for mITs +license = AGPL-3.0-or-later +license_files = LICENSE +classifiers = + Development Status :: 3 - Alpha + Environment :: Plugins + Framework :: Django + License :: OSI Approved :: GNU Affero General Public License v3 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Topic :: Office/Business + +[options] +packages = find: +include_package_data = True +install_requires = + pretix>=2024.3 +python_requires = >=3.10 + +[options.packages.find] +exclude = + tests + tests.* + +[options.entry_points] +pretix.plugin = + selective_export = pretix_selective_export + +[options.package_data] +pretix_selective_export = + templates/**/* + static/**/* diff --git a/release/.git-keep-me b/release/.git-keep-me deleted file mode 100644 index e69de29..0000000