generated from coop-cloud/example
feat: add custom plugins and dockerfile
This commit is contained in:
39
.drone.yml
39
.drone.yml
@ -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
|
||||
@ -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
1
.gitignore
vendored
@ -1 +0,0 @@
|
||||
.envrc
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal 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
153
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).
|
||||
|
||||
<!-- 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!
|
||||
2
abra.sh
2
abra.sh
@ -1,2 +0,0 @@
|
||||
# Set any config versions here
|
||||
# Docs: https://docs.coopcloud.tech/maintainers/handbook/#manage-configs
|
||||
39
compose.yml
39
compose.yml
@ -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
26
plugins.txt
Normal 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
|
||||
4
plugins/attendance_confirm_plugin/MANIFEST.in
Normal file
4
plugins/attendance_confirm_plugin/MANIFEST.in
Normal file
@ -0,0 +1,4 @@
|
||||
recursive-include
|
||||
pretix_attendance_confirm/templates *
|
||||
recursive-include
|
||||
pretix_attendance_confirm/static *
|
||||
19
plugins/attendance_confirm_plugin/README.md
Normal file
19
plugins/attendance_confirm_plugin/README.md
Normal 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
|
||||
```
|
||||
@ -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
|
||||
```
|
||||
@ -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
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
[pretix.plugin]
|
||||
attendance_confirm = pretix_attendance_confirm
|
||||
@ -0,0 +1 @@
|
||||
pretix>=2024.3
|
||||
@ -0,0 +1 @@
|
||||
pretix_attendance_confirm
|
||||
@ -0,0 +1 @@
|
||||
default_app_config = "pretix_attendance_confirm.apps.PretixAttendanceConfirmApp"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
@ -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)."
|
||||
),
|
||||
)
|
||||
@ -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({})
|
||||
@ -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 = "";
|
||||
});
|
||||
})();
|
||||
@ -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>
|
||||
@ -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 %}
|
||||
@ -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'
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
})
|
||||
3
plugins/attendance_confirm_plugin/pyproject.toml
Normal file
3
plugins/attendance_confirm_plugin/pyproject.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
38
plugins/attendance_confirm_plugin/setup.cfg
Normal file
38
plugins/attendance_confirm_plugin/setup.cfg
Normal 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/**/*
|
||||
4
plugins/selective_export_plugin/MANIFEST.in
Normal file
4
plugins/selective_export_plugin/MANIFEST.in
Normal file
@ -0,0 +1,4 @@
|
||||
recursive-include
|
||||
pretix_selective_export/templates *
|
||||
recursive-include
|
||||
pretix_selective_export/static *
|
||||
48
plugins/selective_export_plugin/README.md
Normal file
48
plugins/selective_export_plugin/README.md
Normal 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).
|
||||
@ -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).
|
||||
@ -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
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
[pretix.plugin]
|
||||
selective_export = pretix_selective_export
|
||||
@ -0,0 +1 @@
|
||||
pretix>=2024.3
|
||||
@ -0,0 +1 @@
|
||||
pretix_selective_export
|
||||
@ -0,0 +1 @@
|
||||
default_app_config = "pretix_selective_export.apps.PretixSelectiveExportApp"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
@ -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)
|
||||
@ -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({})
|
||||
@ -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 = "";
|
||||
});
|
||||
})();
|
||||
@ -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>
|
||||
3
plugins/selective_export_plugin/pyproject.toml
Normal file
3
plugins/selective_export_plugin/pyproject.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
38
plugins/selective_export_plugin/setup.cfg
Normal file
38
plugins/selective_export_plugin/setup.cfg
Normal 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/**/*
|
||||
Reference in New Issue
Block a user