feat: add custom plugins and dockerfile

This commit is contained in:
2026-03-18 13:10:42 +01:00
parent cac35c6b73
commit ca021d0628
54 changed files with 1574 additions and 106 deletions

View File

@ -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

View File

@ -1,8 +0,0 @@
TYPE={{ .Name }}
DOMAIN={{ .Name }}.example.com
## Domain aliases
#EXTRA_DOMAINS=', `www.{{ .Name }}.example.com`'
LETS_ENCRYPT_ENV=production

1
.gitignore vendored
View File

@ -1 +0,0 @@
.envrc

19
Dockerfile Normal file
View File

@ -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

153
README.md
View File

@ -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).
<!-- metadata -->
---
* **Category**: {{ .Category }}
* **Status**: {{ .Status }}
* **Image**: {{ .Image }}
* **Healthcheck**: {{ .Healthcheck }}
* **Backups**: {{ .Backups }}
* **Email**: {{ .Email }}
* **Tests**: {{ .Tests }}
* **SSO**: {{ .SSO }}
## How it works
<!-- endmetadata -->
```
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 <app-name>`
* `abra app deploy <app-name>`
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/<your-org>/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/<your-org>/pretix-plugins:1.0.0`
- `git.coopcloud.tech/<your-org>/pretix-plugins:latest`
### Manual
```bash
docker build -t git.coopcloud.tech/<your-org>/pretix-plugins:1.0.0 .
docker push git.coopcloud.tech/<your-org>/pretix-plugins:1.0.0
```
---
## 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!

View File

@ -1,2 +0,0 @@
# Set any config versions here
# Docs: https://docs.coopcloud.tech/maintainers/handbook/#manage-configs

View File

@ -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 <name>" 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

26
plugins.txt Normal file
View File

@ -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

View File

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

View File

@ -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
```

View File

@ -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
```

View File

@ -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

View File

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

View File

@ -0,0 +1 @@
pretix>=2024.3

View File

@ -0,0 +1 @@
pretix_attendance_confirm

View File

@ -0,0 +1 @@
default_app_config = "pretix_attendance_confirm.apps.PretixAttendanceConfirmApp"

View File

@ -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

View File

@ -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)."
),
)

View File

@ -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({})

View File

@ -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 = "";
});
})();

View File

@ -0,0 +1,13 @@
{% load i18n static %}
<script id="attendance-confirm-preset-i18n" type="application/json">
{
"title": "Voreinstellungen",
"selectPlaceholder": "Voreinstellung wählen",
"load": "Laden",
"namePlaceholder": "Name der Voreinstellung",
"save": "Voreinstellung speichern",
"nameRequired": "Bitte einen Namen eingeben.",
"confirmOverwrite": "Bestehende Voreinstellung überschreiben?"
}
</script>
<script defer src="{% static "pretixplugins/attendance_confirm/presets.js" %}"></script>

View File

@ -0,0 +1,35 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% block title %}{% trans "Teilnahmebestätigungen" %}{% endblock %}
{% block content %}
<h1>{% trans "Teilnahmebestätigungen" %}</h1>
{% if not event_ended %}
<div class="alert alert-warning">
{% trans "Diese Veranstaltung ist noch nicht vorbei. Der Versand ist erst nach dem Ende möglich." %}
</div>
{% endif %}
<p>
{% 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 %}
</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button class="btn btn-primary" type="submit" {% if not event_ended %}disabled{% endif %}>
{% trans "Teilnahmebestätigungen senden" %}
</button>
</form>
{% endblock %}

View File

@ -0,0 +1,13 @@
from django.urls import re_path
from . import views
app_name = "attendance_confirm"
urlpatterns = [
re_path(
r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/attendance-confirm/send/$',
views.SendConfirmationsView.as_view(),
name='send'
),
]

View File

@ -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,
})

View File

@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"

View File

@ -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/**/*

View File

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

View File

@ -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).

View File

@ -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).

View File

@ -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

View File

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

View File

@ -0,0 +1 @@
pretix>=2024.3

View File

@ -0,0 +1 @@
pretix_selective_export

View File

@ -0,0 +1 @@
default_app_config = "pretix_selective_export.apps.PretixSelectiveExportApp"

View File

@ -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

View File

@ -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)

View File

@ -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({})

View File

@ -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 = "";
});
})();

View File

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

View File

@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"

View File

@ -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/**/*

View File