Compare commits

...
Author SHA1 Message Date
Richard Schreiber e2afb2300c Update tests.yml 2026-05-12 13:07:26 +02:00
Richard Schreiber 1a2ea42084 try installing playwright as dep 2026-05-12 13:00:22 +02:00
Richard Schreiber 00514f73e8 Update tests.yml 2026-05-12 12:49:08 +02:00
Richard Schreiber fc8d1a2430 Update tests.yml 2026-05-12 12:36:09 +02:00
Richard Schreiber 508e67d547 Fix playwright install on CI 2026-05-12 12:12:59 +02:00
luelistaandGitHub 80ed6e76cd Fix failing orderlist export if orders with invalid payment provider identifiers exist (Z#23233440) (#6159)
* Fix orderlist export if orders with invalid payment provider identifiers exist (Z#23233440)
* Performance: Move _get_all_payment_methods out of loop
2026-05-12 11:57:18 +02:00
Lukas BockstallerandGitHub bb211be436 use datetime.fromisoformat instead of dateutil.parser (Z#23234093) (#6164)
* use datetime.fromisoformat instead of dateutil.parser

* convert remaining parser usages as well
2026-05-12 10:41:24 +02:00
Richard SchreiberandGitHub 3b70ef8c84 Allow event being optional in LoggingMixin (#6166) 2026-05-12 09:45:42 +02:00
Richard Schreiber 9d57380c9a Widget: fix missing whitespace in PriceBox 2026-05-12 09:34:17 +02:00
Richard SchreiberandGitHub 8b468c31a5 Fix translation for order import (#6165) 2026-05-12 09:03:34 +02:00
Richard Schreiber 9aec608601 Fix checkinrules js errors 2026-05-12 08:34:22 +02:00
Raphael Michel e542bb606d Vue3: Minor fixes in checkinlist editor 2026-05-11 18:51:21 +02:00
Raphael MichelandGitHub fe1b4ec9d0 Order bulk action: Remove nonsensical <form action> attribute (#6154) 2026-05-11 17:39:46 +02:00
f04df7a6ee Migrate vue2 control components and widget to vue3 and vite (#5989)
* setup vite and integrate fully with django

- vite starts with `python manage.py runserver`
- add templatetags to simply load vite hmr and entry points
- add eslint (recheck rules)
- enable non-strict ts

* better syntax for cors header setting

* migrate checkin rules editor to vue3

- move constants to a module
- move reading from and writing to non-vue html to django interop module
- switch to composition api and script setup sfc with pug
- use optional chaining operators a lot to simplify code

* migrate webcheckin plugin to vite+vue3

- migrate vue sfcs to script setup and pug
- move fetch calls into a api.ts module
- move common formatting and i18n strings into module

* fix migration error

* first draft migrating widget to vue3/vite

* first couple widget e2e tests

courtesy of claude
most of the tests don't work yet

* test file is not actually used

* drop widget_ prefix from e2e test fixtures

* add test for complete widget journey for simple event

* switch timezone in e2e tests to Europe/Berlin

* make dates in e2e tests relative

* migrate widget bugfix #5886

* start testing event series widget

* working vite widget setup for prod (untested), local dev (with or without dev server) and pytests, with flags for running the old version or the vite version

* simplify e2e test iframe check

* less flaky e2e tests

* top level await in iife build mode is not supported, so let's do import.meta.glob instead (we just need the build step not to see await, the code doesn't actually ever get loaded because it's DEV only)

* fix inconsistencies from automatic migration

* Allow gradual rollout of new vite-based widget by adding urls to an allowlist that gets checked against the "Origin" http header of request fetching the widget js

* add e2e tests for widget button, testing empty cart, adding specific items, and subevents

* remove janky claude testts again

* resolve migration TODOs: properly refocus parent on navigations

* use `npm run dev:control` for the vite dev server for admin components

* upgrade npm dependencies

* fix js linter errors

* fix python linter errors

* build all control vue components

* add new js config files to check-manifest ignore

* working prod build

acutal serving of built assets not tested yet

* fix templatetag paths to match what's in the vite mantifest

* add missing quotes around 'unsafe-eval' cors value

* remove now unused old vue2 tooling

* try fixing e2e test ci

* fix flake8 error

* check if vite build artefacts are in the wheel

* add license headers

* remove dom manipilation code necessary for `div.pretix-widget-compat` to work. No longer needed for vue3

* remove superfluous `createElement` calls

They might have been there because of IE, which is no longer relevant

* make widget dev mode parametizable through query params and document the usage and those params

* fix rst syntax

* remove migration todos file

Co-authored-by: luelista <mira@teamwiki.de>

* rearrange dockerfile commands for smaller image, thanks @luelista

* Update .gitignore, adding .vite

Co-authored-by: luelista <mira@teamwiki.de>

* add eslint CI

* make vue dev work in plugins

* fix docker build

* rebuild vite setup to support static prod plugins and dynamic hmr plugin development

* use toml for vite plugin config instead of standalone json file

* Add widget changes from #6047, #6149

* Allow buttons to reuse cart (Z#23226853)

* Always keep cart of buttons with items set

* widget: handle cart if not same-site (#6149)

---------

Co-authored-by: luelista <mira@teamwiki.de>
Co-authored-by: Kara Engelhardt <engelhardt@pretix.eu>
2026-05-11 15:05:06 +02:00
pajowuandGitHub 1640ddd497 Widget: handle cart if not same-site (Z#23233393)
Sets SameSite for cookie if page is secure, so cookie can be read even if not same-site. Also stores cart-id in vue state, so correct cart is used even if cookies to not work
2026-05-11 15:02:57 +02:00
pajowuandGitHub 27148324a6 sendmail: Add missing cleanup migration (#6158) 2026-05-11 14:53:47 +02:00
corentin-specandRaphael Michel 71edfa8e1a Translations: Update French
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2026-05-08 09:48:04 +02:00
Daniel MusketaandRaphael Michel 8303ba7808 Translations: Update German
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2026-05-08 09:48:04 +02:00
pajowuandGitHub 5bbbf0334d sendmail: Do not copy rules with subevent when closing an event (Z#23233683) (#6156) 2026-05-06 15:56:06 +02:00
sweenuandGitHub 14708eef80 Invoice: fix issuer details rendering when address missing (#6139) 2026-05-05 17:58:02 +02:00
Ruud HendrickxandRaphael Michel 952f121008 Translations: Update Dutch (Belgium)
Currently translated at 83.0% (5228 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl_BE/

powered by weblate
2026-05-05 17:36:25 +02:00
Ruud HendrickxandRaphael Michel 074d26cff3 Translations: Update Dutch
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl/

powered by weblate
2026-05-05 17:36:25 +02:00
Mie FrydensbjergandRaphael Michel 6a9815ea5f Translations: Update Danish
Currently translated at 58.4% (3677 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/da/

powered by weblate
2026-05-05 17:36:25 +02:00
Daniel MusketaandRaphael Michel 01bd81a3cd Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2026-05-05 17:36:25 +02:00
Martin GrossandRaphael Michel 6ae8cfe6f0 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (256 of 256 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/de_Informal/

powered by weblate
2026-05-05 17:36:25 +02:00
Martin GrossandRaphael Michel b60c8165c2 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2026-05-05 17:36:25 +02:00
Daniel MusketaandRaphael Michel e460bf8bae Translations: Update Norwegian Bokmål
Currently translated at 83.3% (5247 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nb_NO/

powered by weblate
2026-05-05 17:36:25 +02:00
Daniel MusketaandRaphael Michel b4f3d5c435 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2026-05-05 17:36:25 +02:00
Daniel MusketaandRaphael Michel 4bc8caae73 Translations: Update German
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2026-05-05 17:36:25 +02:00
Paul BerschickandRaphael Michel 9183034c15 Translations: Update Spanish
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2026-05-05 17:36:25 +02:00
Paul BerschickandRaphael Michel 33ccd4342f Translations: Update Catalan
Currently translated at 29.7% (1875 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/ca/

powered by weblate
2026-05-05 17:36:25 +02:00
Paul BerschickandRaphael Michel 301c47b761 Translations: Update Spanish
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2026-05-05 17:36:25 +02:00
CVZ-esandRaphael Michel b0d1c93fd9 Translations: Update Spanish
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2026-05-05 17:36:25 +02:00
CVZ-esandRaphael Michel 70d59a960c Translations: Update French
Currently translated at 100.0% (6295 of 6295 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2026-05-05 17:36:25 +02:00
Daniel MusketaandGitHub e87b030427 Stripe: Add missing word in help text (#6146) 2026-05-05 17:15:34 +02:00
Daniel MusketaandGitHub 994e4b410a Fix gettext singular forms in cart error_messages (#6147) 2026-05-05 17:15:14 +02:00
Daniel MusketaandGitHub bd6abbc280 Docs: Update link to Django style guide (#6140) 2026-05-05 17:14:22 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ca7c982abd Bump @babel/preset-env from 7.29.0 to 7.29.3 in /src/pretix/static/npm_dir (#6136)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.29.0 to 7.29.3.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.3/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-version: 7.29.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-05 17:13:40 +02:00
Raphael MichelandGitHub 6010d7f9e5 Order details: Link to subevent details (Z#23227664) (#6148) 2026-05-05 14:56:02 +02:00
Richard SchreiberandGitHub ac08359a0e Widget: Raise BadRequest when GET-offset is not an int (#6143) 2026-05-04 16:19:02 +02:00
0aee73a9bd Quotas: Add bulk-edit, bulk-delete and filter form (#6080)
* Quotas: Add bulk-edit, bulk-delete and filter form

* Fix GroupConcat

* Apply suggestions from code review

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>

* Review notes

* Fix handling of required fields

---------

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2026-05-04 12:44:22 +02:00
luelistaandGitHub 27183a26ee Respect per-event plugin availability in OrganizerPluginEvents view (#5983)
* Allow plugins to declare their availability per event

* Fix message type

* small optimization of PluginsField serializer
2026-05-04 11:34:05 +02:00
136 changed files with 14491 additions and 9500 deletions
+1
View File
@@ -1,5 +1,6 @@
doc/ doc/
env/ env/
node_modules/
res/ res/
local/ local/
.git/ .git/
+5
View File
@@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = tab
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
+4 -1
View File
@@ -46,4 +46,7 @@ jobs:
- name: Run build - name: Run build
run: python -m build run: python -m build
- name: Check files - name: Check files
run: unzip -l dist/pretix*whl | grep node_modules || exit 1 run: |
for pat in 'static.dist/vite/widget/widget.js' 'static.dist/vite/control/assets/checkinrules/main-' 'static.dist/vite/control/assets/webcheckin/main-'; do
unzip -l dist/pretix*whl | grep -q "$pat" || { echo "Missing: $pat"; exit 1; }
done
+43
View File
@@ -0,0 +1,43 @@
name: JS Code Style
on:
push:
branches: [ master ]
paths:
- 'src/pretix/static/pretixpresale/widget/**'
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
- 'src/pretix/plugins/webcheckin/**'
- 'eslint.config.mjs'
- 'package.json'
- 'package-lock.json'
pull_request:
branches: [ master ]
paths:
- 'src/pretix/static/pretixpresale/widget/**'
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
- 'src/pretix/plugins/webcheckin/**'
- 'eslint.config.mjs'
- 'package.json'
- 'package-lock.json'
permissions:
contents: read
env:
FORCE_COLOR: 1
jobs:
eslint:
name: eslint
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install Dependencies
run: npm ci
- name: Run ESLint
run: npm run lint:eslint
+44 -1
View File
@@ -72,7 +72,7 @@ jobs:
run: make all compress run: make all compress
- name: Run tests - name: Run tests
working-directory: ./src working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --maxfail=100 run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --ignore=tests/e2e --maxfail=100
- name: Run concurrency tests - name: Run concurrency tests
working-directory: ./src working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db
@@ -84,3 +84,46 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false fail_ci_if_error: false
if: matrix.database == 'postgres' && matrix.python-version == '3.13' if: matrix.database == 'postgres' && matrix.python-version == '3.13'
e2e:
runs-on: ubuntu-22.04
name: E2E Tests
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: pretix
options: >-
--health-cmd "pg_isready -U postgres -d pretix"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install system dependencies
run: sudo apt update && sudo apt install -y gettext
- name: Install Python dependencies
run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary
- name: Install JS dependencies
working-directory: ./src
run: make npminstall
- name: Compile
working-directory: ./src
run: make all compress
- name: Install Playwright browsers
run: playwright install
- name: Run E2E tests
working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10
+2
View File
@@ -24,5 +24,7 @@ local/
.project .project
.pydevproject .pydevproject
.DS_Store .DS_Store
node_modules/
.vite/
+1
View File
@@ -0,0 +1 @@
/*
+7 -3
View File
@@ -1,6 +1,7 @@
FROM python:3.13-trixie FROM python:3.13-trixie
RUN apt-get update && \ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential \ build-essential \
gettext \ gettext \
@@ -21,8 +22,7 @@ RUN apt-get update && \
libmaxminddb0 \ libmaxminddb0 \
libmaxminddb-dev \ libmaxminddb-dev \
zlib1g-dev \ zlib1g-dev \
nodejs \ nodejs && \
npm && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
dpkg-reconfigure locales && \ dpkg-reconfigure locales && \
@@ -50,6 +50,10 @@ COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
COPY pyproject.toml /pretix/pyproject.toml COPY pyproject.toml /pretix/pyproject.toml
COPY _build /pretix/_build COPY _build /pretix/_build
COPY src /pretix/src COPY src /pretix/src
COPY package.json /pretix/package.json
COPY package-lock.json /pretix/package-lock.json
COPY tsconfig.json /pretix/tsconfig.json
COPY vite.config.ts /pretix/vite.config.ts
RUN pip3 install -U \ RUN pip3 install -U \
pip \ pip \
+5
View File
@@ -48,3 +48,8 @@ recursive-include src Makefile
recursive-exclude doc * recursive-exclude doc *
recursive-exclude deployment * recursive-exclude deployment *
recursive-exclude res * recursive-exclude res *
include package.json
include package-lock.json
include tsconfig.json
include vite.config.ts
+2 -2
View File
@@ -86,7 +86,7 @@ individual commits, we use "Rebase and merge" instead. Merge commits should be a
.. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/ .. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/
.. _flake8: https://pypi.python.org/pypi/flake8 .. _flake8: https://pypi.python.org/pypi/flake8
.. _Django Coding Style: https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/ .. _Django Coding Style: https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/
.. _translation: https://docs.djangoproject.com/en/1.11/topics/i18n/translation/ .. _translation: https://docs.djangoproject.com/en/6.0/topics/i18n/translation/
.. _class-based views: https://docs.djangoproject.com/en/1.11/topics/class-based-views/ .. _class-based views: https://docs.djangoproject.com/en/6.0/topics/class-based-views/
.. _pytest-style: https://docs.pytest.org/en/latest/assert.html .. _pytest-style: https://docs.pytest.org/en/latest/assert.html
.. _fixtures: https://docs.pytest.org/en/latest/fixture.html .. _fixtures: https://docs.pytest.org/en/latest/fixture.html
+50
View File
@@ -110,6 +110,56 @@ process::
However, beware that code changes will not auto-reload within Celery. However, beware that code changes will not auto-reload within Celery.
Running the local development server will also automatically start a vite dev server for all control vue components.
Run the widget development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
To locally develop the presale widget you need to start a separate vite dev server using::
npm run dev:widget
You can control the org, event and much more via query parameters like this::
http://localhost:5180/?org=testorg&event=testevent
The following query parameters are supported:
.. list-table::
:header-rows: 1
:widths: 20 20 60
* - Parameter
- Default
- Description
* - ``org``
- ``testorg``
- Organization slug
* - ``event``
- ``testevent``
- Event slug
* - ``host``
- ``http://localhost:8000``
- Backend host URL
* - ``type``
- ``widget``
- Element type: ``widget`` or ``button``
* - ``mode``
- ``dev``
- ``dev`` loads the Vite dev source, ``prod`` loads the built ``v2.{lang}.js``
* - ``lang``
- ``de``
- Language code for the prod script
* - ``button-text``
- ``Buy tickets!``
- Text content for the button (only used when ``type=button``)
Any other query parameter is passed through as an attribute on the widget/button element.
For example, ``?skip-ssl-check&list-type=calendar&items=123`` adds those attributes directly.
.. _`checksandtests`: .. _`checksandtests`:
Code checks and unit tests Code checks and unit tests
+108
View File
@@ -0,0 +1,108 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import ts from 'typescript-eslint'
import stylistic from '@stylistic/eslint-plugin'
import vue from 'eslint-plugin-vue'
import vuePug from 'eslint-plugin-vue-pug'
const ignores = globalIgnores([
'**/node_modules',
'**/dist'
])
export default defineConfig([
ignores,
...ts.config(
js.configs.recommended,
ts.configs.recommended
),
stylistic.configs.customize({
indent: 'tab',
braceStyle: '1tbs',
quoteProps: 'as-needed'
}),
...vue.configs['flat/recommended'],
...vuePug.configs['flat/recommended'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
localStorage: false,
$: 'readonly',
$$: 'readonly',
$ref: 'readonly',
$computed: 'readonly',
},
parserOptions: {
parser: '@typescript-eslint/parser'
}
},
rules: {
'no-debugger': 'off',
curly: 0,
'no-return-assign': 0,
'no-console': 'off',
'vue/require-default-prop': 0,
'vue/require-v-for-key': 0,
'vue/valid-v-for': 'warn',
'vue/no-reserved-keys': 0,
'vue/no-setup-props-destructure': 0,
'vue/multi-word-component-names': 0,
'vue/max-attributes-per-line': 0,
'vue/attribute-hyphenation': ['warn', 'never'],
'vue/v-on-event-hyphenation': ['warn', 'never'],
'import/first': 0,
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/no-explicit-any': 0,
'no-use-before-define': 'off',
'no-var': 'error',
'@typescript-eslint/no-use-before-define': ['error', {
typedefs: false,
functions: false,
}],
'@typescript-eslint/no-unused-vars': ['error', {
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true
}],
'@stylistic/comma-dangle': 0,
'@stylistic/space-before-function-paren': ['error', 'always'],
'@stylistic/max-statements-per-line': ['error', { max: 1, ignoredNodes: ['BreakStatement'] }],
'@stylistic/member-delimiter-style': 0,
'@stylistic/arrow-parens': 0,
'@stylistic/generator-star-spacing': 0,
'@stylistic/yield-star-spacing': ['error', 'after'],
},
},
{
files: [
'src/pretix/static/pretixcontrol/js/ui/checkinrules/**/*.vue',
'src/pretix/plugins/webcheckin/**/*.vue',
],
languageOptions: {
globals: {
moment: 'readonly',
},
},
},
{
files: [
'src/pretix/static/pretixpresale/widget/**/*.{ts,vue}',
],
languageOptions: {
globals: {
LANG: 'readonly',
},
},
},
])
+4788
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "pretix",
"version": "1.0.0",
"description": "",
"homepage": "https://github.com/pretix/pretix#readme",
"bugs": {
"url": "https://github.com/pretix/pretix/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/pretix/pretix.git"
},
"license": "SEE LICENSE IN LICENSE",
"author": "",
"type": "module",
"main": "index.js",
"directories": {
"doc": "doc"
},
"scripts": {
"dev:control": "vite",
"dev:widget": "vite src/pretix/static/pretixpresale/widget",
"build": "npm run build:control -s && npm run build:widget -s",
"build:control": "vite build",
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"vue": "^3.5.30"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/jquery": "^3.5.33",
"@types/moment": "^2.11.29",
"@types/node": "^25.5.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/language-plugin-pug": "^3.2.5",
"eslint": "^10.0.3",
"eslint-plugin-vue": "^10.8.0",
"eslint-plugin-vue-pug": "^1.0.0-alpha.5",
"globals": "^17.4.0",
"pug": "^3.0.3",
"sass-embedded": "^1.98.0",
"smol-toml": "^1.6.1",
"stylus": "^0.64.0",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.0"
}
}
+2
View File
@@ -124,7 +124,9 @@ dev = [
"pytest-mock==3.15.*", "pytest-mock==3.15.*",
"pytest-sugar", "pytest-sugar",
"pytest-xdist==3.8.*", "pytest-xdist==3.8.*",
"pytest-playwright",
"pytest==9.0.*", "pytest==9.0.*",
"playwright",
"responses", "responses",
] ]
+5
View File
@@ -37,4 +37,9 @@ ignore =
CONTRIBUTING.md CONTRIBUTING.md
Dockerfile Dockerfile
SECURITY.md SECURITY.md
eslint.config.mjs
package-lock.json
package.json
tsconfig.json
vite.config.js
+6 -6
View File
@@ -9,10 +9,10 @@ localegen:
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS) ./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS) ./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
staticfiles: jsi18n staticfiles: npminstall npmbuild jsi18n
./manage.py collectstatic --noinput ./manage.py collectstatic --noinput
compress: npminstall compress:
./manage.py compress ./manage.py compress
jsi18n: localecompile jsi18n: localecompile
@@ -25,8 +25,8 @@ coverage:
coverage run -m py.test coverage run -m py.test
npminstall: npminstall:
# keep this in sync with pretix/_build.py! npm ci
mkdir -p pretix/static.dist/node_prefix/
cp -r pretix/static/npm_dir/* pretix/static.dist/node_prefix/ npmbuild:
npm ci --prefix=pretix/static.dist/node_prefix npm run build
+3 -2
View File
@@ -37,9 +37,11 @@ INSTALLED_APPS = [
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize', 'django.contrib.humanize',
# pretix needs to go before staticfiles
# so we can override the runserver command
'pretix.base', 'pretix.base',
'django.contrib.staticfiles',
'pretix.control', 'pretix.control',
'pretix.presale', 'pretix.presale',
'pretix.multidomain', 'pretix.multidomain',
@@ -243,7 +245,6 @@ STORAGES = {
COMPRESS_PRECOMPILERS = ( COMPRESS_PRECOMPILERS = (
('text/x-scss', 'django_libsass.SassCompiler'), ('text/x-scss', 'django_libsass.SassCompiler'),
('text/vue', 'pretix.helpers.compressor.VueCompiler'),
) )
COMPRESS_OFFLINE_CONTEXT = { COMPRESS_OFFLINE_CONTEXT = {
+7 -6
View File
@@ -21,13 +21,13 @@
# #
import os import os
import shutil
import subprocess import subprocess
from setuptools.command.build import build from setuptools.command.build import build
from setuptools.command.build_ext import build_ext from setuptools.command.build_ext import build_ext
here = os.path.abspath(os.path.dirname(__file__)) here = os.path.abspath(os.path.dirname(__file__))
project_root = os.path.abspath(os.path.join(here, '..', '..'))
npm_installed = False npm_installed = False
@@ -35,14 +35,14 @@ def npm_install():
global npm_installed global npm_installed
if not npm_installed: if not npm_installed:
# keep this in sync with Makefile! subprocess.check_call('npm ci', shell=True, cwd=project_root)
node_prefix = os.path.join(here, 'static.dist', 'node_prefix')
os.makedirs(node_prefix, exist_ok=True)
shutil.copytree(os.path.join(here, 'static', 'npm_dir'), node_prefix, dirs_exist_ok=True)
subprocess.check_call('npm ci', shell=True, cwd=node_prefix)
npm_installed = True npm_installed = True
def npm_build():
subprocess.check_call('npm run build', shell=True, cwd=project_root)
class CustomBuild(build): class CustomBuild(build):
def run(self): def run(self):
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"): if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
@@ -62,6 +62,7 @@ class CustomBuild(build):
settings.COMPRESS_OFFLINE = True settings.COMPRESS_OFFLINE = True
npm_install() npm_install()
npm_build()
management.call_command('compilemessages', verbosity=1) management.call_command('compilemessages', verbosity=1)
management.call_command('compilejsi18n', verbosity=1) management.call_command('compilejsi18n', verbosity=1)
management.call_command('collectstatic', verbosity=1, interactive=False) management.call_command('collectstatic', verbosity=1, interactive=False)
+2
View File
@@ -47,3 +47,5 @@ HAS_MEMCACHED = False
HAS_CELERY = False HAS_CELERY = False
HAS_GEOIP = False HAS_GEOIP = False
SENTRY_ENABLED = False SENTRY_ENABLED = False
VITE_DEV_MODE = False
VITE_IGNORE = False
+2 -2
View File
@@ -115,10 +115,10 @@ class PluginsField(serializers.Field):
def to_representation(self, obj): def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins from pretix.base.plugins import get_all_plugins
active_plugins = set(obj.get_plugins())
return sorted([ return sorted([
p.module for p in get_all_plugins() p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins() if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in active_plugins
]) ])
def to_internal_value(self, data): def to_internal_value(self, data):
+3 -2
View File
@@ -160,7 +160,7 @@ class OrderListExporter(MultiSheetListExporter):
def _get_all_payment_methods(self, qs): def _get_all_payment_methods(self, qs):
pps = dict(get_all_payment_providers()) pps = dict(get_all_payment_providers())
return sorted([(pp, pps[pp]) for pp in set( return sorted([(pp, pps.get(pp, pp)) for pp in set(
OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list( OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list(
'provider', flat=True 'provider', flat=True
).distinct() ).distinct()
@@ -330,6 +330,7 @@ class OrderListExporter(MultiSheetListExporter):
taxsum=Sum('tax_value'), grosssum=Sum('value') taxsum=Sum('tax_value'), grosssum=Sum('value')
) )
} }
payment_methods = None
if form_data.get('include_payment_amounts'): if form_data.get('include_payment_amounts'):
payment_sum_cache = { payment_sum_cache = {
(o['order__id'], o['provider']): o['grosssum'] for o in (o['order__id'], o['provider']): o['grosssum'] for o in
@@ -347,6 +348,7 @@ class OrderListExporter(MultiSheetListExporter):
grosssum=Sum('amount') grosssum=Sum('amount')
) )
} }
payment_methods = self._get_all_payment_methods(qs)
sum_cache = { sum_cache = {
(o['order__id'], o['tax_rate']): o for o in (o['order__id'], o['tax_rate']): o for o in
OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate( OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate(
@@ -434,7 +436,6 @@ class OrderListExporter(MultiSheetListExporter):
) )
if form_data.get('include_payment_amounts'): if form_data.get('include_payment_amounts'):
payment_methods = self._get_all_payment_methods(qs)
for id, vn in payment_methods: for id, vn in payment_methods:
row.append( row.append(
payment_sum_cache.get((order.id, id), Decimal('0.00')) - payment_sum_cache.get((order.id, id), Decimal('0.00')) -
+1 -1
View File
@@ -1160,7 +1160,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
return stylesheet return stylesheet
def _draw_invoice_from(self, canvas): def _draw_invoice_from(self, canvas):
if not self.invoice.invoice_from: if not self.invoice.address_invoice_from:
return return
c = [ c = [
self._clean_text(l) self._clean_text(l)
@@ -0,0 +1,59 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
"""This command supersedes the Django-inbuilt runserver command.
It runs the local frontend server, if node is installed and the setting
is set.
"""
import atexit
import os
import subprocess
from pathlib import Path
from django.conf import settings
from django.contrib.staticfiles.management.commands.runserver import (
Command as Parent,
)
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
class Command(Parent):
def handle(self, *args, **options):
# Only start Vite in the non-main process of the autoreloader
if settings.VITE_DEV_MODE and os.environ.get(DJANGO_AUTORELOAD_ENV) != "true":
# Start the vite server in the background
vite_server = subprocess.Popen(
["npm", "run", "dev:control"],
cwd=Path(__file__).parent.parent.parent.parent.parent
)
def cleanup():
vite_server.terminate()
try:
vite_server.wait(timeout=5)
except subprocess.TimeoutExpired:
vite_server.kill()
atexit.register(cleanup)
super().handle(*args, **options)
+13 -1
View File
@@ -281,7 +281,7 @@ class SecurityMiddleware(MiddlewareMixin):
h = { h = {
'default-src': ["{static}"], 'default-src': ["{static}"],
'script-src': ['{static}'], 'script-src': ["{static}"],
'object-src': ["'none'"], 'object-src': ["'none'"],
'frame-src': ['{static}'], 'frame-src': ['{static}'],
'style-src': ["{static}", "{media}"], 'style-src': ["{static}", "{media}"],
@@ -295,6 +295,18 @@ class SecurityMiddleware(MiddlewareMixin):
# this. However, we'll restrict it to HTTPS. # this. However, we'll restrict it to HTTPS.
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []), 'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
} }
if settings.VITE_DEV_MODE:
h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"]
h['style-src'] += ["'unsafe-inline'"]
h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"]
if hasattr(request, 'csp_nonce'):
nonce = f"'nonce-{request.csp_nonce}'"
h['script-src'].append(nonce)
if not settings.VITE_DEV_MODE:
# can't have 'unsafe-inline' and nonce at the same time
h['style-src'].append(nonce)
# Only include pay.google.com for wallet detection purposes on the Payment selection page # Only include pay.google.com for wallet detection purposes on the Payment selection page
if ( if (
url.url_name == "event.order.pay.change" or url.url_name == "event.order.pay.change" or
+1 -1
View File
@@ -442,7 +442,7 @@ class AttendeeState(ImportColumn):
@property @property
def verbose_name(self): def verbose_name(self):
return _('Attendee address') + ': ' + _('State') return _('Attendee address') + ': ' + pgettext('address', 'State')
def clean(self, value, previous_values): def clean(self, value, previous_values):
if value: if value:
+1 -1
View File
@@ -125,7 +125,7 @@ class LoggingMixin:
elif isinstance(self, Event): elif isinstance(self, Event):
event = self event = self
organizer_id = self.organizer_id organizer_id = self.organizer_id
elif hasattr(self, 'event'): elif hasattr(self, 'event') and self.event:
event = self.event event = self.event
organizer_id = self.event.organizer_id organizer_id = self.event.organizer_id
elif hasattr(self, 'organizer_id'): elif hasattr(self, 'organizer_id'):
+29 -24
View File
@@ -49,14 +49,39 @@ class PluginType(Enum):
EXPORT = 4 EXPORT = 4
def plugin_is_available(meta, event=None, organizer=None):
if not hasattr(meta.app, 'is_available'):
return True
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if event:
return meta.app.is_available(event)
elif organizer:
if not hasattr(organizer, '_plugin_availability_fallback_event'):
with scope(organizer=organizer):
setattr(organizer, '_plugin_availability_fallback_event', organizer.events.first())
return (
organizer._plugin_availability_fallback_event
and meta.app.is_available(organizer._plugin_availability_fallback_event)
)
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer:
return meta.app.is_available(organizer)
elif event:
return meta.app.is_available(event.organizer)
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer):
return meta.app.is_available(event or organizer)
return True
def get_all_plugins(*, event=None, organizer=None) -> List[type]: def get_all_plugins(*, event=None, organizer=None) -> List[type]:
""" """
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps. Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
""" """
assert not event or not organizer assert not event or not organizer
plugins = [] plugins = []
event_fallback = None
event_fallback_used = False
for app in apps.get_app_configs(): for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'): if hasattr(app, 'PretixPluginMeta'):
meta = app.PretixPluginMeta meta = app.PretixPluginMeta
@@ -65,28 +90,8 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]:
if app.name in settings.PRETIX_PLUGINS_EXCLUDE: if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue continue
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT) if not plugin_is_available(meta, event, organizer):
if level == PLUGIN_LEVEL_EVENT: continue
if event and hasattr(app, 'is_available'):
if not app.is_available(event):
continue
elif organizer and hasattr(app, 'is_available'):
if not event_fallback_used:
with scope(organizer=organizer):
event_fallback = organizer.events.first()
event_fallback_used = True
if not event_fallback or not app.is_available(event_fallback):
continue
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer and hasattr(app, 'is_available'):
if not app.is_available(organizer):
continue
elif event and hasattr(app, 'is_available'):
if not app.is_available(event.organizer):
continue
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer) and hasattr(app, 'is_available'):
if not app.is_available(event or organizer):
continue
plugins.append(meta) plugins.append(meta)
return sorted( return sorted(
+2 -2
View File
@@ -162,12 +162,12 @@ error_messages = {
'price_too_high': gettext_lazy('The entered price is to high.'), 'price_too_high': gettext_lazy('The entered price is to high.'),
'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'), 'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'),
'voucher_min_usages': ngettext_lazy( 'voucher_min_usages': ngettext_lazy(
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.', 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product.',
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.', 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
'number' 'number'
), ),
'voucher_min_usages_removed': ngettext_lazy( 'voucher_min_usages_removed': ngettext_lazy(
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. ' 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product. '
'We have therefore removed some positions from your cart that can no longer be purchased like this.', 'We have therefore removed some positions from your cart that can no longer be purchased like this.',
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. ' 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
'We have therefore removed some positions from your cart that can no longer be purchased like this.', 'We have therefore removed some positions from your cart that can no longer be purchased like this.',
File diff suppressed because one or more lines are too long
+243
View File
@@ -0,0 +1,243 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import json
import logging
import pathlib
import re
import secrets
from urllib.parse import urljoin
from urllib.request import urlopen
import importlib_metadata as metadata
from django import template
from django.conf import settings
from django.utils.safestring import mark_safe
register = template.Library()
LOGGER = logging.getLogger(__name__)
_MANIFEST = {}
# TODO more os.path.join ?
MANIFEST_PATH = settings.STATIC_ROOT + "/vite/control/.vite/manifest.json"
MANIFEST_BASE = "vite/control/"
# entry_name -> {"manifest_entry": {...}, "url_base": "..."}
_PLUGIN_REGISTRY = {}
def _discover_plugin_manifests():
"""Discover plugin vite manifests at startup.
Scans installed pretix plugins for a .vite/manifest.json inside a static.dist
directory. Only non-editable (wheel) plugins are expected to ship pre-built
assets; editable plugins are served through the Vite dev server.
"""
for ep in metadata.entry_points(group='pretix.plugin'):
dist = ep.dist
if not dist or not dist.files:
continue
try:
url_info = json.loads(dist.read_text('direct_url.json') or '{}')
if url_info.get('dir_info', {}).get('editable', False):
continue # editable plugins are served via vite dev server
except Exception:
pass
# Find .vite/manifest.json inside a /static/ directory
try:
manifest_rel = None
for f in dist.files:
if f.name == 'manifest.json' and '/static/' in str(f) and '/.vite/' in str(f):
manifest_rel = f
break
if not manifest_rel:
continue
manifest_path = pathlib.Path(str(dist.locate_file(manifest_rel)))
if not manifest_path.exists():
continue
plugin_manifest = json.loads(manifest_path.read_text())
url_base = re.search(r'/static/(.+?)/\.vite/', str(manifest_rel)).group(1) + '/'
for _key, entry in plugin_manifest.items():
if entry.get('isEntry') and 'name' in entry:
_PLUGIN_REGISTRY[entry['name']] = {
'manifest_entry': entry,
'url_base': url_base,
}
except Exception:
LOGGER.warning(f"Failed to discover vite manifest for plugin {ep.name}", exc_info=True)
# Load core manifest
if not settings.VITE_DEV_MODE and not settings.VITE_IGNORE:
try:
with open(MANIFEST_PATH) as fp:
_MANIFEST = json.load(fp)
except Exception as e:
LOGGER.warning(f"Error reading vite manifest at {MANIFEST_PATH}: {str(e)}")
# Discover plugin manifests
if not settings.VITE_IGNORE:
_discover_plugin_manifests()
def _generate_script_tag(path, attrs, src=None):
all_attrs = " ".join(f'{key}="{value}"' for key, value in attrs.items())
if src is None:
if settings.VITE_DEV_MODE:
src = urljoin(settings.VITE_DEV_SERVER, path)
else:
src = urljoin(settings.STATIC_URL, path)
return f'<script {all_attrs} src="{src}"></script>'
def _generate_css_tags(asset, already_processed=None):
"""Recursively builds all CSS tags used in a given asset from the core manifest."""
tags = []
manifest_entry = _MANIFEST[asset]
if already_processed is None:
already_processed = []
if "css" in manifest_entry:
for css_path in manifest_entry["css"]:
if css_path not in already_processed:
full_path = urljoin(settings.STATIC_URL, MANIFEST_BASE + css_path)
tags.append(f'<link rel="stylesheet" href="{full_path}" />')
already_processed.append(css_path)
if "imports" in manifest_entry:
for import_path in manifest_entry["imports"]:
tags += _generate_css_tags(import_path, already_processed)
return tags
def _generate_plugin_css_tags(manifest_entry, url_base):
"""Build CSS tags for a plugin manifest entry."""
tags = []
if "css" in manifest_entry:
for css_path in manifest_entry["css"]:
full_path = urljoin(settings.STATIC_URL, url_base + css_path)
tags.append(f'<link rel="stylesheet" href="{full_path}" />')
return tags
@register.simple_tag
@mark_safe
def vite_asset(path):
"""
Generates one <script> tag and <link> tags for each of the CSS dependencies.
"""
if not path:
return ""
# Check plugin registry (non-editable plugins with pre-built assets)
if path in _PLUGIN_REGISTRY:
info = _PLUGIN_REGISTRY[path]
entry = info['manifest_entry']
url_base = info['url_base']
tags = _generate_plugin_css_tags(entry, url_base)
# Always use STATIC_URL for pre-built plugin assets, even in dev mode
src = urljoin(settings.STATIC_URL, url_base + entry["file"])
tags.append(_generate_script_tag(path, {"type": "module", "crossorigin": ""}, src=src))
return "".join(tags)
# Dev mode: editable plugins and core entries go through the vite dev server
if settings.VITE_DEV_MODE:
return _generate_script_tag(path, {"type": "module"})
# Prod mode
manifest_entry = _MANIFEST.get(path)
if not manifest_entry:
raise RuntimeError(f"Cannot find {path} in Vite manifest at {MANIFEST_PATH}")
tags = _generate_css_tags(path)
tags.append(
_generate_script_tag(
MANIFEST_BASE + manifest_entry["file"], {"type": "module", "crossorigin": ""}
)
)
return "".join(tags)
@register.simple_tag
@mark_safe
def vite_hmr():
if not settings.VITE_DEV_MODE:
return ""
return _generate_script_tag("@vite/client", {"type": "module"})
_dev_importmap_cache = None
def _get_dev_importmap():
"""Fetch the shared-dep import map from the Vite dev server. Cached after first call."""
global _dev_importmap_cache
if _dev_importmap_cache is not None:
return _dev_importmap_cache
try:
url = urljoin(settings.VITE_DEV_SERVER, "/__pretix_importmap")
raw = json.loads(urlopen(url, timeout=2).read())
_dev_importmap_cache = {
dep: urljoin(settings.VITE_DEV_SERVER, dep_path)
for dep, dep_path in raw.items()
}
except Exception:
LOGGER.warning("Failed to fetch import map from Vite dev server")
_dev_importmap_cache = {}
return _dev_importmap_cache
@register.simple_tag(takes_context=True)
@mark_safe
def vite_importmap(context):
"""Emit an import map so pre-built plugin assets can resolve shared dependencies like vue."""
imports = {}
if settings.VITE_DEV_MODE:
# Fetch the import map from the Vite dev server (served by sharedDepsPlugin)
imports.update(_get_dev_importmap())
else:
# Discover all _vendor/* entries from the core manifest
for _key, entry in _MANIFEST.items():
name = entry.get("name", "")
if name.startswith("_vendor/"):
bare_specifier = name[len("_vendor/"):]
imports[bare_specifier] = urljoin(settings.STATIC_URL, MANIFEST_BASE + entry["file"])
if not imports:
return ""
# Generate a nonce and store it on the request so the CSP middleware can allow it
nonce = secrets.token_urlsafe(16)
request = context.get('request')
if request:
request.csp_nonce = nonce
return f'<script type="importmap" nonce="{nonce}">{json.dumps({"imports": imports})}</script>'
+127
View File
@@ -1528,6 +1528,133 @@ class SubEventFilterForm(FilterForm):
return self.event.organizer.meta_properties.filter(filter_allowed=True) return self.event.organizer.meta_properties.filter(filter_allowed=True)
class QuotaFilterForm(FilterForm):
orders = {
'-date': ('-subevent__date_from', 'name', 'pk'),
'date': ('subevent__date_from', '-name', '-pk'),
'size': ('size', 'name', 'pk'),
'-size': ('-size', '-name', '-pk'),
'name': ('name', 'pk'),
'-name': ('-name', '-pk'),
}
subevent = forms.ModelChoiceField(
label=pgettext_lazy('subevent', 'Date'),
queryset=SubEvent.objects.none(),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates')
)
date_from = forms.DateField(
label=_('Date from'),
required=False,
widget=DatePickerWidget({
'placeholder': _('Date from'),
}),
)
date_until = forms.DateField(
label=_('Date until'),
required=False,
widget=DatePickerWidget({
'placeholder': _('Date until'),
}),
)
time_from = forms.TimeField(
label=_('Start time from'),
required=False,
widget=TimePickerWidget({}),
)
time_until = forms.TimeField(
label=_('Start time until'),
required=False,
widget=TimePickerWidget({}),
)
weekday = forms.MultipleChoiceField(
label=_('Weekday'),
choices=(
('2', _('Monday')),
('3', _('Tuesday')),
('4', _('Wednesday')),
('5', _('Thursday')),
('6', _('Friday')),
('7', _('Saturday')),
('1', _('Sunday')),
),
widget=forms.CheckboxSelectMultiple,
required=False
)
query = forms.CharField(
label=_('Quota name'),
widget=forms.TextInput(),
required=False
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
if self.event.has_subevents:
self.fields['date_from'].widget = DatePickerWidget()
self.fields['date_until'].widget = DatePickerWidget()
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'All dates')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
else:
del self.fields['subevent']
del self.fields['date_from']
del self.fields['date_until']
del self.fields['time_from']
del self.fields['time_until']
del self.fields['weekday']
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('weekday'):
qs = qs.annotate(wday=ExtractWeekDay('subevent__date_from')).filter(wday__in=fdata.get('weekday'))
if fdata.get('subevent'):
qs = qs.filter(subevent=fdata["subevent"])
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(name__icontains=query)
if fdata.get('date_until'):
date_end = make_aware(datetime.combine(
fdata.get('date_until') + timedelta(days=1),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(
Q(subevent__date_to__isnull=True, subevent__date_from__lt=date_end) |
Q(subevent__date_to__isnull=False, subevent__date_to__lt=date_end)
)
if fdata.get('date_from'):
date_start = make_aware(datetime.combine(
fdata.get('date_from'),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(subevent__date_from__gte=date_start)
if fdata.get('time_until'):
qs = qs.filter(subevent__date_from__time__lte=fdata.get('time_until'))
if fdata.get('time_from'):
qs = qs.filter(subevent__date_from__time__gte=fdata.get('time_from'))
if fdata.get('ordering'):
qs = qs.order_by(*get_deterministic_ordering(Quota, self.get_order_by()))
else:
qs = qs.order_by('-subevent__date_from', 'name', 'pk')
return qs
class OrganizerFilterForm(FilterForm): class OrganizerFilterForm(FilterForm):
orders = { orders = {
'slug': 'slug', 'slug': 'slug',
@@ -104,6 +104,12 @@ class GlobalSettingsForm(SettingsForm):
help_text=_("Will be served at {domain}/.well-known/apple-developer-merchantid-domain-association").format( help_text=_("Will be served at {domain}/.well-known/apple-developer-merchantid-domain-association").format(
domain=settings.SITE_URL domain=settings.SITE_URL
) )
)),
('widget_vite_origins', forms.CharField(
widget=forms.Textarea(attrs={'rows': '3'}),
required=False,
label=_("Vite widget origins"),
help_text=_("One origin per line (e.g. https://example.com). Requests from these origins will be served the new vite-based widget."),
)) ))
]) ])
responses = register_global_settings.send(self) responses = register_global_settings.send(self)
+55
View File
@@ -43,6 +43,7 @@ from django.core.exceptions import ValidationError
from django.db.models import Max, Q from django.db.models import Max, Q
from django.forms import ChoiceField, RadioSelect from django.forms import ChoiceField, RadioSelect
from django.forms.formsets import DELETION_FIELD_NAME from django.forms.formsets import DELETION_FIELD_NAME
from django.forms.utils import ErrorDict
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape, format_html from django.utils.html import escape, format_html
@@ -375,6 +376,60 @@ class QuotaForm(I18nModelForm):
return inst return inst
class QuotaBulkEditForm(QuotaForm):
def __init__(self, *args, **kwargs):
self.mixed_values = kwargs.pop('mixed_values')
self.queryset = kwargs.pop('queryset')
super().__init__(**kwargs)
self.fields.pop("subevent", None) # Would add extra complexity and it's hard to imagine a use case for that
self.fields["name"].required = False
self.fields["itemvars"].required = False
def clean(self):
d = super().clean()
if self.prefix + "name" in self.data.getlist('_bulk') and not d.get("name"):
raise ValidationError({"name": _("This field is required.")})
if self.prefix + "itemvars" in self.data.getlist('_bulk') and not d.get("itemvars"):
raise ValidationError({"itemvars": _("This field is required.")})
return d
def save(self, commit=True):
objs = list(self.queryset)
fields = set()
for k in self.fields:
cb_val = self.prefix + k
if cb_val not in self.data.getlist('_bulk'):
continue
fields.add(k)
if k == 'itemvars':
selected_items = set(list(self.event.items.filter(id__in=[
i.split('-')[0] for i in self.cleaned_data['itemvars']
])))
selected_variations = list(ItemVariation.objects.filter(item__event=self.event, id__in=[
i.split('-')[1] for i in self.cleaned_data['itemvars'] if '-' in i
]))
for obj in objs:
obj.items.set(selected_items)
obj.variations.set(selected_variations)
else:
for obj in objs:
setattr(obj, k, self.cleaned_data[k])
fields = [f for f in fields if f != 'itemvars']
if fields:
Quota.objects.bulk_update(objs, fields, 200)
def full_clean(self):
if len(self.data) == 0:
# form wasn't submitted
self._errors = ErrorDict()
return
super().full_clean()
class ItemCreateForm(I18nModelForm): class ItemCreateForm(I18nModelForm):
NONE = 'none' NONE = 'none'
EXISTING = 'existing' EXISTING = 'existing'
+6 -6
View File
@@ -34,11 +34,11 @@
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
from collections import defaultdict from collections import defaultdict
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Optional from typing import Optional
import bleach import bleach
import dateutil.parser
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.formats import date_format from django.utils.formats import date_format
@@ -248,7 +248,7 @@ class OrderValidFromChanged(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data): def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('The validity start date for position #{posid} has been changed to {value}.').format( return _('The validity start date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get( value=date_format(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
'new_value') else '' 'new_value') else ''
) )
@@ -260,7 +260,7 @@ class OrderValidUntilChanged(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data): def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('The validity end date for position #{posid} has been changed to {value}.').format( return _('The validity end date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else '' value=date_format(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else ''
) )
@@ -364,7 +364,7 @@ class CheckinErrorLogEntryType(OrderLogEntryType):
data['posid'] = logentry.parsed_data.get('positionid', '?') data['posid'] = logentry.parsed_data.get('positionid', '?')
if 'datetime' in data: if 'datetime' in data:
dt = dateutil.parser.parse(data.get('datetime')) dt = datetime.fromisoformat(data.get('datetime'))
if abs((logentry.datetime - dt).total_seconds()) > 5 or data.get('forced'): if abs((logentry.datetime - dt).total_seconds()) > 5 or data.get('forced'):
if event: if event:
data['datetime'] = date_format(dt.astimezone(event.timezone), "SHORT_DATETIME_FORMAT") data['datetime'] = date_format(dt.astimezone(event.timezone), "SHORT_DATETIME_FORMAT")
@@ -430,7 +430,7 @@ class OrderPrintLogEntryType(OrderLogEntryType):
return _('Position #{posid} has been printed at {datetime} with type "{type}".').format( return _('Position #{posid} has been printed at {datetime} with type "{type}".').format(
posid=data.get('positionid'), posid=data.get('positionid'),
datetime=date_format( datetime=date_format(
dateutil.parser.parse(data["datetime"]).astimezone(logentry.event.timezone), datetime.fromisoformat(data["datetime"]).astimezone(logentry.event.timezone),
"SHORT_DATETIME_FORMAT" "SHORT_DATETIME_FORMAT"
) if logentry.event else data["datetime"], ) if logentry.event else data["datetime"],
type=dict(PrintLog.PRINT_TYPES)[data["type"]], type=dict(PrintLog.PRINT_TYPES)[data["type"]],
@@ -985,7 +985,7 @@ class LegacyCheckinLogEntryType(OrderLogEntryType):
def display(self, logentry, data): def display(self, logentry, data):
# deprecated # deprecated
dt = dateutil.parser.parse(data.get('datetime')) dt = datetime.fromisoformat(data.get('datetime'))
tz = logentry.event.timezone tz = logentry.event.timezone
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT") dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
if 'list' in data: if 'list' in data:
@@ -2,6 +2,7 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load statici18n %} {% load statici18n %}
{% load vite %}
{% load eventsignal %} {% load eventsignal %}
{% load eventurl %} {% load eventurl %}
{% load dialog %} {% load dialog %}
@@ -84,6 +85,7 @@
<meta name="theme-color" content="#3b1c4a"> <meta name="theme-color" content="#3b1c4a">
<meta name="referrer" content="origin"> <meta name="referrer" content="origin">
{% vite_importmap %}
{% block custom_header %}{% endblock %} {% block custom_header %}{% endblock %}
</head> </head>
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}" <body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"
@@ -3,6 +3,7 @@
{% load bootstrap3 %} {% load bootstrap3 %}
{% load static %} {% load static %}
{% load compress %} {% load compress %}
{% load vite %}
{% block title %} {% block title %}
{% if checkinlist %} {% if checkinlist %}
{% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %} {% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %}
@@ -74,45 +75,8 @@
{% bootstrap_field form.ignore_in_statistics layout="control" %} {% bootstrap_field form.ignore_in_statistics layout="control" %}
<h3>{% trans "Custom check-in rule" %}</h3> <h3>{% trans "Custom check-in rule" %}</h3>
<div id="rules-editor" class="form-inline"> <div id="rules-editor">
<div> <!-- Vue app mount point -->
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active">
<a href="#rules-edit" role="tab" data-toggle="tab">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
</li>
<li role="presentation">
<a href="#rules-viz" role="tab" data-toggle="tab">
<span class="fa fa-eye"></span>
{% trans "Visualize" %}
</a>
</li>
</ul>
<!-- Tab panes -->
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="rules-edit">
<checkin-rules-editor></checkin-rules-editor>
</div>
<div role="tabpanel" class="tab-pane" id="rules-viz">
<checkin-rules-visualization></checkin-rules-visualization>
</div>
</div>
</div>
<div class="alert alert-info" v-if="missingItems.length">
<p>
{% trans "Your rule always filters by product or variation, but the following products or variations are not contained in any of your rule parts so people with these tickets will not get in:" %}
</p>
<ul>
<li v-for="h in missingItems">{{ "{" }}{h}{{ "}" }}</li>
</ul>
<p>
{% trans "Please double-check if this was intentional." %}
</p>
</div>
</div> </div>
<div class="disabled-withoutjs sr-only"> <div class="disabled-withoutjs sr-only">
{{ form.rules }} {{ form.rules }}
@@ -125,13 +89,10 @@
</button> </button>
</div> </div>
</form> </form>
{{ items|json_script:"items" }} {% if items %}
{{ items|json_script:"items" }}
{% if DEBUG %}
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
{% else %}
<script type="text/javascript" src="{% static "vuejs/vue.min.js" %}"></script>
{% endif %} {% endif %}
{% compress js %} {% compress js %}
<script type="text/javascript" src="{% static "d3/d3.v6.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3.v6.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-color.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-color.v2.js" %}"></script>
@@ -144,15 +105,6 @@
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
{% endcompress %} {% endcompress %}
{% compress js %} {% vite_hmr %}
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script> {% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts" %}
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/datetimefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/timefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/lookup-select2.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rule.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-editor.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/viz-node.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue' %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules.js" %}"></script>
{% endcompress %}
{% endblock %} {% endblock %}
@@ -5,6 +5,7 @@
{% load getitem %} {% load getitem %}
{% load static %} {% load static %}
{% load compress %} {% load compress %}
{% load vite %}
{% block title %}{% trans "Check-in simulator" %}{% endblock %} {% block title %}{% trans "Check-in simulator" %}{% endblock %}
{% block inside %} {% block inside %}
<h1> <h1>
@@ -124,11 +125,9 @@
{% endif %} {% endif %}
{% if result.rule_graph %} {% if result.rule_graph %}
<div id="rules-editor" class="form-inline"> <div id="rules-editor" class="form-inline">
<div role="tabpanel" class="tab-pane" id="rules-viz"> <!-- Vue app mount point -->
<checkin-rules-visualization></checkin-rules-visualization>
</div>
<textarea id="id_rules" class="sr-only">{{ result.rule_graph|attr_escapejson_dumps }}</textarea>
</div> </div>
<textarea id="id_rules" class="sr-only">{{ result.rule_graph|attr_escapejson_dumps }}</textarea>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -152,10 +151,6 @@
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
{% endcompress %} {% endcompress %}
{% compress js %} {% vite_hmr %}
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script> {% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts" %}
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/viz-node.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue' %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules.js" %}"></script>
{% endcompress %}
{% endblock %} {% endblock %}
@@ -0,0 +1,49 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block content %}
<h1>
{% trans "Change multiple quotas" %}
<small>
{% blocktrans trimmed with number=quotas.count %}
{{ number }} selected
{% endblocktrans %}
</small>
</h1>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form_errors form %}
<div class="hidden">
{% for d in quotas %}
<input type="hidden" name="quota" value="{{ d.pk }}">
{% endfor %}
</div>
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="bulkedit" %}
{% bootstrap_field form.size layout="bulkedit" %}
</fieldset>
<fieldset>
<legend>{% trans "Items" %}</legend>
<p>
{% blocktrans trimmed %}
Please select the products or product variations this quota should be applied to. If you apply two
quotas to the same product, it will only be available if <strong>both</strong> quotas have capacity
left.
{% endblocktrans %}
</p>
{% bootstrap_field form.itemvars layout="bulkedit" %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced options" %}</legend>
{% bootstrap_field form.close_when_sold_out layout="bulkedit" %}
{% bootstrap_field form.release_after_exit layout="bulkedit" %}
{% bootstrap_field form.ignore_for_event_availability layout="bulkedit" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,34 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete quotas" %}{% endblock %}
{% block content %}
<h1>{% trans "Delete quotas" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if allowed %}
<p>{% blocktrans trimmed count num=allowed|length %}
Are you sure you want to delete the following quota?
{% plural %}
Are you sure you want to delete the following {{ num }} quotas?
{% endblocktrans %}</p>
<ul>
{% for q in allowed %}
<li>
{{ q }} {% if q.subevent %}({{ q.subevent }}){% endif %}
<input type="hidden" name="quota" value="{{ q.pk }}">
</li>
{% endfor %}
</ul>
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:event.items.quotas" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save" value="delete_confirm" name="action">
{% trans "Delete" %}
</button>
</div>
</form>
{% endblock %}
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/items/base.html" %} {% extends "pretixcontrol/items/base.html" %}
{% load i18n %} {% load i18n %}
{% load urlreplace %} {% load urlreplace %}
{% load bootstrap3 %}
{% block title %}{% trans "Quotas" %}{% endblock %} {% block title %}{% trans "Quotas" %}{% endblock %}
{% block inside %} {% block inside %}
<h1>{% trans "Quotas" %}</h1> <h1>{% trans "Quotas" %}</h1>
@@ -13,21 +14,12 @@
number of a specific ticket type at the same time. number of a specific ticket type at the same time.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
{% if request.event.has_subevents %} {% if quotas|length == 0 and not filter_form.filtered %}
<form class="form-inline helper-display-inline" action="" method="get">
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
</form>
{% endif %}
{% if quotas|length == 0 %}
<div class="empty-collection"> <div class="empty-collection">
<p> <p>
{% if request.GET.subevent %} {% blocktrans trimmed %}
{% trans "Your search did not match any quotas." %} You haven't created any quotas yet.
{% else %} {% endblocktrans %}
{% blocktrans trimmed %}
You haven't created any quotas yet.
{% endblocktrans %}
{% endif %}
</p> </p>
{% if 'event.items:write' in request.eventpermset %} {% if 'event.items:write' in request.eventpermset %}
@@ -36,79 +28,160 @@
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Filter" %}
</h3>
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
<div class="{% if not filter_form.subevent %}col-lg-6{% else %}col-lg-2{% endif %} col-md-6 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.query %}
</div>
{% if filter_form.subevent %}
<div class="col-lg-2 col-md-6 col-md-2 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.subevent %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.date_from %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.date_until %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.time_from %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.time_until %}
</div>
<div class="col-xs-12 one-line-checkboxes">
{% bootstrap_field filter_form.weekday %}
</div>
{% endif %}
</div>
<div class="text-right flip">
<button class="btn btn-primary btn-lg" type="submit">
<span class="fa fa-filter"></span>
{% trans "Filter" %}
</button>
</div>
</form>
</div>
{% if 'event.items:write' in request.eventpermset %} {% if 'event.items:write' in request.eventpermset %}
<p> <p>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new quota" %} <a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new quota" %}
</a> </a>
</p> </p>
{% endif %} {% endif %}
<div class="table-responsive"> <form action="{% url "control:event.items.quotas.bulkaction" organizer=request.event.organizer.slug event=request.event.slug %}" method="post">
<table class="table table-hover table-quotas"> {% csrf_token %}
<thead> {% for field in filter_form %}
<tr> {{ field.as_hidden }}
<th>{% trans "Quota name" %} {% endfor %}
<a href="?{% url_replace request 'ordering' '-name' %}"><i class="fa fa-caret-down"></i></a> <div class="table-responsive">
<a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a> <table class="table table-hover table-quotas">
</th> <thead>
<th>{% trans "Products" %}</th>
{% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}
<a href="?{% url_replace request 'ordering' '-date' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% endif %}
<th>{% trans "Total capacity" %}
<a href="?{% url_replace request 'ordering' '-size' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'size' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Capacity left" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for q in quotas %}
<tr> <tr>
<td> {% if "event.items:write" in request.eventpermset %}
<strong><a href="{% url "control:event.items.quotas.show" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}">{{ q.name }}</a></strong> <th>
{% if q.ignore_for_event_availability %} <label aria-label="{% trans "select all rows for batch-operation" %}" class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Ignore this quota when determining event availability" %}"></span> </th>
{% endif %}
</td>
<td>
<ul>
{% for item in q.cached_items %}
{% if not item.has_variations %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a></li>
{% endif %}
{% endfor %}
{% for v in q.variations.all %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=v.item.id %}#tab-0-3-open">
{{ v.item }} {{ v }}</a></li>
{% endfor %}
</ul>
</td>
{% if request.event.has_subevents %}
<td>
{{ q.subevent.name }} {{ q.subevent.get_date_range_display_with_times }}
</td>
{% endif %} {% endif %}
<td>{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %}</td> <th>{% trans "Quota name" %}
<td>{% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.cached_avail closed=q.closed %}</td> <a href="?{% url_replace request 'filter-ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<td class="text-right flip"> <a href="?{% url_replace request 'filter-ordering' 'name' %}"><i class="fa fa-caret-up"></i></a>
{% if 'event.items:write' in request.eventpermset %} </th>
<a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a> <th>{% trans "Products" %}</th>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ q.id }}" {% if request.event.has_subevents %}
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip"> <th>{% trans "Date" context "subevent" %}
<span class="fa fa-copy"></span> <a href="?{% url_replace request 'filter-ordering' '-date' %}"><i class="fa fa-caret-down"></i></a>
</a> <a href="?{% url_replace request 'filter-ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
<a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a> </th>
{% endif %} {% endif %}
</td> <th>{% trans "Total capacity" %}
<a href="?{% url_replace request 'filter-ordering' '-size' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'size' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Capacity left" %}</th>
<th class="action-col-2"></th>
</tr> </tr>
{% endfor %} {% if "event.items:write" in request.eventpermset and page_obj.paginator.num_pages > 1 %}
</tbody> <tr class="table-select-all warning hidden">
</table> <td>
</div> <input type="checkbox" name="__ALL" id="__all" data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="6">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for q in quotas %}
<tr>
{% if "event.items:write" in request.eventpermset %}
<td>
<label aria-label="{% trans "select row for batch-operation" %}" class="batch-select-label"><input type="checkbox" name="quota" class="batch-select-checkbox" value="{{ q.pk }}"/></label>
</td>
{% endif %}
<td>
<strong><a href="{% url "control:event.items.quotas.show" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}">{{ q.name }}</a></strong>
{% if q.ignore_for_event_availability %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Ignore this quota when determining event availability" %}"></span>
{% endif %}
</td>
<td>
<ul>
{% for item in q.cached_items %}
{% if not item.has_variations %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a></li>
{% endif %}
{% endfor %}
{% for v in q.variations.all %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=v.item.id %}#tab-0-3-open">
{{ v.item }} {{ v }}</a></li>
{% endfor %}
</ul>
</td>
{% if request.event.has_subevents %}
<td>
{{ q.subevent.name }} {{ q.subevent.get_date_range_display_with_times }}
</td>
{% endif %}
<td>{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %}</td>
<td>{% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.cached_avail closed=q.closed %}</td>
<td class="text-right flip">
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ q.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
<a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if "event.items:write" in request.eventpermset %}
<div class="batch-select-actions">
<button type="submit" class="btn btn-danger btn-save" name="action" value="delete">
<i class="fa fa-trash"></i>{% trans "Delete selected" %}
</button>
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit"
formaction="{% url "control:event.items.quotas.bulkedit" organizer=request.event.organizer.slug event=request.event.slug %}">
<i class="fa fa-edit"></i>{% trans "Edit selected" %}
</button>
</div>
{% endif %}
</form>
{% endif %} {% endif %}
{% include "pretixcontrol/pagination.html" %} {% include "pretixcontrol/pagination.html" %}
{% endblock %} {% endblock %}
@@ -471,7 +471,9 @@
{% endif %} {% endif %}
{% if line.subevent %} {% if line.subevent %}
<br/> <br/>
<span class="fa fa-calendar fa-fw"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display_with_times }} <span class="fa fa-calendar fa-fw"></span>
<a href="{% url "control:event.subevent" organizer=request.event.organizer.slug event=request.event.slug subevent=line.subevent_id %}">{{ line.subevent.name }}</a>
&middot; {{ line.subevent.get_date_range_display_with_times }}
{% endif %} {% endif %}
{% if line.used_membership %} {% if line.used_membership %}
<br /><span class="fa fa-id-card fa-fw" aria-hidden="true"></span> <br /><span class="fa fa-id-card fa-fw" aria-hidden="true"></span>
@@ -108,7 +108,7 @@
</a> </a>
</p> </p>
{% endif %} {% endif %}
<form action="{% url "control:organizer.device.bulk_edit" organizer=request.organizer.slug %}" method="post"> <form action="#will-be-overridden" method="post">
{% csrf_token %} {% csrf_token %}
{% for field in filter_form %} {% for field in filter_form %}
{{ field.as_hidden }} {{ field.as_hidden }}
+2
View File
@@ -349,6 +349,8 @@ urlpatterns = [
name='event.items.questions.edit'), name='event.items.questions.edit'),
re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'), re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'), re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
re_path(r'^quotas/bulk_action$', item.QuotaBulkAction.as_view(), name='event.items.quotas.bulkaction'),
re_path(r'^quotas/bulk_edit$', item.QuotaBulkUpdateView.as_view(), name='event.items.quotas.bulkedit'),
re_path(r'^quotas/(?P<quota>\d+)/$', item.QuotaView.as_view(), name='event.items.quotas.show'), re_path(r'^quotas/(?P<quota>\d+)/$', item.QuotaView.as_view(), name='event.items.quotas.show'),
re_path(r'^quotas/select$', typeahead.quotas_select2, name='event.items.quotas.select2'), re_path(r'^quotas/select$', typeahead.quotas_select2, name='event.items.quotas.select2'),
re_path(r'^quotas/(?P<quota>\d+)/change$', item.QuotaUpdate.as_view(), name='event.items.quotas.edit'), re_path(r'^quotas/(?P<quota>\d+)/change$', item.QuotaUpdate.as_view(), name='event.items.quotas.edit'),
+199 -28
View File
@@ -41,21 +41,22 @@ from json.decoder import JSONDecodeError
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.files import File from django.core.files import File
from django.db import transaction from django.db import models, transaction
from django.db.models import ( from django.db.models import (
Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q, Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q, Subquery, Value,
) )
from django.db.models.functions import Cast, Concat
from django.forms.models import inlineformset_factory from django.forms.models import inlineformset_factory
from django.http import ( from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
) )
from django.shortcuts import redirect from django.shortcuts import redirect, render
from django.urls import resolve, reverse from django.urls import resolve, reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from django.views.generic import ListView from django.views.generic import FormView, ListView, View
from django.views.generic.detail import DetailView, SingleObjectMixin from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country from django_countries.fields import Country
@@ -65,7 +66,7 @@ from pretix.api.serializers.item import (
) )
from pretix.base.forms import I18nFormSet from pretix.base.forms import I18nFormSet
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota, OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
SeatCategoryMapping, Voucher, SeatCategoryMapping, Voucher,
) )
@@ -74,12 +75,15 @@ from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tickets import invalidate_cache from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability from pretix.base.signals import quota_availability
from pretix.control.forms.filter import QuestionAnswerFilterForm from pretix.control.forms.filter import (
QuestionAnswerFilterForm, QuotaFilterForm,
)
from pretix.control.forms.item import ( from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm, CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm, ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm, ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm, ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaBulkEditForm,
QuotaForm,
) )
from pretix.control.permissions import ( from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required, EventPermissionRequiredMixin, event_permission_required,
@@ -87,6 +91,7 @@ from pretix.control.permissions import (
from pretix.control.signals import item_forms, item_formsets from pretix.control.signals import item_forms, item_formsets
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
from ...helpers import GroupConcat
from ...helpers.compat import CompatDeleteView from ...helpers.compat import CompatDeleteView
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
@@ -831,13 +836,38 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret return ret
class QuotaList(PaginationMixin, ListView): class QuotaQueryMixin:
@cached_property
def request_data(self):
if self.request.method == "POST":
return self.request.POST
return self.request.GET
def get_queryset(self):
qs = self.request.event.quotas
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
if 'quota' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
id__in=self.request_data.getlist('quota')
)
return qs
@cached_property
def filter_form(self):
return QuotaFilterForm(data=self.request_data, prefix='filter', event=self.request.event)
class QuotaList(PaginationMixin, QuotaQueryMixin, ListView):
model = Quota model = Quota
context_object_name = 'quotas' context_object_name = 'quotas'
template_name = 'pretixcontrol/items/quotas.html' template_name = 'pretixcontrol/items/quotas.html'
def get_queryset(self): def get_queryset(self):
qs = self.request.event.quotas.prefetch_related( return super().get_queryset().prefetch_related(
Prefetch( Prefetch(
"items", "items",
queryset=Item.objects.annotate( queryset=Item.objects.annotate(
@@ -852,28 +882,10 @@ class QuotaList(PaginationMixin, ListView):
queryset=self.request.event.subevents.all() queryset=self.request.event.subevents.all()
) )
) )
if self.request.GET.get("subevent", "") != "":
s = self.request.GET.get("subevent", "")
qs = qs.filter(subevent_id=s)
valid_orders = {
'-date': ('-subevent__date_from', 'name', 'pk'),
'date': ('subevent__date_from', '-name', '-pk'),
'size': ('size', 'name', 'pk'),
'-size': ('-size', '-name', '-pk'),
'name': ('name', 'pk'),
'-name': ('-name', '-pk'),
}
if self.request.GET.get("ordering", "-date") in valid_orders:
qs = qs.order_by(*valid_orders[self.request.GET.get("ordering", "-date")])
else:
qs = qs.order_by('name', 'subevent__date_from', 'pk')
return qs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data() ctx = super().get_context_data()
ctx['filter_form'] = self.filter_form
qa = QuotaAvailability() qa = QuotaAvailability()
qa.queue(*ctx['quotas']) qa.queue(*ctx['quotas'])
@@ -884,6 +896,165 @@ class QuotaList(PaginationMixin, ListView):
return ctx return ctx
class QuotaBulkAction(QuotaQueryMixin, EventPermissionRequiredMixin, View):
permission = 'event.items:write'
@transaction.atomic
def post(self, request, *args, **kwargs):
if request.POST.get('action') == 'delete':
return render(request, 'pretixcontrol/items/quota_delete_bulk.html', {
'allowed': self.get_queryset().select_related("subevent"),
})
elif request.POST.get('action') == 'delete_confirm':
log_entries = []
to_delete = []
for obj in self.get_queryset():
log_entries.append(obj.log_action('pretix.event.quota.deleted', user=self.request.user, save=False))
to_delete.append(obj.pk)
if to_delete:
LogEntry.bulk_create_and_postprocess(log_entries)
Quota.objects.filter(pk__in=to_delete).delete()
messages.success(request, _('The selected quotas have been deleted or disabled.'))
return redirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse('control:event.items.quotas', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
class QuotaBulkUpdateView(QuotaQueryMixin, EventPermissionRequiredMixin, FormView):
template_name = 'pretixcontrol/items/quota_bulk_edit.html'
permission = 'event.items:write'
context_object_name = 'quota'
form_class = QuotaBulkEditForm
def get_queryset(self):
return super().get_queryset().prefetch_related(None).order_by()
def get(self, request, *args, **kwargs):
return HttpResponse(status=405)
@cached_property
def is_submitted(self):
# Usually, django considers a form "bound" / "submitted" on every POST request. However, this view is always
# called with POST method, even if just to pass the selection of objects to work on, so we want to modify
# that behaviour
return '_bulk' in self.request.POST
def get_form_kwargs(self):
initial = {}
mixed_values = set()
qs = self.get_queryset().annotate(
items_list=Subquery(
Quota.items.through.objects.filter(
quota_id=OuterRef('pk'),
item__variations__isnull=True,
).order_by().values('quota_id').annotate(
g=GroupConcat('item_id', separator=',', ordered=True)
).values('g')
),
vars_list=Subquery(
Quota.variations.through.objects.filter(
quota_id=OuterRef('pk')
).order_by().values('quota_id').annotate(
g=GroupConcat(
Concat(
Cast(F('itemvariation__item_id'), output_field=models.TextField()),
Value('-', output_field=models.TextField()),
Cast(F('itemvariation_id'), output_field=models.TextField()),
),
separator=',',
ordered=True
)
).values('g')
),
)
fields = {
'name': 'name',
'size': 'size',
'subevent': 'subevent',
'close_when_sold_out': 'close_when_sold_out',
'release_after_exit': 'release_after_exit',
'ignore_for_event_availability': 'ignore_for_event_availability',
}
for k, f in fields.items():
existing_values = list(qs.order_by(f).values(f).annotate(c=Count('*')))
if len(existing_values) == 1:
initial[k] = existing_values[0][f]
elif len(existing_values) > 1:
mixed_values.add(k)
initial[k] = None
item_values = list(qs.order_by("items_list").values("items_list").annotate(c=Count('*')))
var_values = list(qs.order_by("vars_list").values("vars_list").annotate(c=Count('*')))
if len(item_values) > 1 or len(var_values) > 1:
mixed_values.add("itemvars")
else:
initial["itemvars"] = [iv for iv in (item_values[0]["items_list"] or "").split(",") + (var_values[0]["vars_list"] or "").split(",") if iv]
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs['prefix'] = 'bulkedit'
kwargs['initial'] = initial
kwargs['queryset'] = self.get_queryset()
kwargs['mixed_values'] = mixed_values
if not self.is_submitted:
kwargs['data'] = None
kwargs['files'] = None
return kwargs
def get_success_url(self):
return reverse('control:event.items.quotas', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
@transaction.atomic()
def form_valid(self, form):
log_entries = []
# Main form
form.save()
data = {
k: v
for k, v in form.cleaned_data.items()
if k in form.changed_data
}
data['_raw_bulk_data'] = self.request.POST.dict()
for obj in self.get_queryset():
log_entries.append(
obj.log_action('pretix.event.quota.changed', data=data, user=self.request.user, save=False)
)
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['quotas'] = self.get_queryset()
ctx['bulk_selected'] = self.request.POST.getlist("_bulk")
return ctx
def post(self, request, *args, **kwargs):
form = self.get_form()
is_valid = (
self.is_submitted and
form.is_valid()
)
if is_valid:
return self.form_valid(form)
else:
if self.is_submitted:
messages.error(self.request, _('We could not save your changes. See below for details.'))
return self.form_invalid(form)
class QuotaCreate(EventPermissionRequiredMixin, CreateView): class QuotaCreate(EventPermissionRequiredMixin, CreateView):
model = Quota model = Quota
form_class = QuotaForm form_class = QuotaForm
+16 -20
View File
@@ -102,7 +102,7 @@ from pretix.base.models.organizer import (
from pretix.base.payment import PaymentException from pretix.base.payment import PaymentException
from pretix.base.plugins import ( from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_ORGANIZER, plugin_is_available,
) )
from pretix.base.services.export import ( from pretix.base.services.export import (
init_organizer_exporters, multiexport, scheduled_organizer_export, init_organizer_exporters, multiexport, scheduled_organizer_export,
@@ -597,6 +597,13 @@ class OrganizerCreate(CreateView):
}) })
def available_plugins(organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView, SingleObjectMixin): class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView, SingleObjectMixin):
model = Organizer model = Organizer
context_object_name = 'organizer' context_object_name = 'organizer'
@@ -606,12 +613,6 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
def get_object(self, queryset=None) -> Organizer: def get_object(self, queryset=None) -> Organizer:
return self.request.organizer return self.request.organizer
def available_plugins(self, organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def prepare_links(self, pluginmeta, key): def prepare_links(self, pluginmeta, key):
links = getattr(pluginmeta, key, []) links = getattr(pluginmeta, key, [])
try: try:
@@ -637,7 +638,7 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
from pretix.base.plugins import CATEGORY_LABELS, CATEGORY_ORDER from pretix.base.plugins import CATEGORY_LABELS, CATEGORY_ORDER
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
plugins = list(self.available_plugins(self.object)) plugins = list(available_plugins(self.object))
active_counter = Counter() active_counter = Counter()
events_total = 0 events_total = 0
@@ -685,7 +686,7 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
self.object = self.get_object() self.object = self.get_object()
plugins_available = { plugins_available = {
p.module: p for p in self.available_plugins(self.object) p.module: p for p in available_plugins(self.object)
} }
choose_events_next = False choose_events_next = False
with transaction.atomic(): with transaction.atomic():
@@ -786,12 +787,6 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
} }
return kwargs return kwargs
def available_plugins(self, organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data( return super().get_context_data(
plugin=self.plugin, plugin=self.plugin,
@@ -799,12 +794,10 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
) )
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
plugins_available = { try:
p.module: p for p in self.available_plugins(self.request.organizer) self.plugin = next(p for p in available_plugins(self.request.organizer) if p.module == kwargs["plugin"])
} except StopIteration:
if kwargs["plugin"] not in plugins_available:
raise Http404(_("Unknown plugin.")) raise Http404(_("Unknown plugin."))
self.plugin = plugins_available[kwargs["plugin"]]
level = getattr(self.plugin, "level", PLUGIN_LEVEL_EVENT) level = getattr(self.plugin, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_ORGANIZER: if level == PLUGIN_LEVEL_ORGANIZER:
raise Http404(_("This plugin can only be enabled for the entire organizer account.")) raise Http404(_("This plugin can only be enabled for the entire organizer account."))
@@ -835,6 +828,9 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
logentries_to_save = [] logentries_to_save = []
for e in self.request.organizer.events.filter(pk__in=events_to_enable): for e in self.request.organizer.events.filter(pk__in=events_to_enable):
if not plugin_is_available(self.plugin, organizer=self.request.organizer, event=e):
messages.warning(self.request, _("This plugin cannot be activated for event {}.").format(e.name))
continue
logentries_to_save.append( logentries_to_save.append(
e.log_action('pretix.event.plugins.enabled', user=self.request.user, data={'plugin': self.plugin.module}, save=False) e.log_action('pretix.event.plugins.enabled', user=self.request.user, data={'plugin': self.plugin.module}, save=False)
) )
-63
View File
@@ -1,63 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import os
import re
import shlex
from compressor.exceptions import FilterError
from compressor.filters import CompilerFilter
from django.conf import settings
class VueCompiler(CompilerFilter):
# Based on work (c) Laura Klünder in https://github.com/codingcatgirl/django-vue-rollup
# Released under Apache License 2.0
def __init__(self, content, attrs, **kwargs):
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'static', 'npm_dir')
node_path = os.path.join(settings.STATIC_ROOT, 'node_prefix', 'node_modules')
self.rollup_bin = os.path.join(node_path, 'rollup', 'dist', 'bin', 'rollup')
rollup_config = os.path.join(config_dir, 'rollup.config.js')
if not os.path.exists(self.rollup_bin) and not settings.DEBUG:
raise FilterError("Rollup not installed or pretix not built properly, please run 'make npminstall' in source root.")
command = (
' '.join((
'NODE_PATH=' + shlex.quote(node_path),
shlex.quote(self.rollup_bin),
'-c',
shlex.quote(rollup_config))
) +
' --input {infile} -n {export_name} --file {outfile}'
)
super().__init__(content, command=command, **kwargs)
def input(self, **kwargs):
if self.filename is None:
raise FilterError('VueCompiler can only compile files, not inline code.')
if not os.path.exists(self.rollup_bin):
raise FilterError("Rollup not installed, please run 'make npminstall' in source root.")
self.options += (('export_name', re.sub(
r'^([a-z])|[^a-z0-9A-Z]+([a-zA-Z0-9])?',
lambda s: s.group(0)[-1].upper(),
os.path.basename(self.filename).split('.')[0]
)),)
return super().input(**kwargs)
+6 -1
View File
@@ -117,12 +117,17 @@ class GroupConcat(Aggregate):
template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s' ORDER BY %(field)s::text ASC)" template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s' ORDER BY %(field)s::text ASC)"
else: else:
template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s')" template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s')"
return super().as_sql(
template, params = super().as_sql(
compiler, connection, compiler, connection,
function='string_agg', function='string_agg',
template=template, template=template,
**extra_context, **extra_context,
) )
if self.ordered:
# ordered statement requires field parameters twice
params = params + params
return template, params
class ReplicaRouter: class ReplicaRouter:
+13 -18
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n" "POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2025-09-29 07:39+0000\n" "PO-Revision-Date: 2026-04-30 18:00+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n" "Last-Translator: Paul Berschick <paul@plainschwarz.com>\n"
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix/" "Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix/"
"ca/>\n" "ca/>\n"
"Language: ca\n" "Language: ca\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.13.3\n" "X-Generator: Weblate 5.17\n"
#: pretix/_base_settings.py:87 #: pretix/_base_settings.py:87
msgid "English" msgid "English"
@@ -14132,13 +14132,13 @@ msgid "Contact:"
msgstr "Adreça de contacte" msgstr "Adreça de contacte"
#: pretix/base/templates/pretixbase/email/order_details.html:54 #: pretix/base/templates/pretixbase/email/order_details.html:54
#, fuzzy, python-format #, python-format
#| msgid ""
#| "You are receiving this email because you placed an order for {event}."
msgid "" msgid ""
"You are receiving this email because you placed an order for " "You are receiving this email because you placed an order for "
"<strong>%(event)s</strong>." "<strong>%(event)s</strong>."
msgstr "Heu rebut aquest correu perquè heu fet una comanda per {event}." msgstr ""
"Has rebut aquest correu perquè has fet una inscripció a <strong>%(event)s</"
"strong>."
#: pretix/base/templates/pretixbase/email/order_details.html:93 #: pretix/base/templates/pretixbase/email/order_details.html:93
#: pretix/control/templates/pretixcontrol/organizers/customer.html:23 #: pretix/control/templates/pretixcontrol/organizers/customer.html:23
@@ -24790,7 +24790,7 @@ msgstr "Data d'inici de l'esdeveniment"
#: pretix/control/templates/pretixcontrol/order/index.html:465 #: pretix/control/templates/pretixcontrol/order/index.html:465
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html:55 #: pretix/presale/templates/pretixpresale/event/fragment_cart.html:55
msgid "Voucher code used:" msgid "Voucher code used:"
msgstr "" msgstr "Codi de descompte utilitzat:"
#: pretix/control/templates/pretixcontrol/order/index.html:467 #: pretix/control/templates/pretixcontrol/order/index.html:467
#, fuzzy, python-format #, fuzzy, python-format
@@ -35206,11 +35206,9 @@ msgstr ""
#: pretix/presale/forms/renderers.py:66 #: pretix/presale/forms/renderers.py:66
#: pretix/presale/templates/pretixpresale/event/fragment_voucher_form.html:14 #: pretix/presale/templates/pretixpresale/event/fragment_voucher_form.html:14
#, fuzzy
#| msgid "expired"
msgctxt "form" msgctxt "form"
msgid "required" msgid "required"
msgstr "expirat" msgstr "obligatori"
#: pretix/presale/ical.py:87 pretix/presale/ical.py:146 #: pretix/presale/ical.py:87 pretix/presale/ical.py:146
#: pretix/presale/ical.py:182 #: pretix/presale/ical.py:182
@@ -35399,10 +35397,8 @@ msgid "We're now trying to book these add-ons for you!"
msgstr "" msgstr ""
#: pretix/presale/templates/pretixpresale/event/checkout_addons.html:28 #: pretix/presale/templates/pretixpresale/event/checkout_addons.html:28
#, fuzzy
#| msgid "Meta information"
msgid "Additional options for" msgid "Additional options for"
msgstr "Informació meta" msgstr "Opcions addicionals per a"
#: pretix/presale/templates/pretixpresale/event/checkout_addons.html:60 #: pretix/presale/templates/pretixpresale/event/checkout_addons.html:60
#, fuzzy #, fuzzy
@@ -35754,12 +35750,11 @@ msgstr[0] "Heu de triar només una opció d'aquesta categoria."
msgstr[1] "Heu de triar %(min_count)s d'aquesta categoria." msgstr[1] "Heu de triar %(min_count)s d'aquesta categoria."
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:26 #: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:26
#, fuzzy, python-format #, python-format
#| msgid "You can choose up to %(max_count)s options from this category."
msgid "You can choose one option from this category." msgid "You can choose one option from this category."
msgid_plural "You can choose up to %(max_count)s options from this category." msgid_plural "You can choose up to %(max_count)s options from this category."
msgstr[0] "Podeu triar fins a %(max_count)s opcions d'aquesta categoria." msgstr[0] "Pots triar una opció d'aquesta categoria."
msgstr[1] "Podeu triar fins a %(max_count)s opcions d'aquesta categoria." msgstr[1] "Pots escollir fins a %(max_count)s opcions daquesta categoria."
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:34 #: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:34
#, python-format #, python-format
+5 -6
View File
@@ -4,10 +4,10 @@ msgstr ""
"Project-Id-Version: 1\n" "Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n" "POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-04-23 17:00+0000\n" "PO-Revision-Date: 2026-05-04 14:19+0000\n"
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n" "Last-Translator: Mie Frydensbjerg <mif@aarhus.dk>\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix/da/" "Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix/"
">\n" "da/>\n"
"Language: da\n" "Language: da\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@@ -36278,9 +36278,8 @@ msgstr "Fortsæt"
#: pretix/presale/templates/pretixpresale/fragment_event_list_status.html:14 #: pretix/presale/templates/pretixpresale/fragment_event_list_status.html:14
#: pretix/presale/templates/pretixpresale/fragment_week_calendar.html:58 #: pretix/presale/templates/pretixpresale/fragment_week_calendar.html:58
#: pretix/presale/views/widget.py:441 #: pretix/presale/views/widget.py:441
#, fuzzy
msgid "Few tickets left" msgid "Few tickets left"
msgstr "PDF-billet" msgstr "billetter tilbage"
#: pretix/presale/templates/pretixpresale/fragment_calendar.html:92 #: pretix/presale/templates/pretixpresale/fragment_calendar.html:92
#: pretix/presale/templates/pretixpresale/fragment_day_calendar.html:97 #: pretix/presale/templates/pretixpresale/fragment_day_calendar.html:97
+43 -42
View File
@@ -5,8 +5,8 @@ msgstr ""
"Project-Id-Version: 1\n" "Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n" "POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-04-28 09:22+0000\n" "PO-Revision-Date: 2026-05-06 00:00+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n" "Last-Translator: Daniel Musketa <daniel@musketa.de>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/" "Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/"
"de/>\n" "de/>\n"
"Language: de\n" "Language: de\n"
@@ -14,7 +14,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.17\n" "X-Generator: Weblate 5.17.1\n"
"X-Poedit-Bookmarks: -1,-1,904,-1,-1,-1,-1,-1,-1,-1\n" "X-Poedit-Bookmarks: -1,-1,904,-1,-1,-1,-1,-1,-1,-1\n"
#: pretix/_base_settings.py:87 #: pretix/_base_settings.py:87
@@ -3072,7 +3072,7 @@ msgstr "Wertgutscheine"
#: pretix/base/exporters/orderlist.py:1367 #: pretix/base/exporters/orderlist.py:1367
msgid "Download a spreadsheet of all gift cards including their current value." msgid "Download a spreadsheet of all gift cards including their current value."
msgstr "" msgstr ""
"Tabelle (Excel oder CSV) mit allen Wertgutscheinen und deren aktuellen Wert." "Tabelle (Excel oder CSV) mit allen Wertgutscheinen und deren aktuellem Wert."
#: pretix/base/exporters/orderlist.py:1378 #: pretix/base/exporters/orderlist.py:1378
msgid "Show value at" msgid "Show value at"
@@ -4971,7 +4971,7 @@ msgstr ""
"Sollte kurz sein und darf nur Kleinbuchstaben, Zahlen, Bindestriche und " "Sollte kurz sein und darf nur Kleinbuchstaben, Zahlen, Bindestriche und "
"Punkte enthalten. Muss unter Ihren Veranstaltungen einmalig sein. Wir " "Punkte enthalten. Muss unter Ihren Veranstaltungen einmalig sein. Wir "
"empfehlen eine Abkürzung oder ein Datum mit unter 10 Zeichen, das man sich " "empfehlen eine Abkürzung oder ein Datum mit unter 10 Zeichen, das man sich "
"gut merken kann. Sie können jedoch auch einen zufälligen Wert verwendet. " "gut merken kann. Sie können jedoch auch einen zufälligen Wert verwenden. "
"Dies wird z.B. in Links, Bestellnummern, Rechnungsnummern und " "Dies wird z.B. in Links, Bestellnummern, Rechnungsnummern und "
"Verwendungszwecken für Banküberweisungen benutzt." "Verwendungszwecken für Banküberweisungen benutzt."
@@ -7515,7 +7515,7 @@ msgid ""
"You are already on this waiting list! We will notify you as soon as we have " "You are already on this waiting list! We will notify you as soon as we have "
"a ticket available for you." "a ticket available for you."
msgstr "" msgstr ""
"Sie sind bereits auf der Warteliste! Wir benachrichtigen Sie sobald wir ein " "Sie sind bereits auf der Warteliste! Wir benachrichtigen Sie, sobald wir ein "
"verfügbares Ticket für Sie haben." "verfügbares Ticket für Sie haben."
#: pretix/base/notifications.py:192 pretix/control/navigation.py:205 #: pretix/base/notifications.py:192 pretix/control/navigation.py:205
@@ -8671,8 +8671,8 @@ msgid ""
"offline scanning please refer to documentation or support for details)" "offline scanning please refer to documentation or support for details)"
msgstr "" msgstr ""
"pretix Signaturverfahren 1 (für sehr große Veranstaltungen, ändert die " "pretix Signaturverfahren 1 (für sehr große Veranstaltungen, ändert die "
"Funktionsweise des Offline-Modus, bitte informiere dich in der Dokumentation " "Funktionsweise des Offline-Modus, bitte informieren Sie sich in der "
"oder beim Support)" "Dokumentation oder beim Support)"
#: pretix/base/services/cancelevent.py:265 #: pretix/base/services/cancelevent.py:265
#: pretix/base/services/cancelevent.py:351 #: pretix/base/services/cancelevent.py:351
@@ -9579,8 +9579,8 @@ msgid ""
"changes are still accurate and try again." "changes are still accurate and try again."
msgstr "" msgstr ""
"Diese Bestellung wurde zeitgleich von einem anderen Benutzer bearbeitet. " "Diese Bestellung wurde zeitgleich von einem anderen Benutzer bearbeitet. "
"Bitte prüfe, ob deine Änderungen immer noch zutreffend sind und probiere es " "Bitte prüfen Sie, ob Ihre Änderungen immer noch zutreffend sind und "
"erneut." "probieren Sie es erneut."
#: pretix/base/services/orders.py:150 #: pretix/base/services/orders.py:150
msgid "Your cart is empty." msgid "Your cart is empty."
@@ -11363,7 +11363,7 @@ msgid ""
"If your event series has more than 50 dates in the future, only the month or " "If your event series has more than 50 dates in the future, only the month or "
"week calendar can be used." "week calendar can be used."
msgstr "" msgstr ""
"Wenn deine Veranstaltungsreihe mehr als 50 zukünftige Termine hat, kann nur " "Wenn Ihre Veranstaltungsreihe mehr als 50 zukünftige Termine hat, kann nur "
"der Monats- oder Wochenkalender verwendet werden." "der Monats- oder Wochenkalender verwendet werden."
#: pretix/base/settings.py:1892 #: pretix/base/settings.py:1892
@@ -11636,8 +11636,9 @@ msgid ""
"set this to e.g. 10, they will only be able to choose values in increments " "set this to e.g. 10, they will only be able to choose values in increments "
"of 10." "of 10."
msgstr "" msgstr ""
"Standardmäßig können Kunden auf einen beliebigen Betrag verzichten. Wenn du " "Standardmäßig können Kunden auf einen beliebigen Betrag verzichten. Wenn Sie "
"diesen Wert z.B. auf 10 setzt, sind nur noch Werte im Abstand von 10 erlaubt." "diesen Wert z.B. auf 10 setzen, sind nur noch Werte im Abstand von 10 "
"erlaubt."
#: pretix/base/settings.py:2193 #: pretix/base/settings.py:2193
msgid "" msgid ""
@@ -17304,7 +17305,7 @@ msgstr ""
#: pretix/control/forms/vouchers.py:448 #: pretix/control/forms/vouchers.py:448
msgid "You need to specify as many seats as voucher codes." msgid "You need to specify as many seats as voucher codes."
msgstr "Sie müssen genau so viele Sitze angeben, wie du Gutscheine erzeugst." msgstr "Sie müssen genau so viele Sitze angeben, wie Sie Gutscheine erzeugen."
#: pretix/control/forms/waitinglist.py:39 #: pretix/control/forms/waitinglist.py:39
msgid "Select a valid choice." msgid "Select a valid choice."
@@ -20819,7 +20820,7 @@ msgid ""
"event and only retain the financial information such as the number and type " "event and only retain the financial information such as the number and type "
"of tickets sold." "of tickets sold."
msgstr "" msgstr ""
"Sie können personenbezogene Daten wie Namen und E-Mail-Adressen aus deiner " "Sie können personenbezogene Daten wie Namen und E-Mail-Adressen aus Ihrer "
"Veranstaltung entfernen und nur finanzielle Infos wie die Anzahl und Art der " "Veranstaltung entfernen und nur finanzielle Infos wie die Anzahl und Art der "
"verkauften Tickets aufbewahren." "verkauften Tickets aufbewahren."
@@ -23019,7 +23020,7 @@ msgstr ""
"Wenn Sie eine Gültigkeit in Tagen, Monaten oder Jahren angeben, endet die " "Wenn Sie eine Gültigkeit in Tagen, Monaten oder Jahren angeben, endet die "
"Gültigkeit immer am Ende eines vollen Tages (Mitternacht), plus der " "Gültigkeit immer am Ende eines vollen Tages (Mitternacht), plus der "
"ausgewählten Anzahl an Minuten und Stunden. Der Starttermin wird in die " "ausgewählten Anzahl an Minuten und Stunden. Der Starttermin wird in die "
"Berechnung eingeschlossen, d.h. wenn Sie \"1 Tag\" auswählen ist das Ticket " "Berechnung eingeschlossen, d.h. wenn Sie \"1 Tag\" auswählen, ist das Ticket "
"gültig bis zum Ende des Tages, an dem die Gültigkeit beginnt." "gültig bis zum Ende des Tages, an dem die Gültigkeit beginnt."
#: pretix/control/templates/pretixcontrol/item/index.html:254 #: pretix/control/templates/pretixcontrol/item/index.html:254
@@ -23452,7 +23453,7 @@ msgid ""
"statistical data on customers who previously selected this option, and when " "statistical data on customers who previously selected this option, and when "
"such customers edit their answers, they need to select a different option." "such customers edit their answers, they need to select a different option."
msgstr "" msgstr ""
"Wenn du eine Antwortoption löschst, kannst du anschließend keine " "Wenn Sie eine Antwortoption löschen, können Sie anschließend keine "
"statistischen Daten mehr zur Verwendung dieser Option abrufen und wenn " "statistischen Daten mehr zur Verwendung dieser Option abrufen und wenn "
"Kunden, die diese Option gewählt haben, ihre Daten bearbeiten möchten, muss " "Kunden, die diese Option gewählt haben, ihre Daten bearbeiten möchten, muss "
"eine andere Option gewählt werden." "eine andere Option gewählt werden."
@@ -25961,8 +25962,8 @@ msgid ""
"Instead of an URL, you can also configure a text that will be shown within " "Instead of an URL, you can also configure a text that will be shown within "
"pretix. This will be ignored if a URL is configured." "pretix. This will be ignored if a URL is configured."
msgstr "" msgstr ""
"Statt einer URL kannst du auch einen Text eingeben, der von pretix angezeigt " "Statt einer URL können Sie auch einen Text eingeben, der von pretix "
"wird. Dies wird ignoriert, wenn eine URL konfiguriert wurde." "angezeigt wird. Dies wird ignoriert, wenn eine URL konfiguriert wurde."
#: pretix/control/templates/pretixcontrol/organizers/edit.html:229 #: pretix/control/templates/pretixcontrol/organizers/edit.html:229
msgid "Barcode media" msgid "Barcode media"
@@ -26128,7 +26129,7 @@ msgid ""
"This feature allows you to configure acceptance of gift cards across " "This feature allows you to configure acceptance of gift cards across "
"multiple organizer accounts." "multiple organizer accounts."
msgstr "" msgstr ""
"Diese Funktion erlaubt dir, die Akzeptanz von Wertgutscheinen über mehrere " "Diese Funktion erlaubt Ihnen, die Akzeptanz von Wertgutscheinen über mehrere "
"Veranstalterkonten hinweg zu konfigurieren." "Veranstalterkonten hinweg zu konfigurieren."
#: pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_list.html:18 #: pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_list.html:18
@@ -26168,7 +26169,7 @@ msgstr "Ablehnen"
#: pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_list.html:84 #: pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_list.html:84
msgid "Other organizers accepting gift cards from you" msgid "Other organizers accepting gift cards from you"
msgstr "Andere Veranstalter, die deine Wertgutscheine akzeptieren" msgstr "Andere Veranstalter, die Ihre Wertgutscheine akzeptieren"
#: pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_list.html:87 #: pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_list.html:87
msgid "" msgid ""
@@ -27156,9 +27157,9 @@ msgid ""
"use pretixPRINT version %(print_version)s (or newer) or pretixSCAN Desktop " "use pretixPRINT version %(print_version)s (or newer) or pretixSCAN Desktop "
"version %(scan_version)s (or newer)." "version %(scan_version)s (or newer)."
msgstr "" msgstr ""
"Dieses Layout verwendet neue Funktionen. Wenn du mit einem Gerät druckst, " "Dieses Layout verwendet neue Funktionen. Wenn Sie mit einem Gerät drucken, "
"stelle sicher, dass pretixPRINT-Version %(print_version)s (oder neuer) oder " "stellen Sie sicher, dass pretixPRINT-Version %(print_version)s (oder neuer) "
"pretixSCAN Desktop Version %(scan_version)s (oder neuer) im Einsatz ist." "oder pretixSCAN Desktop Version %(scan_version)s (oder neuer) im Einsatz ist."
#: pretix/control/templates/pretixcontrol/pdf/placeholders.html:16 #: pretix/control/templates/pretixcontrol/pdf/placeholders.html:16
msgid "Available placeholders" msgid "Available placeholders"
@@ -27234,8 +27235,8 @@ msgid ""
"might be required to keep some of this data on file. You can therefore " "might be required to keep some of this data on file. You can therefore "
"download the following file and store it in a safe place:" "download the following file and store it in a safe place:"
msgstr "" msgstr ""
"Sie sind dabei, Daten unwiderruflich vom Server zu löschen, auch wenn du " "Sie sind dabei, Daten unwiderruflich vom Server zu löschen, auch wenn Sie "
"manche dieser Daten ggf. aus gesetzlichen Gründen noch aufheben musst. Wir " "manche dieser Daten ggf. aus gesetzlichen Gründen noch aufheben müssen. Wir "
"empfehlen daher, die folgende Datei herunterzuladen und sie sicher " "empfehlen daher, die folgende Datei herunterzuladen und sie sicher "
"aufzubewahren:" "aufzubewahren:"
@@ -27284,7 +27285,7 @@ msgid ""
"while to complete. We will inform you via email once it has been completed." "while to complete. We will inform you via email once it has been completed."
msgstr "" msgstr ""
"Abhängig von der Datenmenge in der Veranstaltung kann der folgende Schritt " "Abhängig von der Datenmenge in der Veranstaltung kann der folgende Schritt "
"eine Weile dauern. Wir informieren dich per E-Mail, sobald er abgeschlossen " "eine Weile dauern. Wir informieren Sie per E-Mail, sobald er abgeschlossen "
"ist." "ist."
#: pretix/control/templates/pretixcontrol/shredder/index.html:11 #: pretix/control/templates/pretixcontrol/shredder/index.html:11
@@ -32188,8 +32189,8 @@ msgid ""
msgstr "" msgstr ""
"Während die meisten Zahlungsmethoden keinen grundlosen Widerruf vorsehen, " "Während die meisten Zahlungsmethoden keinen grundlosen Widerruf vorsehen, "
"können SEPA-Lastschriften per Mausklick zurückgerufen werden. Aus diesem " "können SEPA-Lastschriften per Mausklick zurückgerufen werden. Aus diesem "
"Grund - und abhängig von der Art Ihrer Veranstaltung - kann es notwendig " "Grund und abhängig von der Art Ihrer Veranstaltung kann es notwendig "
"sein SEPA-Lastschriften nicht anzubieten und damit das Risiko von " "sein, SEPA-Lastschriften nicht anzubieten und damit das Risiko von "
"kostspieligen Rücklastschriften zu vermeiden." "kostspieligen Rücklastschriften zu vermeiden."
#: pretix/plugins/paypal2/payment.py:182 #: pretix/plugins/paypal2/payment.py:182
@@ -33624,8 +33625,8 @@ msgid ""
"Some payment methods might need to be enabled in the settings of your Stripe " "Some payment methods might need to be enabled in the settings of your Stripe "
"account before they work properly." "account before they work properly."
msgstr "" msgstr ""
"Manche Zahlungsmethoden müssen in den Einstellungen deines Stripe-Kontos " "Manche Zahlungsmethoden müssen in den Einstellungen Ihres Stripe-Kontos "
"aktiviert werden bevor sie funktionieren." "aktiviert werden, bevor sie funktionieren."
#: pretix/plugins/stripe/payment.py:369 pretix/plugins/stripe/payment.py:1597 #: pretix/plugins/stripe/payment.py:369 pretix/plugins/stripe/payment.py:1597
msgid "Alipay" msgid "Alipay"
@@ -33644,8 +33645,8 @@ msgid ""
"Some payment methods might need to be enabled in the settings of your Stripe " "Some payment methods might need to be enabled in the settings of your Stripe "
"account before work properly." "account before work properly."
msgstr "" msgstr ""
"Manche Zahlungsmethoden müssen in den Einstellungen deines Stripe-Kontos " "Manche Zahlungsmethoden müssen in den Einstellungen Ihres Stripe-Kontos "
"aktiviert werden bevor sie funktionieren." "aktiviert werden, bevor sie funktionieren."
#: pretix/plugins/stripe/payment.py:391 #: pretix/plugins/stripe/payment.py:391
msgid "" msgid ""
@@ -35338,7 +35339,7 @@ msgstr "Diese Zahlungsmethode unterstützt den Testmodus nicht."
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html:113 #: pretix/presale/templates/pretixpresale/event/checkout_payment.html:113
msgid "If you continue, actual money might be transferred." msgid "If you continue, actual money might be transferred."
msgstr "Wenn du fortfährst, wird möglicherweise echtes Geld transferiert." msgstr "Wenn Sie fortfahren, wird möglicherweise echtes Geld transferiert."
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html:124 #: pretix/presale/templates/pretixpresale/event/checkout_payment.html:124
msgid "There are no payment providers enabled." msgid "There are no payment providers enabled."
@@ -35790,7 +35791,7 @@ msgid ""
msgstr "" msgstr ""
"Da Sie eine Firmenadresse eingegeben haben, wurde Ihr Preis aus dem Preis " "Da Sie eine Firmenadresse eingegeben haben, wurde Ihr Preis aus dem Preis "
"ohne Umsatzsteuer neu berechnet. Durch geänderte Rundung hat sich der " "ohne Umsatzsteuer neu berechnet. Durch geänderte Rundung hat sich der "
"Endpreis deiner Buchung minimal verändert." "Endpreis Ihrer Buchung minimal verändert."
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html:516 #: pretix/presale/templates/pretixpresale/event/fragment_cart.html:516
#, python-format #, python-format
@@ -36025,7 +36026,7 @@ msgid ""
"the order clicked the link in the email they received to confirm the email " "the order clicked the link in the email they received to confirm the email "
"address is valid." "address is valid."
msgstr "" msgstr ""
"Sie können Ihre Tickets hier herunterladen sobald die Person, die die " "Sie können Ihre Tickets hier herunterladen, sobald die Person, die die "
"Bestellung aufgegeben hat, einen Link in der an sie geschickten E-Mail " "Bestellung aufgegeben hat, einen Link in der an sie geschickten E-Mail "
"geklickt hat." "geklickt hat."
@@ -37011,10 +37012,10 @@ msgid ""
"need the ticket any more, please be so kind and remove your ticket from the " "need the ticket any more, please be so kind and remove your ticket from the "
"list so we can pass it on to the next person waiting as quickly as possible!" "list so we can pass it on to the next person waiting as quickly as possible!"
msgstr "" msgstr ""
"Sie wurden von unserer Warteliste ausgewählt um ein Ticket zu erhalten. Wenn " "Sie wurden von unserer Warteliste ausgewählt, um ein Ticket zu erhalten. "
"Sie das Ticket nicht mehr brauchen, helfen Sie uns indem Sie sich von der " "Wenn Sie das Ticket nicht mehr brauchen, helfen Sie uns, indem Sie sich von "
"Warteliste entfernen, sodass wir das Ticket schnellstmöglich an die nächste " "der Warteliste entfernen, sodass wir das Ticket schnellstmöglich an die "
"wartende Person weitergeben können." "nächste wartende Person weitergeben können."
#: pretix/presale/templates/pretixpresale/event/waitinglist_remove.html:16 #: pretix/presale/templates/pretixpresale/event/waitinglist_remove.html:16
msgctxt "waitinglist" msgctxt "waitinglist"
@@ -37754,8 +37755,8 @@ msgstr ""
#: pretix/presale/views/order.py:1198 #: pretix/presale/views/order.py:1198
msgid "Please click the link we sent you via email to download your tickets." msgid "Please click the link we sent you via email to download your tickets."
msgstr "" msgstr ""
"Bitte klicke den Link, den wir dir per E-Mail geschickt haben, um deine " "Bitte klicken Sie den Link, den wir Ihnen per E-Mail geschickt haben, um "
"Tickets herunterzuladen." "Ihre Tickets herunterzuladen."
#: pretix/presale/views/order.py:1689 #: pretix/presale/views/order.py:1689
#, python-brace-format #, python-brace-format
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: 1\n" "Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n" "POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-04-28 09:22+0000\n" "PO-Revision-Date: 2026-05-04 10:44+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n" "Last-Translator: Daniel Musketa <daniel@musketa.de>\n"
"Language-Team: German (informal) <https://translate.pretix.eu/projects/" "Language-Team: German (informal) <https://translate.pretix.eu/projects/"
"pretix/pretix/de_Informal/>\n" "pretix/pretix/de_Informal/>\n"
"Language: de_Informal\n" "Language: de_Informal\n"
@@ -829,7 +829,7 @@ msgid ""
"Field \"{field_name}\" requires {required_input}, but only got " "Field \"{field_name}\" requires {required_input}, but only got "
"{available_inputs}. Please check your {provider_name} settings." "{available_inputs}. Please check your {provider_name} settings."
msgstr "" msgstr ""
"Feld \"{field_name}\" erfordert {required_input}, aber hat nur " "Feld \"{field_name}\" erfordert {required_input}, aber nur "
"{available_inputs} sind verfügbar. Bitte prüfe die Einstellungen für " "{available_inputs} sind verfügbar. Bitte prüfe die Einstellungen für "
"{provider_name}." "{provider_name}."
@@ -1529,8 +1529,8 @@ msgid ""
"Only include invoices issued in this time frame. Note that the invoice date " "Only include invoices issued in this time frame. Note that the invoice date "
"does not always correspond to the order or payment date." "does not always correspond to the order or payment date."
msgstr "" msgstr ""
"Nur Rechnungen, die in diesem Zeitraum wurden. Achtung: Das Rechnungsdatum " "Nur Rechnungen, die in diesem Zeitraum ausgestellt wurden. Achtung: Das "
"korrespondiert nicht zwingend zum Bestell- oder Zahlungsdatum." "Rechnungsdatum korrespondiert nicht zwingend zum Bestell- oder Zahlungsdatum."
#: pretix/base/exporters/events.py:47 #: pretix/base/exporters/events.py:47
msgid "Event data" msgid "Event data"
@@ -3540,7 +3540,7 @@ msgstr "Das eingegebene aktuelle Passwort war nicht korrekt."
#: pretix/base/forms/user.py:95 #: pretix/base/forms/user.py:95
msgid "Please choose a password different to your current one." msgid "Please choose a password different to your current one."
msgstr "Bitte wählen ein anderes Passwort als das derzeitige." msgstr "Bitte wähle ein anderes Passwort als das derzeitige."
#: pretix/base/forms/user.py:105 pretix/presale/forms/customer.py:399 #: pretix/base/forms/user.py:105 pretix/presale/forms/customer.py:399
#: pretix/presale/forms/customer.py:475 #: pretix/presale/forms/customer.py:475
@@ -3579,7 +3579,7 @@ msgid ""
"up. Please note: to use literal \"{\" or \"}\", you need to double them as " "up. Please note: to use literal \"{\" or \"}\", you need to double them as "
"\"{{\" and \"}}\"." "\"{{\" and \"}}\"."
msgstr "" msgstr ""
"Es ist ein Fehler in deine Platzhalter-Syntax. Bitte prüfe, dass die " "Es ist ein Fehler in deiner Platzhalter-Syntax. Bitte prüfe, dass die "
"öffnenden \"{\" und schließenden \"}\" geschweiften Klammern zusammenpassen. " "öffnenden \"{\" und schließenden \"}\" geschweiften Klammern zusammenpassen. "
"Um die geschweiften Klammern \"{\" und \"}\" im erzeugten Text zu verwenden, " "Um die geschweiften Klammern \"{\" und \"}\" im erzeugten Text zu verwenden, "
"müssen sie doppelt gesetzt werden als \"{{\" und \"}}\"." "müssen sie doppelt gesetzt werden als \"{{\" und \"}}\"."
@@ -4217,7 +4217,7 @@ msgstr "Automatisch generieren"
#: pretix/base/modelimport_orders.py:496 #: pretix/base/modelimport_orders.py:496
msgid "You cannot assign a position secret that already exists." msgid "You cannot assign a position secret that already exists."
msgstr "Sie können keinen Ticketcode verwenden, der bereits existiert." msgstr "Du kannst keinen Ticketcode verwenden, der bereits existiert."
#: pretix/base/modelimport_orders.py:528 #: pretix/base/modelimport_orders.py:528
msgid "Please enter a valid language code." msgid "Please enter a valid language code."
@@ -4971,7 +4971,7 @@ msgstr ""
"Sollte kurz sein und darf nur Kleinbuchstaben, Zahlen, Bindestriche und " "Sollte kurz sein und darf nur Kleinbuchstaben, Zahlen, Bindestriche und "
"Punkte enthalten. Muss unter deinen Veranstaltungen einmalig sein. Wir " "Punkte enthalten. Muss unter deinen Veranstaltungen einmalig sein. Wir "
"empfehlen eine Abkürzung oder ein Datum mit unter 10 Zeichen, das man sich " "empfehlen eine Abkürzung oder ein Datum mit unter 10 Zeichen, das man sich "
"gut merken kann. Du kannst jedoch auch einen zufälligen Wert verwendet. Dies " "gut merken kann. Du kannst jedoch auch einen zufälligen Wert verwenden. Dies "
"wird z.B. in Links, Bestellnummern, Rechnungsnummern und Verwendungszwecken " "wird z.B. in Links, Bestellnummern, Rechnungsnummern und Verwendungszwecken "
"für Banküberweisungen benutzt." "für Banküberweisungen benutzt."
@@ -6186,7 +6186,7 @@ msgstr "Minimaler Wert"
#: pretix/base/models/items.py:1747 pretix/base/models/items.py:1750 #: pretix/base/models/items.py:1747 pretix/base/models/items.py:1750
#: pretix/base/models/items.py:1754 #: pretix/base/models/items.py:1754
msgid "Currently not supported in our apps and during check-in" msgid "Currently not supported in our apps and during check-in"
msgstr "Derzeit nicht von unseren Apps und während dem Check-In unterstützt" msgstr "Derzeit nicht von unseren Apps und beim Check-In unterstützt"
#: pretix/base/models/items.py:1737 pretix/base/models/items.py:1743 #: pretix/base/models/items.py:1737 pretix/base/models/items.py:1743
#: pretix/base/models/items.py:1749 #: pretix/base/models/items.py:1749
@@ -6231,7 +6231,7 @@ msgstr "Ungültige Nummerneingabe."
#: pretix/base/models/items.py:1870 pretix/base/models/items.py:1894 #: pretix/base/models/items.py:1870 pretix/base/models/items.py:1894
msgid "Please choose a later date." msgid "Please choose a later date."
msgstr "Bitte wählen Sie ein späteres Datum." msgstr "Bitte wähle ein späteres Datum."
#: pretix/base/models/items.py:1872 pretix/base/models/items.py:1896 #: pretix/base/models/items.py:1872 pretix/base/models/items.py:1896
msgid "Please choose an earlier date." msgid "Please choose an earlier date."
@@ -6880,7 +6880,7 @@ msgid ""
msgstr "" msgstr ""
"Wenn du dies anschaltest, müssen alle Mitglieder entweder Zwei-Faktor-" "Wenn du dies anschaltest, müssen alle Mitglieder entweder Zwei-Faktor-"
"Authentifizierung einrichten oder das Team verlassen. Die Einstellung kann " "Authentifizierung einrichten oder das Team verlassen. Die Einstellung kann "
"ein paar Minuten benötigen um für alle Benutzer aktiv zu werden." "ein paar Minuten benötigen, um für alle Benutzer aktiv zu werden."
#: pretix/base/models/organizer.py:384 #: pretix/base/models/organizer.py:384
msgid "All event permissions" msgid "All event permissions"
@@ -9015,7 +9015,7 @@ msgstr ""
msgid "One of the products you selected can only be bought part of a bundle." msgid "One of the products you selected can only be bought part of a bundle."
msgstr "" msgstr ""
"Eins der ausgewählten Produkte wird nicht einzeln verkauft, sondern nur als " "Eins der ausgewählten Produkte wird nicht einzeln verkauft, sondern nur als "
"Teil fester Produktpaketen." "Teil fester Produktpakete."
#: pretix/base/services/cart.py:218 #: pretix/base/services/cart.py:218
msgid "Please select a valid seat." msgid "Please select a valid seat."
@@ -9912,7 +9912,7 @@ msgid ""
"country is currently not available. We will therefore need to charge you the " "country is currently not available. We will therefore need to charge you the "
"same tax rate as if you did not enter a VAT ID." "same tax rate as if you did not enter a VAT ID."
msgstr "" msgstr ""
"Die USt-ID-Nr. konnte nicht geprüft werden, da der Prüfdienst Ihres Landes " "Die USt-ID-Nr. konnte nicht geprüft werden, da der Prüfdienst deines Landes "
"im Moment nicht verfügbar ist. Wir müssen daher den selben Steuersatz " "im Moment nicht verfügbar ist. Wir müssen daher den selben Steuersatz "
"berechnen, wie wenn keine USt-ID-Nr. eingegeben worden wäre." "berechnen, wie wenn keine USt-ID-Nr. eingegeben worden wäre."
@@ -11535,8 +11535,8 @@ msgid ""
"cancellation fee from the user." "cancellation fee from the user."
msgstr "" msgstr ""
"Betrifft nur ausstehende Zahlungen, für kostenlose Bestellungen wird nie " "Betrifft nur ausstehende Zahlungen, für kostenlose Bestellungen wird nie "
"eine Stornogebühr erhoben. Bitte beachten Sie, dass Sie für das Eintreiben " "eine Stornogebühr erhoben. Bitte beachte, dass du für das Eintreiben der "
"der Stornogebühr selbst verantwortlich sind." "Stornogebühr selbst verantwortlich bist."
#: pretix/base/settings.py:2073 #: pretix/base/settings.py:2073
msgid "Charge payment, shipping and service fees" msgid "Charge payment, shipping and service fees"
@@ -19340,8 +19340,8 @@ msgstr ""
msgid "" msgid ""
"Please leave a short comment on what you did in the following admin sessions:" "Please leave a short comment on what you did in the following admin sessions:"
msgstr "" msgstr ""
"Bitte hinterlassen Sie einen kurzen Kommentar, was Sie in diesen Admin-" "Bitte hinterlasse einen kurzen Kommentar, was du in diesen Admin-Sitzungen "
"Sitzungen gemacht haben:" "gemacht hast:"
#: pretix/control/templates/pretixcontrol/base.html:376 #: pretix/control/templates/pretixcontrol/base.html:376
msgid "Read more" msgid "Read more"
@@ -19427,8 +19427,7 @@ msgstr "im Entwicklermodus"
#: pretix/presale/templates/pretixpresale/postmessage.html:27 #: pretix/presale/templates/pretixpresale/postmessage.html:27
#: pretix/presale/templates/pretixpresale/waiting.html:42 #: pretix/presale/templates/pretixpresale/waiting.html:42
msgid "If this takes longer than a few minutes, please contact us." msgid "If this takes longer than a few minutes, please contact us."
msgstr "" msgstr "Wenn dies länger als einige Minuten dauert, kontaktiere uns bitte."
"Wenn dies länger als einige Minuten dauert, kontaktieren Sie uns bitte."
#: pretix/control/templates/pretixcontrol/boxoffice/payment.html:4 #: pretix/control/templates/pretixcontrol/boxoffice/payment.html:4
#: pretix/control/templates/pretixcontrol/organizers/devices.html:75 #: pretix/control/templates/pretixcontrol/organizers/devices.html:75
@@ -22988,7 +22987,7 @@ msgstr ""
"Wenn du eine Gültigkeit in Tagen, Monaten oder Jahren angibst, endet die " "Wenn du eine Gültigkeit in Tagen, Monaten oder Jahren angibst, endet die "
"Gültigkeit immer am Ende eines vollen Tages (Mitternacht), plus der " "Gültigkeit immer am Ende eines vollen Tages (Mitternacht), plus der "
"ausgewählten Anzahl an Minuten und Stunden. Der Starttermin wird in die " "ausgewählten Anzahl an Minuten und Stunden. Der Starttermin wird in die "
"Berechnung eingeschlossen, d.h. wenn Sie \"1 Tag\" auswählen ist das Ticket " "Berechnung eingeschlossen, d.h. wenn du \"1 Tag\" auswählst, ist das Ticket "
"gültig bis zum Ende des Tages, an dem die Gültigkeit beginnt." "gültig bis zum Ende des Tages, an dem die Gültigkeit beginnt."
#: pretix/control/templates/pretixcontrol/item/index.html:254 #: pretix/control/templates/pretixcontrol/item/index.html:254
@@ -23662,8 +23661,8 @@ msgid ""
"Are you sure you want to generate a new client secret for the application " "Are you sure you want to generate a new client secret for the application "
"<strong>%(application)s</strong>?" "<strong>%(application)s</strong>?"
msgstr "" msgstr ""
"Möchten Sie wirklich einen neuen Schlüssel für die App " "Möchtest du wirklich einen neuen Schlüssel für die App <strong>%(application)"
"<strong>%(application)s</strong> generieren?" "s</strong> generieren?"
#: pretix/control/templates/pretixcontrol/oauth/app_rollkeys.html:15 #: pretix/control/templates/pretixcontrol/oauth/app_rollkeys.html:15
msgid "Roll secret" msgid "Roll secret"
@@ -23720,7 +23719,7 @@ msgstr "Bestellung freigeben"
#: pretix/control/templates/pretixcontrol/order/approve.html:10 #: pretix/control/templates/pretixcontrol/order/approve.html:10
msgid "Do you really want to approve this order?" msgid "Do you really want to approve this order?"
msgstr "Möchten Sie diese Bestellung wirklich freigeben?" msgstr "Möchtest du diese Bestellung wirklich freigeben?"
#: pretix/control/templates/pretixcontrol/order/approve.html:20 #: pretix/control/templates/pretixcontrol/order/approve.html:20
#: pretix/control/templates/pretixcontrol/order/cancel.html:46 #: pretix/control/templates/pretixcontrol/order/cancel.html:46
@@ -25689,8 +25688,8 @@ msgid ""
"Download an app that is compatible with pretix. For example, our check-in " "Download an app that is compatible with pretix. For example, our check-in "
"app <strong>pretixSCAN</strong> is available on all major platforms." "app <strong>pretixSCAN</strong> is available on all major platforms."
msgstr "" msgstr ""
"Laden Sie eine App herunter, die mit pretix kompatibel ist, wie z.B. unsere " "Lade eine App herunter, die mit pretix kompatibel ist, wie z.B. unsere Check-"
"Check-in-App <strong>pretixSCAN</strong>." "in-App <strong>pretixSCAN</strong>."
#: pretix/control/templates/pretixcontrol/organizers/device_connect.html:14 #: pretix/control/templates/pretixcontrol/organizers/device_connect.html:14
msgid "Download pretixSCAN" msgid "Download pretixSCAN"
@@ -25917,7 +25916,7 @@ msgid ""
"target=\"_blank\">our documentation</a>." "target=\"_blank\">our documentation</a>."
msgstr "" msgstr ""
"In einigen Regionen, einschließlich der Europäischen Union, bist du " "In einigen Regionen, einschließlich der Europäischen Union, bist du "
"verpflichtet, Informationen über die Barrierefreiheit Ihres Ticketshops zu " "verpflichtet, Informationen über die Barrierefreiheit deines Ticketshops zu "
"veröffentlichen. Du findest eine Vorlage in <a href=\"https://docs.pretix.eu/" "veröffentlichen. Du findest eine Vorlage in <a href=\"https://docs.pretix.eu/"
"de/trust/accessibility/\" target=\"_blank\">unserer Dokumentation</a>." "de/trust/accessibility/\" target=\"_blank\">unserer Dokumentation</a>."
@@ -26023,7 +26022,7 @@ msgstr "Station löschen:"
#: pretix/control/templates/pretixcontrol/organizers/gate_delete.html:8 #: pretix/control/templates/pretixcontrol/organizers/gate_delete.html:8
msgid "Are you sure you want to delete the gate?" msgid "Are you sure you want to delete the gate?"
msgstr "Möchten Sie die Station wirklich löschen?" msgstr "Möchtest du die Station wirklich löschen?"
#: pretix/control/templates/pretixcontrol/organizers/gate_edit.html:6 #: pretix/control/templates/pretixcontrol/organizers/gate_edit.html:6
msgid "Gate:" msgid "Gate:"
@@ -26389,7 +26388,7 @@ msgid ""
"The plugin \"%(name)s\" is enabled for your organizer account, but also " "The plugin \"%(name)s\" is enabled for your organizer account, but also "
"needs to be enabled for the specific events you want to use it with." "needs to be enabled for the specific events you want to use it with."
msgstr "" msgstr ""
"Die Erweiterung \"%(name)s\" ist für Ihr Veranstalterkonto aktiv, muss " "Die Erweiterung \"%(name)s\" ist für dein Veranstalterkonto aktiv, muss "
"jedoch auch für die einzelnen Veranstaltungen aktiviert werden, für die sie " "jedoch auch für die einzelnen Veranstaltungen aktiviert werden, für die sie "
"benutzt werden soll." "benutzt werden soll."
@@ -26936,7 +26935,7 @@ msgid ""
msgstr "" msgstr ""
"Dieser Editor wurde mit aktuellen Versionen von Google Chrome, Mozilla " "Dieser Editor wurde mit aktuellen Versionen von Google Chrome, Mozilla "
"Firefox und Opera getestet. Andere Browser, besonders Internet Explorer oder " "Firefox und Opera getestet. Andere Browser, besonders Internet Explorer oder "
"Microsoft Edge, haben möglicherweise Probleme Ihr Hintergrund-PDF korrekt " "Microsoft Edge, haben möglicherweise Probleme, dein Hintergrund-PDF korrekt "
"darzustellen oder die richtigen Schriftarten zu laden." "darzustellen oder die richtigen Schriftarten zu laden."
#: pretix/control/templates/pretixcontrol/pdf/index.html:207 #: pretix/control/templates/pretixcontrol/pdf/index.html:207
@@ -27121,9 +27120,9 @@ msgid ""
"use pretixPRINT version %(print_version)s (or newer) or pretixSCAN Desktop " "use pretixPRINT version %(print_version)s (or newer) or pretixSCAN Desktop "
"version %(scan_version)s (or newer)." "version %(scan_version)s (or newer)."
msgstr "" msgstr ""
"Dieses Layout verwendet neue Funktionen. Wenn Sie mit einem Gerät drucken, " "Dieses Layout verwendet neue Funktionen. Wenn du mit einem Gerät druckst, "
"stellen Sie sicher, dass pretixPRINT-Version %(print_version)s (oder neuer) " "stell sicher, dass pretixPRINT-Version %(print_version)s (oder neuer) oder "
"oder pretixSCAN Desktop Version %(scan_version)s (oder neuer) im Einsatz ist." "pretixSCAN Desktop Version %(scan_version)s (oder neuer) im Einsatz ist."
#: pretix/control/templates/pretixcontrol/pdf/placeholders.html:16 #: pretix/control/templates/pretixcontrol/pdf/placeholders.html:16
msgid "Available placeholders" msgid "Available placeholders"
@@ -27397,7 +27396,7 @@ msgid ""
msgstr "" msgstr ""
"Du kannst entweder eine oder mehrere Check-in-Listen für jeden Termin deiner " "Du kannst entweder eine oder mehrere Check-in-Listen für jeden Termin deiner "
"Veranstaltungsreihe einzeln anlegen oder nur eine Check-in-Liste für alle " "Veranstaltungsreihe einzeln anlegen oder nur eine Check-in-Liste für alle "
"Ihre Termine verwenden und den Einlass über Check-in-Regeln limitieren. " "deine Termine verwenden und den Einlass über Check-in-Regeln limitieren. "
"Welcher Ansatz besser geeignet ist, hängt von mehreren Faktoren ab, wie z.B. " "Welcher Ansatz besser geeignet ist, hängt von mehreren Faktoren ab, wie z.B. "
"der Menge an Terminen in deiner Veranstaltungsreihe. Für Reihen mit weniger " "der Menge an Terminen in deiner Veranstaltungsreihe. Für Reihen mit weniger "
"als einem Termin pro Tag sind einzelne Check-in-Listen in der Regel " "als einem Termin pro Tag sind einzelne Check-in-Listen in der Regel "
@@ -29233,7 +29232,7 @@ msgid ""
"participants won't be able to buy the bundle unless you remove this item " "participants won't be able to buy the bundle unless you remove this item "
"from it." "from it."
msgstr "" msgstr ""
"Sie haben dieses Produkt deaktiviert, obwohl es Teil eines Paketes ist. " "Du hast dieses Produkt deaktiviert, obwohl es Teil eines Paketes ist. "
"Solange dies so ist, kann auch das Paket nicht mehr gekauft werden." "Solange dies so ist, kann auch das Paket nicht mehr gekauft werden."
#: pretix/control/views/item.py:1622 #: pretix/control/views/item.py:1622
@@ -30396,7 +30395,7 @@ msgid ""
"and requested a reset of the credentials." "and requested a reset of the credentials."
msgstr "" msgstr ""
"Ein Zwei-Faktor-Notfall-Token wurde von einem Systemadministrator generiert. " "Ein Zwei-Faktor-Notfall-Token wurde von einem Systemadministrator generiert. "
"Dies passiert üblicherweise, wenn du den Zugriff auf Ihren zweiten Faktor " "Dies passiert üblicherweise, wenn du den Zugriff auf deinen zweiten Faktor "
"verloren und ein Zurücksetzen der Zugangsdaten angefordert hast." "verloren und ein Zurücksetzen der Zugangsdaten angefordert hast."
#: pretix/control/views/users.py:169 #: pretix/control/views/users.py:169
@@ -32143,8 +32142,8 @@ msgid ""
msgstr "" msgstr ""
"Während die meisten Zahlungsmethoden keinen grundlosen Widerruf vorsehen, " "Während die meisten Zahlungsmethoden keinen grundlosen Widerruf vorsehen, "
"können SEPA-Lastschriften per Mausklick zurückgerufen werden. Aus diesem " "können SEPA-Lastschriften per Mausklick zurückgerufen werden. Aus diesem "
"Grund - und abhängig von der Art Ihrer Veranstaltung - kann es notwendig " "Grund und abhängig von der Art deiner Veranstaltung kann es notwendig "
"sein SEPA-Lastschriften nicht anzubieten und damit das Risiko von " "sein, SEPA-Lastschriften nicht anzubieten und damit das Risiko von "
"kostspieligen Rücklastschriften zu vermeiden." "kostspieligen Rücklastschriften zu vermeiden."
#: pretix/plugins/paypal2/payment.py:182 #: pretix/plugins/paypal2/payment.py:182
@@ -32474,7 +32473,7 @@ msgid ""
"Your PayPal account is now connected to pretix. You can change the settings " "Your PayPal account is now connected to pretix. You can change the settings "
"in detail below." "in detail below."
msgstr "" msgstr ""
"Ihr PayPal-Konto ist nun mit pretix verbunden. Auf dieser Seite können Sie " "Dein PayPal-Konto ist nun mit pretix verbunden. Auf dieser Seite kannst du "
"die Einstellungen im Detail anpassen." "die Einstellungen im Detail anpassen."
#: pretix/plugins/pretixdroid/apps.py:30 pretix/plugins/pretixdroid/apps.py:33 #: pretix/plugins/pretixdroid/apps.py:30 pretix/plugins/pretixdroid/apps.py:33
@@ -33051,7 +33050,8 @@ msgstr "Neue Regel erstellen"
#: pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html:10 #: pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html:10
msgid "Scheduled emails are not sent as long as your ticket shop is offline." msgid "Scheduled emails are not sent as long as your ticket shop is offline."
msgstr "" msgstr ""
"Geplante E-Mails werden nicht verschickt, solange Ihr Ticketshop offline ist." "Geplante E-Mails werden nicht verschickt, solange dein Ticketshop offline "
"ist."
#: pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_create.html:49 #: pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_create.html:49
#: pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html:63 #: pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html:63
@@ -33383,7 +33383,7 @@ msgid ""
"methods such as iDEAL, Alipay,and many more." "methods such as iDEAL, Alipay,and many more."
msgstr "" msgstr ""
"Akzeptiere Zahlungen über Stripe, einen weltweit beliebten " "Akzeptiere Zahlungen über Stripe, einen weltweit beliebten "
"Zahlungsdienstleister. PayPal unterstützt Zahlungen per Kreditkarte sowie " "Zahlungsdienstleister. Stripe unterstützt Zahlungen per Kreditkarte sowie "
"viele lokale Zahlungsarten wie z.B. iDEAL, Alipay, und viele mehr." "viele lokale Zahlungsarten wie z.B. iDEAL, Alipay, und viele mehr."
#: pretix/plugins/stripe/forms.py:40 #: pretix/plugins/stripe/forms.py:40
@@ -33968,8 +33968,8 @@ msgid ""
"Pay by bank allows you to authorize a secure Open Banking payment from your " "Pay by bank allows you to authorize a secure Open Banking payment from your "
"banking app. Currently available only with a UK bank account." "banking app. Currently available only with a UK bank account."
msgstr "" msgstr ""
"Zahlung per Onlinebanking erlaubt die sichere Zahlung über Ihre Banking-App. " "Zahlung per Onlinebanking erlaubt die sichere Zahlung über deine Banking-"
"Derzeit nur für britische Bankkonten verfügbar." "App. Derzeit nur für britische Bankkonten verfügbar."
#: pretix/plugins/stripe/payment.py:1886 #: pretix/plugins/stripe/payment.py:1886
msgid "PayPal via Stripe" msgid "PayPal via Stripe"
@@ -34356,7 +34356,7 @@ msgid ""
"Your Stripe account is now connected to pretix. You can change the settings " "Your Stripe account is now connected to pretix. You can change the settings "
"in detail below." "in detail below."
msgstr "" msgstr ""
"Ihr Stripe-Konto ist nun mit pretix verbunden. Auf dieser Seite können Sie " "Dein Stripe-Konto ist nun mit pretix verbunden. Auf dieser Seite kannst du "
"die Einstellungen im Detail anpassen." "die Einstellungen im Detail anpassen."
#: pretix/plugins/stripe/views.py:488 #: pretix/plugins/stripe/views.py:488
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:04+0000\n" "POT-Creation-Date: 2026-04-28 09:04+0000\n"
"PO-Revision-Date: 2026-03-17 14:30+0000\n" "PO-Revision-Date: 2026-05-04 07:42+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n" "Last-Translator: Martin Gross <gross@rami.io>\n"
"Language-Team: German (informal) <https://translate.pretix.eu/projects/" "Language-Team: German (informal) <https://translate.pretix.eu/projects/"
"pretix/pretix-js/de_Informal/>\n" "pretix/pretix-js/de_Informal/>\n"
"Language: de_Informal\n" "Language: de_Informal\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.16.2\n" "X-Generator: Weblate 5.17\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -428,8 +428,7 @@ msgstr ""
#: pretix/static/pretixbase/js/asynctask.js:276 #: pretix/static/pretixbase/js/asynctask.js:276
msgid "If this takes longer than a few minutes, please contact us." msgid "If this takes longer than a few minutes, please contact us."
msgstr "" msgstr "Wenn dies länger als einige Minuten dauert, kontaktiere uns bitte."
"Wenn dies länger als einige Minuten dauert, kontaktieren Sie uns bitte."
#: pretix/static/pretixbase/js/asynctask.js:331 #: pretix/static/pretixbase/js/asynctask.js:331
msgid "Close message" msgid "Close message"
+18 -23
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n" "POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-04-17 03:00+0000\n" "PO-Revision-Date: 2026-05-01 21:00+0000\n"
"Last-Translator: Tim <plicnetwork@gmail.com>\n" "Last-Translator: Paul Berschick <paul@plainschwarz.com>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix/" "Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix/"
"es/>\n" "es/>\n"
"Language: es\n" "Language: es\n"
@@ -8231,10 +8231,8 @@ msgstr ""
"2x complemento 2" "2x complemento 2"
#: pretix/base/pdf.py:383 #: pretix/base/pdf.py:383
#, fuzzy
#| msgid "List of Add-Ons"
msgid "List of Checked-In Add-Ons" msgid "List of Checked-In Add-Ons"
msgstr "Lista de add-ons" msgstr "Lista de complementos registrados"
#: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537 #: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537
#: pretix/control/forms/filter.py:1539 #: pretix/control/forms/filter.py:1539
@@ -9237,10 +9235,8 @@ msgid "Czech National Bank"
msgstr "Banco Nacional Checo" msgstr "Banco Nacional Checo"
#: pretix/base/services/currencies.py:41 #: pretix/base/services/currencies.py:41
#, fuzzy
#| msgid "Czech National Bank"
msgid "National Bank of Poland" msgid "National Bank of Poland"
msgstr "Banco Nacional Checo" msgstr "Banco Nacional de Polonia"
#: pretix/base/services/export.py:95 pretix/base/services/export.py:155 #: pretix/base/services/export.py:95 pretix/base/services/export.py:155
msgid "" msgid ""
@@ -10336,16 +10332,12 @@ msgstr ""
"importe de la factura no esté en CZK." "importe de la factura no esté en CZK."
#: pretix/base/settings.py:577 pretix/base/settings.py:586 #: pretix/base/settings.py:577 pretix/base/settings.py:586
#, fuzzy
#| msgid ""
#| "Based on Czech National Bank daily rates, whenever the invoice amount is "
#| "not in CZK."
msgid "" msgid ""
"Based on National Bank of Poland daily rates, whenever the invoice amount is " "Based on National Bank of Poland daily rates, whenever the invoice amount is "
"not in PLN." "not in PLN."
msgstr "" msgstr ""
"Basado en las tarifas diarias del Banco Nacional Checo, siempre que el " "Según los tipos de cambio diarios del Banco Nacional de Polonia, siempre que "
"importe de la factura no esté en CZK." "el importe de la factura no esté en PLN."
#: pretix/base/settings.py:597 #: pretix/base/settings.py:597
msgid "Require invoice address" msgid "Require invoice address"
@@ -16430,10 +16422,8 @@ msgid "Allow to overbook quotas when performing this operation"
msgstr "Permitir sobrevender cupos cuando se realice esta operación" msgstr "Permitir sobrevender cupos cuando se realice esta operación"
#: pretix/control/forms/orders.py:335 #: pretix/control/forms/orders.py:335
#, fuzzy
#| msgid "Number of orders"
msgid "Number of products to add" msgid "Number of products to add"
msgstr "Número de pedidos" msgstr "Número de productos que se van a añadir"
#: pretix/control/forms/orders.py:344 #: pretix/control/forms/orders.py:344
msgid "Add-on to" msgid "Add-on to"
@@ -16465,10 +16455,8 @@ msgstr ""
"defecto del producto" "defecto del producto"
#: pretix/control/forms/orders.py:441 #: pretix/control/forms/orders.py:441
#, fuzzy
#| msgid "You can not select the same seat multiple times."
msgid "You can not choose a seat when adding multiple products at once." msgid "You can not choose a seat when adding multiple products at once."
msgstr "No se puede seleccionar la misma butaca varias veces." msgstr "No es posible elegir un asiento al añadir varios productos a la vez."
#: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482 #: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482
#: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552 #: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552
@@ -17076,7 +17064,7 @@ msgstr "Día de fin de semana"
#: pretix/control/forms/subevents.py:106 #: pretix/control/forms/subevents.py:106
msgctxt "subevent" msgctxt "subevent"
msgid "Skip dates that overlap with any existing date" msgid "Skip dates that overlap with any existing date"
msgstr "" msgstr "Omite las fechas que coincidan con alguna fecha ya existente"
#: pretix/control/forms/subevents.py:109 #: pretix/control/forms/subevents.py:109
msgctxt "subevent" msgctxt "subevent"
@@ -17086,6 +17074,11 @@ msgid ""
"This respects even inactive dates and works best if all dates have both a " "This respects even inactive dates and works best if all dates have both a "
"start and end time." "start and end time."
msgstr "" msgstr ""
"Esto puede resultar útil si todas tus citas tienen lugar en el mismo lugar y "
"no se deben crear citas repetidas que entren en conflicto con eventos "
"especiales ya existentes. Esta función tiene en cuenta incluso las citas "
"inactivas y funciona mejor si todas las citas tienen una hora de inicio y "
"una hora de finalización."
#: pretix/control/forms/subevents.py:128 #: pretix/control/forms/subevents.py:128
msgid "Keep the current values" msgid "Keep the current values"
@@ -24364,7 +24357,7 @@ msgstr "Escaneo de entrada: %(date)s"
#: pretix/control/templates/pretixcontrol/order/index.html:465 #: pretix/control/templates/pretixcontrol/order/index.html:465
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html:55 #: pretix/presale/templates/pretixpresale/event/fragment_cart.html:55
msgid "Voucher code used:" msgid "Voucher code used:"
msgstr "Código de vale de compra utilizado:" msgstr "Código de descuento utilizado:"
#: pretix/control/templates/pretixcontrol/order/index.html:467 #: pretix/control/templates/pretixcontrol/order/index.html:467
#, python-format #, python-format
@@ -30280,6 +30273,8 @@ msgstr "No cree más de 100.000 fechas a la vez."
#: pretix/control/views/subevents.py:966 #: pretix/control/views/subevents.py:966
msgid "All dates would be skipped because they conflict with existing dates." msgid "All dates would be skipped because they conflict with existing dates."
msgstr "" msgstr ""
"Se omitirían todas las fechas, ya que entran en conflicto con las ya "
"existentes."
#: pretix/control/views/subevents.py:1102 #: pretix/control/views/subevents.py:1102
#, python-brace-format #, python-brace-format
@@ -34927,7 +34922,7 @@ msgstr "tiene errores"
#: pretix/presale/templates/pretixpresale/event/fragment_voucher_form.html:14 #: pretix/presale/templates/pretixpresale/event/fragment_voucher_form.html:14
msgctxt "form" msgctxt "form"
msgid "required" msgid "required"
msgstr "requerido" msgstr "obligatorio"
#: pretix/presale/ical.py:87 pretix/presale/ical.py:146 #: pretix/presale/ical.py:87 pretix/presale/ical.py:146
#: pretix/presale/ical.py:182 #: pretix/presale/ical.py:182
+22 -25
View File
@@ -4,16 +4,16 @@ msgstr ""
"Project-Id-Version: 1\n" "Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n" "POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-03-31 17:00+0000\n" "PO-Revision-Date: 2026-05-08 04:00+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n" "Last-Translator: corentin-spec <corentin@spectentaculaire.fr>\n"
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/fr/" "Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/"
">\n" "fr/>\n"
"Language: fr\n" "Language: fr\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n" "Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 5.16.2\n" "X-Generator: Weblate 5.17.1\n"
#: pretix/_base_settings.py:87 #: pretix/_base_settings.py:87
msgid "English" msgid "English"
@@ -8279,10 +8279,8 @@ msgstr ""
"2x Add-on 2" "2x Add-on 2"
#: pretix/base/pdf.py:383 #: pretix/base/pdf.py:383
#, fuzzy
#| msgid "List of Add-Ons"
msgid "List of Checked-In Add-Ons" msgid "List of Checked-In Add-Ons"
msgstr "Liste des Addons" msgstr "Liste des modules complémentaires enregistrés"
#: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537 #: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537
#: pretix/control/forms/filter.py:1539 #: pretix/control/forms/filter.py:1539
@@ -9296,10 +9294,8 @@ msgid "Czech National Bank"
msgstr "Banque nationale tchèque" msgstr "Banque nationale tchèque"
#: pretix/base/services/currencies.py:41 #: pretix/base/services/currencies.py:41
#, fuzzy
#| msgid "Czech National Bank"
msgid "National Bank of Poland" msgid "National Bank of Poland"
msgstr "Banque nationale tchèque" msgstr "Banque nationale de Pologne"
#: pretix/base/services/export.py:95 pretix/base/services/export.py:155 #: pretix/base/services/export.py:95 pretix/base/services/export.py:155
msgid "" msgid ""
@@ -10396,16 +10392,12 @@ msgstr ""
"que le montant de la facture nest pas en CZK." "que le montant de la facture nest pas en CZK."
#: pretix/base/settings.py:577 pretix/base/settings.py:586 #: pretix/base/settings.py:577 pretix/base/settings.py:586
#, fuzzy
#| msgid ""
#| "Based on Czech National Bank daily rates, whenever the invoice amount is "
#| "not in CZK."
msgid "" msgid ""
"Based on National Bank of Poland daily rates, whenever the invoice amount is " "Based on National Bank of Poland daily rates, whenever the invoice amount is "
"not in PLN." "not in PLN."
msgstr "" msgstr ""
"Sur la base des taux journaliers de la Banque nationale tchèque, chaque fois " "Sur la base des taux quotidiens de la Banque nationale de Pologne, lorsque "
"que le montant de la facture nest pas en CZK." "le montant de la facture n'est pas libellé en PLN."
#: pretix/base/settings.py:597 #: pretix/base/settings.py:597
msgid "Require invoice address" msgid "Require invoice address"
@@ -16569,10 +16561,8 @@ msgstr ""
"Autoriser à surbooker les quotas lors de lexécution de cette opération" "Autoriser à surbooker les quotas lors de lexécution de cette opération"
#: pretix/control/forms/orders.py:335 #: pretix/control/forms/orders.py:335
#, fuzzy
#| msgid "Number of orders"
msgid "Number of products to add" msgid "Number of products to add"
msgstr "Nombre de commandes" msgstr "Nombre d'articles à ajouter"
#: pretix/control/forms/orders.py:344 #: pretix/control/forms/orders.py:344
msgid "Add-on to" msgid "Add-on to"
@@ -16604,10 +16594,10 @@ msgstr ""
"produit" "produit"
#: pretix/control/forms/orders.py:441 #: pretix/control/forms/orders.py:441
#, fuzzy
#| msgid "You can not select the same seat multiple times."
msgid "You can not choose a seat when adding multiple products at once." msgid "You can not choose a seat when adding multiple products at once."
msgstr "Vous ne pouvez pas sélectionner le même siège plusieurs fois." msgstr ""
"Vous ne pouvez pas choisir de siège lorsque vous ajoutez plusieurs produits "
"à la fois."
#: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482 #: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482
#: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552 #: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552
@@ -17230,7 +17220,7 @@ msgstr "Jour de fin de semaine"
#: pretix/control/forms/subevents.py:106 #: pretix/control/forms/subevents.py:106
msgctxt "subevent" msgctxt "subevent"
msgid "Skip dates that overlap with any existing date" msgid "Skip dates that overlap with any existing date"
msgstr "" msgstr "Ignorer les dates qui coïncident avec une date existante"
#: pretix/control/forms/subevents.py:109 #: pretix/control/forms/subevents.py:109
msgctxt "subevent" msgctxt "subevent"
@@ -17240,6 +17230,11 @@ msgid ""
"This respects even inactive dates and works best if all dates have both a " "This respects even inactive dates and works best if all dates have both a "
"start and end time." "start and end time."
msgstr "" msgstr ""
"Cette option peut s'avérer utile si toutes vos dates se déroulent au même "
"endroit et qu'aucune date ne doit être créée en double et entrer en conflit "
"avec des événements spéciaux existants. Elle prend en compte même les dates "
"inactives et fonctionne mieux si toutes les dates comportent à la fois une "
"heure de début et une heure de fin."
#: pretix/control/forms/subevents.py:128 #: pretix/control/forms/subevents.py:128
msgid "Keep the current values" msgid "Keep the current values"
@@ -30512,6 +30507,8 @@ msgstr "Veuillez ne pas créer plus de 100.000 dates à la fois."
#: pretix/control/views/subevents.py:966 #: pretix/control/views/subevents.py:966
msgid "All dates would be skipped because they conflict with existing dates." msgid "All dates would be skipped because they conflict with existing dates."
msgstr "" msgstr ""
"Toutes ces dates seraient ignorées car elles entrent en conflit avec des "
"dates déjà existantes."
#: pretix/control/views/subevents.py:1102 #: pretix/control/views/subevents.py:1102
#, python-brace-format #, python-brace-format
@@ -37887,7 +37884,7 @@ msgstr "Votre panier a été mis à jour."
#: pretix/presale/views/cart.py:525 pretix/presale/views/cart.py:551 #: pretix/presale/views/cart.py:525 pretix/presale/views/cart.py:551
msgid "Your cart is now empty." msgid "Your cart is now empty."
msgstr "Votre panier à été vidé." msgstr "Votre panier a été vidé."
#: pretix/presale/views/cart.py:584 #: pretix/presale/views/cart.py:584
msgid "" msgid ""
+11 -11
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n" "POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2024-05-10 15:47+0000\n" "PO-Revision-Date: 2026-05-02 23:00+0000\n"
"Last-Translator: Martin Gross <gross@rami.io>\n" "Last-Translator: Daniel Musketa <daniel@musketa.de>\n"
"Language-Team: Norwegian Bokmål <https://translate.pretix.eu/projects/pretix/" "Language-Team: Norwegian Bokmål <https://translate.pretix.eu/projects/pretix/"
"pretix/nb_NO/>\n" "pretix/nb_NO/>\n"
"Language: nb_NO\n" "Language: nb_NO\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.4.3\n" "X-Generator: Weblate 5.17\n"
#: pretix/_base_settings.py:87 #: pretix/_base_settings.py:87
msgid "English" msgid "English"
@@ -37,11 +37,11 @@ msgstr "Arabisk"
#: pretix/_base_settings.py:91 #: pretix/_base_settings.py:91
msgid "Basque" msgid "Basque"
msgstr "" msgstr "Baskisk"
#: pretix/_base_settings.py:92 #: pretix/_base_settings.py:92
msgid "Catalan" msgid "Catalan"
msgstr "" msgstr "Katalansk"
#: pretix/_base_settings.py:93 #: pretix/_base_settings.py:93
msgid "Chinese (simplified)" msgid "Chinese (simplified)"
@@ -57,7 +57,7 @@ msgstr "Tsjekkisk"
#: pretix/_base_settings.py:96 #: pretix/_base_settings.py:96
msgid "Croatian" msgid "Croatian"
msgstr "" msgstr "Kroatisk"
#: pretix/_base_settings.py:97 #: pretix/_base_settings.py:97
msgid "Danish" msgid "Danish"
@@ -89,7 +89,7 @@ msgstr "Gresk"
#: pretix/_base_settings.py:104 #: pretix/_base_settings.py:104
msgid "Hebrew" msgid "Hebrew"
msgstr "" msgstr "Hebraisk"
#: pretix/_base_settings.py:105 #: pretix/_base_settings.py:105
msgid "Indonesian" msgid "Indonesian"
@@ -101,7 +101,7 @@ msgstr "Italiensk"
#: pretix/_base_settings.py:107 #: pretix/_base_settings.py:107
msgid "Japanese" msgid "Japanese"
msgstr "" msgstr "Japansk"
#: pretix/_base_settings.py:108 #: pretix/_base_settings.py:108
msgid "Latvian" msgid "Latvian"
@@ -133,11 +133,11 @@ msgstr "Russisk"
#: pretix/_base_settings.py:115 #: pretix/_base_settings.py:115
msgid "Slovak" msgid "Slovak"
msgstr "" msgstr "Slovakisk"
#: pretix/_base_settings.py:116 #: pretix/_base_settings.py:116
msgid "Swedish" msgid "Swedish"
msgstr "" msgstr "Svensk"
#: pretix/_base_settings.py:117 #: pretix/_base_settings.py:117
msgid "Spanish" msgid "Spanish"
@@ -145,7 +145,7 @@ msgstr "Spansk"
#: pretix/_base_settings.py:118 #: pretix/_base_settings.py:118
msgid "Spanish (Latin America)" msgid "Spanish (Latin America)"
msgstr "" msgstr "Spansk (Latin-Amerika)"
#: pretix/_base_settings.py:119 #: pretix/_base_settings.py:119
msgid "Turkish" msgid "Turkish"
+19 -23
View File
@@ -7,16 +7,16 @@ msgstr ""
"Project-Id-Version: 1\n" "Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n" "POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-03-31 17:00+0000\n" "PO-Revision-Date: 2026-05-05 13:26+0000\n"
"Last-Translator: Ruud Hendrickx <ruud@leckxicon.eu>\n" "Last-Translator: Ruud Hendrickx <ruud@leckxicon.eu>\n"
"Language-Team: Dutch <https://translate.pretix.eu/projects/pretix/pretix/nl/" "Language-Team: Dutch <https://translate.pretix.eu/projects/pretix/pretix/nl/>"
">\n" "\n"
"Language: nl\n" "Language: nl\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.16.2\n" "X-Generator: Weblate 5.17.1\n"
#: pretix/_base_settings.py:87 #: pretix/_base_settings.py:87
msgid "English" msgid "English"
@@ -8209,10 +8209,8 @@ msgstr ""
"2x Add-on 2" "2x Add-on 2"
#: pretix/base/pdf.py:383 #: pretix/base/pdf.py:383
#, fuzzy
#| msgid "List of Add-Ons"
msgid "List of Checked-In Add-Ons" msgid "List of Checked-In Add-Ons"
msgstr "Lijst met add-ons" msgstr "Lijst met ingecheckte add-ons"
#: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537 #: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537
#: pretix/control/forms/filter.py:1539 #: pretix/control/forms/filter.py:1539
@@ -9206,10 +9204,8 @@ msgid "Czech National Bank"
msgstr "Tsjechische Nationale Bank" msgstr "Tsjechische Nationale Bank"
#: pretix/base/services/currencies.py:41 #: pretix/base/services/currencies.py:41
#, fuzzy
#| msgid "Czech National Bank"
msgid "National Bank of Poland" msgid "National Bank of Poland"
msgstr "Tsjechische Nationale Bank" msgstr "Nationale Bank van Polen"
#: pretix/base/services/export.py:95 pretix/base/services/export.py:155 #: pretix/base/services/export.py:95 pretix/base/services/export.py:155
msgid "" msgid ""
@@ -10296,16 +10292,12 @@ msgstr ""
"wanneer het factuurbedrag niet in CZK is." "wanneer het factuurbedrag niet in CZK is."
#: pretix/base/settings.py:577 pretix/base/settings.py:586 #: pretix/base/settings.py:577 pretix/base/settings.py:586
#, fuzzy
#| msgid ""
#| "Based on Czech National Bank daily rates, whenever the invoice amount is "
#| "not in CZK."
msgid "" msgid ""
"Based on National Bank of Poland daily rates, whenever the invoice amount is " "Based on National Bank of Poland daily rates, whenever the invoice amount is "
"not in PLN." "not in PLN."
msgstr "" msgstr ""
"Gebaseerd op de dagelijkse tarieven van de Tsjechische Nationale Bank, " "Op basis van de dagkoersen van de Nationale Bank van Polen, wanneer het "
"wanneer het factuurbedrag niet in CZK is." "factuurbedrag niet in PLN is uitgedrukt."
#: pretix/base/settings.py:597 #: pretix/base/settings.py:597
msgid "Require invoice address" msgid "Require invoice address"
@@ -16375,10 +16367,8 @@ msgid "Allow to overbook quotas when performing this operation"
msgstr "Quota overboeken bij deze handeling toestaan" msgstr "Quota overboeken bij deze handeling toestaan"
#: pretix/control/forms/orders.py:335 #: pretix/control/forms/orders.py:335
#, fuzzy
#| msgid "Number of orders"
msgid "Number of products to add" msgid "Number of products to add"
msgstr "Aantal bestellingen" msgstr "Aantal producten dat moet worden toegevoegd"
#: pretix/control/forms/orders.py:344 #: pretix/control/forms/orders.py:344
msgid "Add-on to" msgid "Add-on to"
@@ -16410,10 +16400,9 @@ msgstr ""
"standaardprijs van het product" "standaardprijs van het product"
#: pretix/control/forms/orders.py:441 #: pretix/control/forms/orders.py:441
#, fuzzy
#| msgid "You can not select the same seat multiple times."
msgid "You can not choose a seat when adding multiple products at once." msgid "You can not choose a seat when adding multiple products at once."
msgstr "U kunt dezelfde stoel niet meerdere keren kiezen." msgstr ""
"U kunt geen zitplaats kiezen wanneer u meerdere producten tegelijk toevoegt."
#: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482 #: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482
#: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552 #: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552
@@ -17027,7 +17016,7 @@ msgstr "Weekenddag"
#: pretix/control/forms/subevents.py:106 #: pretix/control/forms/subevents.py:106
msgctxt "subevent" msgctxt "subevent"
msgid "Skip dates that overlap with any existing date" msgid "Skip dates that overlap with any existing date"
msgstr "" msgstr "Sla data over die samenvallen met bestaande data"
#: pretix/control/forms/subevents.py:109 #: pretix/control/forms/subevents.py:109
msgctxt "subevent" msgctxt "subevent"
@@ -17037,6 +17026,11 @@ msgid ""
"This respects even inactive dates and works best if all dates have both a " "This respects even inactive dates and works best if all dates have both a "
"start and end time." "start and end time."
msgstr "" msgstr ""
"Dit kan handig zijn als alle afspraken op dezelfde locatie plaatsvinden en "
"je wilt voorkomen dat terugkerende afspraken in conflict komen met bestaande "
"speciale afspraken. Hierbij wordt ook rekening gehouden met inactieve "
"afspraken en het werkt het beste als alle afspraken een begin- en eindtijd "
"hebben."
#: pretix/control/forms/subevents.py:128 #: pretix/control/forms/subevents.py:128
msgid "Keep the current values" msgid "Keep the current values"
@@ -30168,6 +30162,8 @@ msgstr "U kunt maximaal 100.000 datums tegelijk aanmaken."
#: pretix/control/views/subevents.py:966 #: pretix/control/views/subevents.py:966
msgid "All dates would be skipped because they conflict with existing dates." msgid "All dates would be skipped because they conflict with existing dates."
msgstr "" msgstr ""
"Alle afspraken zouden worden overgeslagen omdat ze samenvallen met bestaande "
"afspraken."
#: pretix/control/views/subevents.py:1102 #: pretix/control/views/subevents.py:1102
#, python-brace-format #, python-brace-format
+28 -26
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:03+0000\n" "POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2026-04-08 18:00+0000\n" "PO-Revision-Date: 2026-05-05 13:26+0000\n"
"Last-Translator: Ruud Hendrickx <ruud@leckxicon.eu>\n" "Last-Translator: Ruud Hendrickx <ruud@leckxicon.eu>\n"
"Language-Team: Dutch (Belgium) <https://translate.pretix.eu/projects/pretix/" "Language-Team: Dutch (Belgium) <https://translate.pretix.eu/projects/pretix/"
"pretix/nl_BE/>\n" "pretix/nl_BE/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.16.2\n" "X-Generator: Weblate 5.17.1\n"
#: pretix/_base_settings.py:87 #: pretix/_base_settings.py:87
msgid "English" msgid "English"
@@ -8204,10 +8204,8 @@ msgstr ""
"2x Add-on 2" "2x Add-on 2"
#: pretix/base/pdf.py:383 #: pretix/base/pdf.py:383
#, fuzzy
#| msgid "List of Add-Ons"
msgid "List of Checked-In Add-Ons" msgid "List of Checked-In Add-Ons"
msgstr "Lijst met add-ons" msgstr "Lijst met ingecheckte add-ons"
#: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537 #: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537
#: pretix/control/forms/filter.py:1539 #: pretix/control/forms/filter.py:1539
@@ -9202,10 +9200,8 @@ msgid "Czech National Bank"
msgstr "Tsjechische Nationale Bank" msgstr "Tsjechische Nationale Bank"
#: pretix/base/services/currencies.py:41 #: pretix/base/services/currencies.py:41
#, fuzzy
#| msgid "Czech National Bank"
msgid "National Bank of Poland" msgid "National Bank of Poland"
msgstr "Tsjechische Nationale Bank" msgstr "Nationale Bank van Polen"
#: pretix/base/services/export.py:95 pretix/base/services/export.py:155 #: pretix/base/services/export.py:95 pretix/base/services/export.py:155
msgid "" msgid ""
@@ -10293,16 +10289,12 @@ msgstr ""
"wanneer het factuurbedrag niet in CZK is." "wanneer het factuurbedrag niet in CZK is."
#: pretix/base/settings.py:577 pretix/base/settings.py:586 #: pretix/base/settings.py:577 pretix/base/settings.py:586
#, fuzzy
#| msgid ""
#| "Based on Czech National Bank daily rates, whenever the invoice amount is "
#| "not in CZK."
msgid "" msgid ""
"Based on National Bank of Poland daily rates, whenever the invoice amount is " "Based on National Bank of Poland daily rates, whenever the invoice amount is "
"not in PLN." "not in PLN."
msgstr "" msgstr ""
"Gebaseerd op de dagelijkse tarieven van de Tsjechische Nationale Bank, " "Op basis van de dagkoersen van de Nationale Bank van Polen, wanneer het "
"wanneer het factuurbedrag niet in CZK is." "factuurbedrag niet in PLN is uitgedrukt."
#: pretix/base/settings.py:597 #: pretix/base/settings.py:597
msgid "Require invoice address" msgid "Require invoice address"
@@ -16369,10 +16361,8 @@ msgid "Allow to overbook quotas when performing this operation"
msgstr "Quota overboeken bij deze handeling toestaan" msgstr "Quota overboeken bij deze handeling toestaan"
#: pretix/control/forms/orders.py:335 #: pretix/control/forms/orders.py:335
#, fuzzy
#| msgid "Number of orders"
msgid "Number of products to add" msgid "Number of products to add"
msgstr "Aantal bestellingen" msgstr "Aantal producten dat moet worden toegevoegd"
#: pretix/control/forms/orders.py:344 #: pretix/control/forms/orders.py:344
msgid "Add-on to" msgid "Add-on to"
@@ -16404,10 +16394,9 @@ msgstr ""
"standaardprijs van het product" "standaardprijs van het product"
#: pretix/control/forms/orders.py:441 #: pretix/control/forms/orders.py:441
#, fuzzy
#| msgid "You can not select the same seat multiple times."
msgid "You can not choose a seat when adding multiple products at once." msgid "You can not choose a seat when adding multiple products at once."
msgstr "U kunt dezelfde stoel niet meerdere keren kiezen." msgstr ""
"U kunt geen zitplaats kiezen wanneer u meerdere producten tegelijk toevoegt."
#: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482 #: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482
#: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552 #: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552
@@ -17020,7 +17009,7 @@ msgstr "Weekenddag"
#: pretix/control/forms/subevents.py:106 #: pretix/control/forms/subevents.py:106
msgctxt "subevent" msgctxt "subevent"
msgid "Skip dates that overlap with any existing date" msgid "Skip dates that overlap with any existing date"
msgstr "" msgstr "Sla data over die samenvallen met bestaande data"
#: pretix/control/forms/subevents.py:109 #: pretix/control/forms/subevents.py:109
msgctxt "subevent" msgctxt "subevent"
@@ -17030,6 +17019,11 @@ msgid ""
"This respects even inactive dates and works best if all dates have both a " "This respects even inactive dates and works best if all dates have both a "
"start and end time." "start and end time."
msgstr "" msgstr ""
"Dit kan handig zijn als alle afspraken op dezelfde locatie plaatsvinden en "
"je wilt voorkomen dat terugkerende afspraken in conflict komen met bestaande "
"speciale afspraken. Hierbij wordt ook rekening gehouden met inactieve "
"afspraken en het werkt het beste als alle afspraken een begin- en eindtijd "
"hebben."
#: pretix/control/forms/subevents.py:128 #: pretix/control/forms/subevents.py:128
msgid "Keep the current values" msgid "Keep the current values"
@@ -30145,6 +30139,8 @@ msgstr "U kunt maximaal 100.000 datums tegelijk aanmaken."
#: pretix/control/views/subevents.py:966 #: pretix/control/views/subevents.py:966
msgid "All dates would be skipped because they conflict with existing dates." msgid "All dates would be skipped because they conflict with existing dates."
msgstr "" msgstr ""
"Alle afspraken zouden worden overgeslagen omdat ze samenvallen met bestaande "
"afspraken."
#: pretix/control/views/subevents.py:1102 #: pretix/control/views/subevents.py:1102
#, python-brace-format #, python-brace-format
@@ -31447,10 +31443,16 @@ msgid ""
"refunds.\n" "refunds.\n"
" " " "
msgstr "" msgstr ""
"\n"
" Let op: terugbetalingen zullen als uitgevoerd worden "
"gemarkeerd zodra er een exportbestand wordt aangemaakt.\n"
" Zorg ervoor dat u het exportbestand downloadt en de "
"terugbetalingen daadwerkelijk uitvoert.\n"
" "
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:50 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:50
msgid "Exported files" msgid "Exported files"
msgstr "" msgstr "Geëxporteerde bestanden"
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:55 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:55
msgid "Export date" msgid "Export date"
@@ -31462,17 +31464,17 @@ msgstr "Aantal bestellingen"
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:80 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:80
msgid "not downloaded" msgid "not downloaded"
msgstr "" msgstr "niet gedownload"
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:85 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:85
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:96 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:96
msgid "Download CSV" msgid "Download CSV"
msgstr "" msgstr "CSV downloaden"
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:90 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:90
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:101 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:101
msgid "SEPA XML" msgid "SEPA XML"
msgstr "" msgstr "SEPA-XML"
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:110 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/refund_export.html:110
msgid "No exports have been created yet." msgid "No exports have been created yet."
@@ -31480,7 +31482,7 @@ msgstr "Er zijn nog geen exports aangemaakt."
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/sepa_export.html:10 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/sepa_export.html:10
msgid "Export SEPA xml" msgid "Export SEPA xml"
msgstr "" msgstr "Exporteer SEPA-XML"
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/sepa_export.html:13 #: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/sepa_export.html:13
#, python-format #, python-format
@@ -0,0 +1,19 @@
from django.db import migrations
from django.db.models import F
def remove_cross_event_scheduled_mails(apps, schema_editor):
Rule = apps.get_model("sendmail", "Rule")
ScheduledMail = apps.get_model("sendmail", "ScheduledMail")
ScheduledMail.objects.filter(rule__subevent__isnull=False).exclude(rule__subevent__event=F('rule__event')).delete()
Rule.objects.filter(subevent__isnull=False).exclude(subevent__event=F('event')).delete()
class Migration(migrations.Migration):
dependencies = [
("sendmail", "0010_auto_20250801_1342"),
]
operations = [
migrations.RunPython(remove_cross_event_scheduled_mails),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.2.13 on 2026-05-06 15:45
from django.db import migrations
from django.db.models import F
def remove_cross_event_scheduled_mails(apps, schema_editor):
Rule = apps.get_model("sendmail", "Rule")
ScheduledMail = apps.get_model("sendmail", "ScheduledMail")
ScheduledMail.objects.filter(subevent__isnull=False).exclude(subevent__event=F('rule__event')).delete()
Rule.objects.filter(subevent__isnull=False).exclude(subevent__event=F('event')).delete()
class Migration(migrations.Migration):
replaces = [('sendmail', '0011_remove_cross_event_scheduled_mails'), ('sendmail', '0012_remove_cross_event_scheduled_mails')]
dependencies = [
('sendmail', '0010_auto_20250801_1342'),
]
operations = [
migrations.RunPython(
code=remove_cross_event_scheduled_mails,
),
]
@@ -0,0 +1,17 @@
from django.db import migrations
from django.db.models import F
def remove_cross_event_scheduled_mails(apps, schema_editor):
ScheduledMail = apps.get_model("sendmail", "ScheduledMail")
ScheduledMail.objects.filter(subevent__isnull=False).exclude(subevent__event=F('rule__event')).delete()
class Migration(migrations.Migration):
dependencies = [
("sendmail", "0011_remove_cross_event_scheduled_mails"),
]
operations = [
migrations.RunPython(remove_cross_event_scheduled_mails),
]
+1 -1
View File
@@ -232,7 +232,7 @@ def sendmail_copy_data_receiver(sender, other, item_map, **kwargs):
if sender.sendmail_rules.exists(): # idempotency if sender.sendmail_rules.exists(): # idempotency
return return
for r in other.sendmail_rules.prefetch_related('limit_products'): for r in other.sendmail_rules.filter(subevent__isnull=True).prefetch_related('limit_products'):
limit_products = list(r.limit_products.all()) limit_products = list(r.limit_products.all())
r = copy.copy(r) r = copy.copy(r)
r.pk = None r.pk = None
+1 -1
View File
@@ -386,7 +386,7 @@ class StripeSettingsHolder(BasePaymentProvider):
disabled=self.event.currency != 'EUR', disabled=self.event.currency != 'EUR',
help_text=( help_text=(
_('Some payment methods might need to be enabled in the settings of your Stripe account ' _('Some payment methods might need to be enabled in the settings of your Stripe account '
'before work properly.') + 'before they work properly.') +
'<div class="alert alert-warning">%s</div>' % _( '<div class="alert alert-warning">%s</div>' % _(
'SEPA Direct Debit payments via Stripe are <strong>not</strong> processed ' 'SEPA Direct Debit payments via Stripe are <strong>not</strong> processed '
'instantly but might take up to <strong>14 days</strong> to be confirmed in some cases. ' 'instantly but might take up to <strong>14 days</strong> to be confirmed in some cases. '
@@ -0,0 +1,271 @@
import type { I18nString, SubEvent } from './i18n'
const settingsEl = document.getElementById('api-settings')
const { urls } = JSON.parse(settingsEl.textContent || '{}') as { urls: {
lists: string
questions: string
} }
// interfaces generated from api docs
export interface PaginatedResponse<T> {
count: number
next: string | null
previous: string | null
results: T[]
}
export interface CheckinList {
id: number
name: string
all_products: boolean
limit_products: number[]
subevent: SubEvent | null
position_count?: number
checkin_count?: number
include_pending: boolean
allow_multiple_entries: boolean
allow_entry_after_exit: boolean
rules: Record<string, unknown>
exit_all_at: string | null
addon_match: boolean
ignore_in_statistics?: boolean
consider_tickets_used?: boolean
}
export interface Checkin {
id: number
list: number
datetime: string
type: 'entry' | 'exit'
gate: number | null
device: number | null
device_id: number | null
auto_checked_in: boolean
}
export interface Seat {
id: number
name: string
zone_name: string
row_name: string
row_label: string | null
seat_number: string
seat_label: string | null
seat_guid: string
}
export interface Position {
id: number
order: string
positionid: number
canceled?: boolean
item: { id?: number; name: I18nString; internal_name?: string; admission?: boolean }
variation: { id?: number; value: I18nString } | null
price: string
attendee_name: string
attendee_name_parts: Record<string, string>
attendee_email: string | null
company?: string | null
street?: string | null
zipcode?: string | null
city?: string | null
country?: string | null
state?: string | null
voucher?: number | null
voucher_budget_use?: string | null
tax_rate: string
tax_value: string
tax_code?: string | null
tax_rule: number | null
secret: string
addon_to: number | null
subevent: SubEvent | null
discount?: number | null
blocked: string[] | null
valid_from: string | null
valid_until: string | null
pseudonymization_id: string
seat: Seat | null
checkins: Checkin[]
downloads?: { output: string; url: string }[]
answers: Answer[]
pdf_data?: Record<string, unknown>
plugin_data?: Record<string, unknown>
// Additional fields from checkin list positions endpoint
order__status?: string
order__valid_if_pending?: boolean
order__require_approval?: boolean
order__locale?: string
require_attention?: boolean
addons?: Addon[]
}
export interface Answer {
question: number | AnswerQuestion
answer: string
question_identifier: string
options: number[]
option_identifiers: string[]
}
export interface AnswerQuestion {
id: number
question: I18nString
help_text?: I18nString
type: string
required: boolean
position: number
items: number[]
identifier: string
ask_during_checkin: boolean
show_during_checkin: boolean
hidden?: boolean
print_on_invoice?: boolean
options: QuestionOption[]
valid_number_min?: string | null
valid_number_max?: string | null
valid_date_min?: string | null
valid_date_max?: string | null
valid_datetime_min?: string | null
valid_datetime_max?: string | null
valid_file_portrait?: boolean
valid_string_length_max?: number | null
dependency_question?: number | null
dependency_values?: string[]
}
export interface QuestionOption {
id: number
identifier: string
position: number
answer: I18nString
}
export interface Addon {
item: { name: I18nString; internal_name?: string }
variation: { value: I18nString } | null
}
export interface CheckinStatusVariation {
id: number
value: string
checkin_count: number
position_count: number
}
export interface CheckinStatusItem {
id: number
name: string
checkin_count: number
admission: boolean
position_count: number
variations: CheckinStatusVariation[]
}
export interface CheckinStatus {
checkin_count: number
position_count: number
inside_count: number
event?: { name: string }
items?: CheckinStatusItem[]
}
export interface RedeemRequest {
questions_supported: boolean
canceled_supported: boolean
ignore_unpaid: boolean
type: 'entry' | 'exit'
answers: Record<string, string>
datetime?: string | null
force?: boolean
nonce?: string
}
export interface RedeemResponseList {
id: number
name: string
event: string
subevent: number | null
include_pending: boolean
}
export interface RedeemResponse {
status: 'ok' | 'error' | 'incomplete'
reason?: 'invalid' | 'unpaid' | 'blocked' | 'invalid_time' | 'canceled' | 'already_redeemed' | 'product' | 'rules' | 'ambiguous' | 'revoked' | 'unapproved' | 'error'
reason_explanation?: string | null
position?: Position
questions?: AnswerQuestion[]
checkin_texts?: string[]
require_attention?: boolean
list?: RedeemResponseList
}
const CSRF_TOKEN = document.querySelector<HTMLInputElement>('input[name=csrfmiddlewaretoken]')?.value ?? ''
function handleAuthError (response: Response): void {
if ([401, 403].includes(response.status)) {
window.location.href = '/control/login?next=' + encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash
)
}
}
export const api = {
// generic fetch wrapper, not sure if this should be exposed
async fetch <T> (url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options)
handleAuthError(response)
if (!response.ok && response.status !== 400 && response.status !== 404) {
throw new Error('HTTP status ' + response.status)
}
return response.json()
},
async fetchCheckinLists (endsAfter?: string): Promise<PaginatedResponse<CheckinList>> {
const cutoff = endsAfter ?? moment().subtract(8, 'hours').toISOString()
const url = `${urls.lists}?exclude=checkin_count&exclude=position_count&expand=subevent&ends_after=${cutoff}`
return api.fetch(url)
},
async fetchCheckinList (listId: string): Promise<CheckinList> {
return api.fetch(`${urls.lists}${listId}/?expand=subevent`)
},
async fetchNextPage<T> (nextUrl: string): Promise<PaginatedResponse<T>> {
return api.fetch(nextUrl)
},
async fetchStatus (listId: number): Promise<CheckinStatus> {
return api.fetch(`${urls.lists}${listId}/status/`)
},
async searchPositions (listId: number, query: string): Promise<PaginatedResponse<Position>> {
const url = `${urls.lists}${listId}/positions/?ignore_status=true&expand=subevent&expand=item&expand=variation&check_rules=true&search=${encodeURIComponent(query)}`
return api.fetch(url)
},
async redeemPosition (
listId: number,
positionId: string,
data: RedeemRequest,
untrusted: boolean = false
): Promise<RedeemResponse> {
let url = `${urls.lists}${listId}/positions/${encodeURIComponent(positionId)}/redeem/?expand=item&expand=subevent&expand=variation&expand=answers.question&expand=addons`
if (untrusted) url += '&untrusted_input=true'
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
handleAuthError(response)
if (response.status === 404) {
return { status: 'error', reason: 'invalid' }
}
if (!response.ok && response.status !== 400) {
throw new Error('HTTP status ' + response.status)
}
return response.json()
}
}
File diff suppressed because it is too large Load Diff
@@ -1,28 +1,21 @@
<template> <script setup lang="ts">
<a class="list-group-item" href="#" @click.prevent="$emit('selected', list)"> import { computed } from 'vue'
<div class="row"> import type { CheckinList } from '../api'
<div class="col-md-6"> import { formatSubevent } from '../i18n'
{{ list.name }}
</div> const props = defineProps<{
<div class="col-md-6 text-muted"> list: CheckinList
{{ subevent }} }>()
</div>
</div> defineEmits<{
</a> selected: [list: CheckinList]
</template> }>()
<script>
export default { const subevent = computed(() => formatSubevent(props.list.subevent))
components: {},
props: {
list: Object
},
computed: {
subevent () {
if (!this.list.subevent) return '';
const name = i18nstring_localize(this.list.subevent.name)
const date = moment.utc(this.list.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
return `${name} · ${date}`
}
},
}
</script> </script>
<template lang="pug">
a.list-group-item(href="#", @click.prevent="$emit('selected', list)")
.row
.col-md-6 {{ list.name }}
.col-md-6.text-muted {{ subevent }}
</template>
@@ -1,101 +1,101 @@
<template> <script setup lang="ts">
<div class="panel panel-primary checkinlist-select"> import { ref, onMounted } from 'vue'
<div class="panel-heading"> import { api } from '../api'
<h3 class="panel-title"> import type { CheckinList } from '../api'
{{ $root.strings['checkinlist.select'] }} import { STRINGS } from '../i18n'
</h3> import CheckinlistItem from './checkinlist-item.vue'
</div>
<ul class="list-group"> const emit = defineEmits<{
<checkinlist-item v-if="lists" v-for="l in lists" :list="l" :key="l.id" @selected="$emit('selected', l)"></checkinlist-item> selected: [list: CheckinList]
<li v-if="loading" class="list-group-item text-center"> }>()
<span class="fa fa-4x fa-cog fa-spin loading-icon"></span>
</li> const loading = ref(false)
<li v-else-if="error" class="list-group-item text-center"> const error = ref<unknown>(null)
{{ error }} const lists = ref<CheckinList[] | null>(null)
</li> const nextUrl = ref<string | null>(null)
<a v-else-if="next_url" class="list-group-item text-center" href="#" @click.prevent="loadNext">
{{ $root.strings['pagination.next'] }} async function load () {
</a> loading.value = true
</ul> error.value = null
</div>
</template> try {
<script> if (location.hash) {
export default { const listId = location.hash.substring(1)
components: { try {
CheckinlistItem: CheckinlistItem.default, const data = await api.fetchCheckinList(listId)
}, loading.value = false
data() { if (data.id) {
return { emit('selected', data)
loading: false, } else {
error: null, location.hash = ''
lists: null, load()
next_url: null, }
} } catch {
}, location.hash = ''
// TODO: pagination load()
mounted() { }
this.load() return
}, }
methods: {
load() { const data = await api.fetchCheckinLists()
this.loading = true loading.value = false
const cutoff = moment().subtract(8, 'hours').toISOString()
if (location.hash) { if (data.results) {
fetch(this.$root.api.lists + location.hash.substr(1) + '/' + '?expand=subevent') lists.value = data.results
.then(response => response.json()) nextUrl.value = data.next
.then(data => { } else if (data.results === 0) {
this.loading = false error.value = STRINGS['checkinlist.none']
if (data.id) { } else {
this.$emit('selected', data) error.value = data
} else { }
location.hash = '' } catch (e) {
this.load() loading.value = false
} error.value = e
}) }
.catch(reason => {
location.hash = ''
this.load()
})
return
}
fetch(this.$root.api.lists + '?exclude=checkin_count&exclude=position_count&expand=subevent&ends_after=' + cutoff)
.then(response => response.json())
.then(data => {
this.loading = false
if (data.results) {
this.lists = data.results
this.next_url = data.next
} else if (data.results === 0) {
this.error = this.$root.strings['checkinlist.none']
} else {
this.error = data
}
})
.catch(reason => {
this.loading = false
this.error = reason
})
},
loadNext() {
this.loading = true
fetch(this.next_url)
.then(response => response.json())
.then(data => {
this.loading = false
if (data.results) {
this.lists.push(...data.results)
this.next_url = data.next
} else if (data.results === 0) {
this.error = this.$root.strings['checkinlist.none']
} else {
this.error = data
}
})
.catch(reason => {
this.loading = false
this.error = reason
})
},
},
} }
async function loadNext () {
if (!nextUrl.value) return
loading.value = true
error.value = null
try {
const data = await api.fetchNextPage<CheckinList>(nextUrl.value)
loading.value = false
if (data.results) {
lists.value.push(...data.results)
nextUrl.value = data.next
} else if (data.results === 0) {
error.value = STRINGS['checkinlist.none']
} else {
error.value = data
}
} catch (e) {
loading.value = false
error.value = e
}
}
onMounted(() => {
load()
})
</script> </script>
<template lang="pug">
.panel.panel-primary.checkinlist-select
.panel-heading
h3.panel-title {{ STRINGS['checkinlist.select'] }}
ul.list-group
CheckinlistItem(
v-for="l in lists",
:key="l.id",
:list="l",
@selected="emit('selected', $event)"
)
li.list-group-item.text-center(v-if="loading")
span.fa.fa-4x.fa-cog.fa-spin.loading-icon
li.list-group-item.text-center(v-else-if="error") {{ error }}
a.list-group-item.text-center(v-else-if="nextUrl", href="#", @click.prevent="loadNext")
| {{ STRINGS['pagination.next'] }}
</template>
@@ -1,54 +1,64 @@
<template> <script setup lang="ts">
<input class="form-control" :required="required"> import { ref, watch, onMounted, onUnmounted } from 'vue'
</template> import { dateFormat, datetimeLocale } from '../i18n'
<script>
export default { const props = defineProps<{
props: ["required", "value"], required?: boolean
mounted: function () { modelValue?: string
var vm = this; id?: string
var multiple = this.multiple; }>()
$(this.$el)
.datetimepicker(this.opts()) const emit = defineEmits<{
.trigger("change") 'update:modelValue': [value: string]
.on("dp.change", function (e) { }>()
vm.$emit("input", $(this).data('DateTimePicker').date().format("YYYY-MM-DD"));
}); const input = ref<HTMLInputElement>()
if (!vm.value) {
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0)); const opts = {
} else { format: dateFormat,
$(this.$el).data("DateTimePicker").date(moment(vm.value)); locale: datetimeLocale,
} useCurrent: false,
}, showClear: props.required,
methods: { icons: {
opts: function () { time: 'fa fa-clock-o',
return { date: 'fa fa-calendar',
format: $("body").attr("data-dateformat"), up: 'fa fa-chevron-up',
locale: $("body").attr("data-datetimelocale"), down: 'fa fa-chevron-down',
useCurrent: false, previous: 'fa fa-chevron-left',
showClear: this.required, next: 'fa fa-chevron-right',
icons: { today: 'fa fa-screenshot',
time: 'fa fa-clock-o', clear: 'fa fa-trash',
date: 'fa fa-calendar', close: 'fa fa-remove',
up: 'fa fa-chevron-up', },
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
};
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
} }
</script>
watch(() => props.modelValue, (val) => {
if (val) {
$(input.value!).data('DateTimePicker').date(moment(val))
}
})
onMounted(() => {
$(input.value!)
.datetimepicker(opts)
.trigger('change')
.on('dp.change', function (this: HTMLElement) {
emit('update:modelValue', $(this).data('DateTimePicker').date().format('YYYY-MM-DD'))
})
if (!props.modelValue) {
$(input.value!).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
} else {
$(input.value!).data('DateTimePicker').date(moment(props.modelValue))
}
})
onUnmounted(() => {
$(input.value!)
.off()
.datetimepicker('destroy')
})
</script>
<template lang="pug">
input.form-control(:id="id", ref="input", :required="required")
</template>
@@ -1,55 +1,65 @@
<template> <script setup lang="ts">
<input class="form-control" :required="required"> import { ref, watch, onMounted, onUnmounted } from 'vue'
</template> import { datetimeFormat, datetimeLocale, timezone } from '../i18n'
<script>
export default { const props = defineProps<{
props: ["required", "value"], required?: boolean
mounted: function () { modelValue?: string
var vm = this; id?: string
var multiple = this.multiple; }>()
$(this.$el)
.datetimepicker(this.opts()) const emit = defineEmits<{
.trigger("change") 'update:modelValue': [value: string]
.on("dp.change", function (e) { }>()
vm.$emit("input", $(this).data('DateTimePicker').date().toISOString());
}); const input = ref<HTMLInputElement>()
if (!vm.value) {
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0)); const opts = {
} else { format: datetimeFormat,
$(this.$el).data("DateTimePicker").date(moment(vm.value)); locale: datetimeLocale,
} timeZone: timezone,
}, useCurrent: false,
methods: { showClear: props.required,
opts: function () { icons: {
return { time: 'fa fa-clock-o',
format: $("body").attr("data-datetimeformat"), date: 'fa fa-calendar',
locale: $("body").attr("data-datetimelocale"), up: 'fa fa-chevron-up',
timeZone: $("body").attr("data-timezone"), down: 'fa fa-chevron-down',
useCurrent: false, previous: 'fa fa-chevron-left',
showClear: this.required, next: 'fa fa-chevron-right',
icons: { today: 'fa fa-screenshot',
time: 'fa fa-clock-o', clear: 'fa fa-trash',
date: 'fa fa-calendar', close: 'fa fa-remove',
up: 'fa fa-chevron-up', },
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
};
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
} }
</script>
watch(() => props.modelValue, (val) => {
if (val) {
$(input.value!).data('DateTimePicker').date(moment(val))
}
})
onMounted(() => {
$(input.value!)
.datetimepicker(opts)
.trigger('change')
.on('dp.change', function (this: HTMLElement) {
emit('update:modelValue', $(this).data('DateTimePicker').date().toISOString())
})
if (!props.modelValue) {
$(input.value!).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
} else {
$(input.value!).data('DateTimePicker').date(moment(props.modelValue))
}
})
onUnmounted(() => {
$(input.value!)
.off()
.datetimepicker('destroy')
})
</script>
<template lang="pug">
input.form-control(:id="id", ref="input", :required="required")
</template>
@@ -1,48 +1,48 @@
<template> <script setup lang="ts">
<a class="list-group-item searchresult" href="#" @click.prevent="$emit('selected', position)" ref="a"> import { computed, ref } from 'vue'
<div class="details"> import type { Position } from '../api'
<h4>{{ position.order }}-{{ position.positionid }} {{ position.attendee_name }}</h4> import { STRINGS, i18nstringLocalize, formatSubevent } from '../i18n'
<span>{{ itemvar }}<br></span>
<span v-if="subevent">{{ subevent }}<br></span> const props = defineProps<{
<div class="secret">{{ position.secret }}</div> position: Position
</div> }>()
<div :class="`status status-${status}`">
<span v-if="position.require_attention"><span class="fa fa-warning"></span><br></span> defineEmits<{
{{ $root.strings[`status.${status}`] }} selected: [position: Position]
</div> }>()
</a>
</template> const rootEl = ref<HTMLAnchorElement>()
<script>
export default { const status = computed(() => {
components: {}, if (props.position.checkins.length) return 'redeemed'
props: { if (props.position.order__status === 'n' && props.position.order__valid_if_pending) return 'pending_valid'
position: Object if (props.position.order__status === 'n' && props.position.order__require_approval) return 'require_approval'
}, return props.position.order__status
computed: { })
status() {
if (this.position.checkins.length) return 'redeemed'; const itemvar = computed(() => {
if (this.position.order__status === 'n' && this.position.order__valid_if_pending) return 'pending_valid'; if (props.position.variation) {
if (this.position.order__status === 'n' && this.position.order__require_approval) return 'require_approval'; return `${i18nstringLocalize(props.position.item.name)} ${i18nstringLocalize(props.position.variation.value)}`
return this.position.order__status }
}, return i18nstringLocalize(props.position.item.name)
itemvar() { })
if (this.position.variation) {
return `${i18nstring_localize(this.position.item.name)} ${i18nstring_localize(this.position.variation.value)}` const subevent = computed(() => formatSubevent(props.position.subevent))
}
return i18nstring_localize(this.position.item.name) defineExpose({ el: rootEl })
},
subevent() {
if (!this.position.subevent) return ''
const name = i18nstring_localize(this.position.subevent.name)
const date = moment.utc(this.position.subevent.date_from).tz(this.$root.timezone).format(this.$root.datetime_format)
return `${name} · ${date}`
},
},
}
// secret
// status
// order code
// name
// seat
// require attention
</script> </script>
<template lang="pug">
a.list-group-item.searchresult(ref="rootEl", href="#", @click.prevent="$emit('selected', position)")
.details
h4 {{ position.order }}-{{ position.positionid }} {{ position.attendee_name }}
span {{ itemvar }}
br
span(v-if="subevent") {{ subevent }}
br
.secret {{ position.secret }}
.status(:class="`status-${status}`")
span(v-if="position.require_attention")
span.fa.fa-warning
br
| {{ STRINGS[`status.${status}`] }}
</template>
@@ -1,54 +1,64 @@
<template> <script setup lang="ts">
<input class="form-control" :required="required"> import { ref, watch, onMounted, onUnmounted } from 'vue'
</template> import { timeFormat, datetimeLocale } from '../i18n'
<script>
export default { const props = defineProps<{
props: ["required", "value"], required?: boolean
mounted: function () { modelValue?: string
var vm = this; id?: string
var multiple = this.multiple; }>()
$(this.$el)
.datetimepicker(this.opts()) const emit = defineEmits<{
.trigger("change") 'update:modelValue': [value: string]
.on("dp.change", function (e) { }>()
vm.$emit("input", $(this).data('DateTimePicker').date().format("HH:mm:ss"));
}); const input = ref<HTMLInputElement>()
if (!vm.value) {
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0)); const opts = {
} else { format: timeFormat,
$(this.$el).data("DateTimePicker").date(moment(vm.value)); locale: datetimeLocale,
} useCurrent: false,
}, showClear: props.required,
methods: { icons: {
opts: function () { time: 'fa fa-clock-o',
return { date: 'fa fa-calendar',
format: $("body").attr("data-timeformat"), up: 'fa fa-chevron-up',
locale: $("body").attr("data-datetimelocale"), down: 'fa fa-chevron-down',
useCurrent: false, previous: 'fa fa-chevron-left',
showClear: this.required, next: 'fa fa-chevron-right',
icons: { today: 'fa fa-screenshot',
time: 'fa fa-clock-o', clear: 'fa fa-trash',
date: 'fa fa-calendar', close: 'fa fa-remove',
up: 'fa fa-chevron-up', },
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
};
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
} }
</script>
watch(() => props.modelValue, (val) => {
if (val) {
$(input.value!).data('DateTimePicker').date(moment(val))
}
})
onMounted(() => {
$(input.value!)
.datetimepicker(opts)
.trigger('change')
.on('dp.change', function (this: HTMLElement) {
emit('update:modelValue', $(this).data('DateTimePicker').date().format('HH:mm:ss'))
})
if (!props.modelValue) {
$(input.value!).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
} else {
$(input.value!).data('DateTimePicker').date(moment(props.modelValue))
}
})
onUnmounted(() => {
$(input.value!)
.off()
.datetimepicker('destroy')
})
</script>
<template lang="pug">
input.form-control(:id="id", ref="input", :required="required")
</template>
@@ -0,0 +1,106 @@
const body = document.body
export const timezone = body.dataset.timezone ?? 'UTC'
export const datetimeFormat = body.dataset.datetimeformat ?? 'L LT'
export const dateFormat = body.dataset.dateformat ?? 'L'
export const timeFormat = body.dataset.timeformat ?? 'LT'
export const datetimeLocale = body.dataset.datetimelocale ?? 'en'
export const pretixLocale = body.dataset.pretixlocale ?? 'en'
moment.locale(datetimeLocale)
export function gettext (msgid: string): string {
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
return django.gettext(msgid)
}
return msgid
}
export function ngettext (singular: string, plural: string, count: number): string {
if (typeof django !== 'undefined' && typeof django.ngettext !== 'undefined') {
return django.ngettext(singular, plural, count)
}
return plural
}
export type I18nString = string | Record<string, string> | null | undefined
export function i18nstringLocalize (obj: I18nString): string {
// external
return i18nstring_localize(obj)
}
export const STRINGS: Record<string, string> = {
'checkinlist.select': gettext('Select a check-in list'),
'checkinlist.none': gettext('No active check-in lists found.'),
'checkinlist.switch': gettext('Switch check-in list'),
'results.headline': gettext('Search results'),
'results.none': gettext('No tickets found'),
'check.headline': gettext('Result'),
'check.attention': gettext('This ticket requires special attention'),
'scantype.switch': gettext('Switch direction'),
'scantype.entry': gettext('Entry'),
'scantype.exit': gettext('Exit'),
'input.placeholder': gettext('Scan a ticket or search and press return…'),
'pagination.next': gettext('Load more'),
'status.p': gettext('Valid'),
'status.n': gettext('Unpaid'),
'status.c': gettext('Canceled'),
'status.e': gettext('Canceled'),
'status.pending_valid': gettext('Confirmed'),
'status.require_approval': gettext('Approval pending'),
'status.redeemed': gettext('Redeemed'),
'modal.cancel': gettext('Cancel'),
'modal.continue': gettext('Continue'),
'modal.unpaid.head': gettext('Ticket not paid'),
'modal.unpaid.text': gettext('This ticket is not yet paid. Do you want to continue anyways?'),
'modal.questions': gettext('Additional information required'),
'result.ok': gettext('Valid ticket'),
'result.exit': gettext('Exit recorded'),
'result.already_redeemed': gettext('Ticket already used'),
'result.questions': gettext('Information required'),
'result.invalid': gettext('Unknown ticket'),
'result.product': gettext('Ticket type not allowed here'),
'result.unpaid': gettext('Ticket not paid'),
'result.rules': gettext('Entry not allowed'),
'result.revoked': gettext('Ticket code revoked/changed'),
'result.blocked': gettext('Ticket blocked'),
'result.invalid_time': gettext('Ticket not valid at this time'),
'result.canceled': gettext('Order canceled'),
'result.ambiguous': gettext('Ticket code is ambiguous on list'),
'result.unapproved': gettext('Order not approved'),
'status.checkin': gettext('Checked-in Tickets'),
'status.position': gettext('Valid Tickets'),
'status.inside': gettext('Currently inside'),
yes: gettext('Yes'),
no: gettext('No'),
}
export interface SubEvent {
name: Record<string, string>
date_from: string
}
export function formatSubevent (subevent: SubEvent | null | undefined): string {
if (!subevent) return ''
const name = i18nstringLocalize(subevent.name)
const date = moment.utc(subevent.date_from).tz(timezone).format(datetimeFormat)
return `${name} · ${date}`
}
export interface Question {
type: string
}
export function formatAnswer (value: string, question: Question): string {
if (question.type === 'B' && value === 'True') {
return STRINGS['yes']
} else if (question.type === 'B' && value === 'False') {
return STRINGS['no']
} else if (question.type === 'W' && value) {
return moment(value).tz(timezone).format('L LT')
} else if (question.type === 'D' && value) {
return moment(value).format('L')
}
return value
}
@@ -1,79 +0,0 @@
/*global gettext, Vue, App*/
function gettext(msgid) {
if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') {
return django.gettext(msgid);
}
return msgid;
}
function ngettext(singular, plural, count) {
if (typeof django !== 'undefined' && typeof django.ngettext !== 'undefined') {
return django.ngettext(singular, plural, count);
}
return plural;
}
moment.locale(document.body.attributes['data-datetimelocale'].value)
window.vapp = new Vue({
components: {
App: App.default
},
render: function (h) {
return h('App')
},
data: {
api: {
lists: document.querySelector('#app').attributes['data-api-lists'].value,
},
strings: {
'checkinlist.select': gettext('Select a check-in list'),
'checkinlist.none': gettext('No active check-in lists found.'),
'checkinlist.switch': gettext('Switch check-in list'),
'results.headline': gettext('Search results'),
'results.none': gettext('No tickets found'),
'check.headline': gettext('Result'),
'check.attention': gettext('This ticket requires special attention'),
'scantype.switch': gettext('Switch direction'),
'scantype.entry': gettext('Entry'),
'scantype.exit': gettext('Exit'),
'input.placeholder': gettext('Scan a ticket or search and press return…'),
'pagination.next': gettext('Load more'),
'status.p': gettext('Valid'),
'status.n': gettext('Unpaid'),
'status.c': gettext('Canceled'),
'status.e': gettext('Canceled'),
'status.pending_valid': gettext('Confirmed'),
'status.require_approval': gettext('Approval pending'),
'status.redeemed': gettext('Redeemed'),
'modal.cancel': gettext('Cancel'),
'modal.continue': gettext('Continue'),
'modal.unpaid.head': gettext('Ticket not paid'),
'modal.unpaid.text': gettext('This ticket is not yet paid. Do you want to continue anyways?'),
'modal.questions': gettext('Additional information required'),
'result.ok': gettext('Valid ticket'),
'result.exit': gettext('Exit recorded'),
'result.already_redeemed': gettext('Ticket already used'),
'result.questions': gettext('Information required'),
'result.invalid': gettext('Unknown ticket'),
'result.product': gettext('Ticket type not allowed here'),
'result.unpaid': gettext('Ticket not paid'),
'result.rules': gettext('Entry not allowed'),
'result.revoked': gettext('Ticket code revoked/changed'),
'result.blocked': gettext('Ticket blocked'),
'result.invalid_time': gettext('Ticket not valid at this time'),
'result.canceled': gettext('Order canceled'),
'result.ambiguous': gettext('Ticket code is ambiguous on list'),
'result.unapproved': gettext('Order not approved'),
'status.checkin': gettext('Checked-in Tickets'),
'status.position': gettext('Valid Tickets'),
'status.inside': gettext('Currently inside'),
'yes': gettext('Yes'),
'no': gettext('No'),
},
event_name: document.querySelector('#app').attributes['data-event-name'].value,
timezone: document.body.attributes['data-timezone'].value,
datetime_format: document.body.attributes['data-datetimeformat'].value,
},
el: '#app'
})
@@ -0,0 +1,17 @@
import { createApp } from 'vue'
// import './scss/main.scss'
import App from './components/app.vue'
const mountEl = document.querySelector<HTMLElement>('#app')!
const app = createApp(App, mountEl.dataset)
app.mount('#app')
app.config.errorHandler = (error, _vm, info) => {
// vue fatals on errors by default, which is a weird choice
// https://github.com/vuejs/core/issues/3525
// https://github.com/vuejs/router/discussions/2435
console.error('[VUE]', info, error)
}
@@ -4,6 +4,7 @@
{% load statici18n %} {% load statici18n %}
{% load eventurl %} {% load eventurl %}
{% load escapejson %} {% load escapejson %}
{% load vite %}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@@ -23,11 +24,7 @@
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}" <body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"
data-dateformat="{{ js_date_format }}" data-datetimelocale="{{ js_locale }}" data-dateformat="{{ js_date_format }}" data-datetimelocale="{{ js_locale }}"
data-pretixlocale="{{ request.LANGUAGE_CODE }}" data-timezone="{{ request.event.settings.timezone }}"> data-pretixlocale="{{ request.LANGUAGE_CODE }}" data-timezone="{{ request.event.settings.timezone }}">
<div <div id="app" data-event-name="{{ request.event.name }}"></div>
data-api-lists="{% url "api-v1:checkinlist-list" event=request.event.slug organizer=request.organizer.slug %}"
data-api-questions="{% url "api-v1:question-list" event=request.event.slug organizer=request.organizer.slug %}"
data-event-name="{{ request.event.name }}"
id="app"></div>
{% compress js %} {% compress js %}
<script type="text/javascript" src="{% static "pretixbase/js/i18nstring.js" %}"></script> <script type="text/javascript" src="{% static "pretixbase/js/i18nstring.js" %}"></script>
<script type="text/javascript" src="{% static "moment/moment-with-locales.js" %}"></script> <script type="text/javascript" src="{% static "moment/moment-with-locales.js" %}"></script>
@@ -35,22 +32,17 @@
<script type="text/javascript" src="{% static "jquery/js/jquery-3.6.4.min.js" %}"></script> <script type="text/javascript" src="{% static "jquery/js/jquery-3.6.4.min.js" %}"></script>
<script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script> <script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
{% endcompress %} {% endcompress %}
{% if DEBUG %}
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
{% else %}
<script type="text/javascript" src="{% static "vuejs/vue.min.js" %}"></script>
{% endif %}
{% compress js %}
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/checkinlist-item.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/checkinlist-select.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/searchresult-item.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/datetimefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/datefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/timefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixplugins/webcheckin/components/app.vue' %}"></script>
<script type="text/javascript" src="{% static "pretixplugins/webcheckin/main.js" %}"></script>
{% endcompress %}
<script type="application/json" id="countries">{{ countries|escapejson_dumps }}</script> <script type="application/json" id="countries">{{ countries|escapejson_dumps }}</script>
<script type="application/json" id="api-settings">
{
"urls": {
"lists": "{% url "api-v1:checkinlist-list" event=request.event.slug organizer=request.organizer.slug %}",
"questions": "{% url "api-v1:question-list" event=request.event.slug organizer=request.organizer.slug %}"
}
}
</script>
{% vite_hmr %}
{% vite_asset "src/pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.ts" %}
{% csrf_token %} {% csrf_token %}
</body> </body>
</html> </html>
+55 -23
View File
@@ -34,6 +34,7 @@ from compressor.filters.jsmin import rJSMinFilter
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import BadRequest
from django.core.files.base import ContentFile, File from django.core.files.base import ContentFile, File
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.db.models import Q from django.db.models import Q
@@ -121,9 +122,22 @@ def widget_css_etag(request, version, **kwargs):
return f'{_get_source_cache_key(version)}-{request.organizer.cache.get_or_set("css_version", default=lambda: int(time.time()))}' return f'{_get_source_cache_key(version)}-{request.organizer.cache.get_or_set("css_version", default=lambda: int(time.time()))}'
def _use_vite(request):
if getattr(settings, 'PRETIX_WIDGET_VITE', False):
return True
origin = request.META.get('HTTP_ORIGIN', '')
gs = GlobalSettingsObject()
vite_origins = gs.settings.get('widget_vite_origins', as_type=str, default='')
if origin and vite_origins:
origins_list = [o.strip() for o in vite_origins.strip().splitlines() if o.strip()]
return origin in origins_list
return False
def widget_js_etag(request, version, lang, **kwargs): def widget_js_etag(request, version, lang, **kwargs):
gs = GlobalSettingsObject() gs = GlobalSettingsObject()
return gs.settings.get('widget_checksum_{}_{}'.format(version, lang)) variant = 'vite' if _use_vite(request) else 'legacy'
return gs.settings.get('widget_checksum_{}_{}_{}'.format(version, lang, variant))
@gzip_page @gzip_page
@@ -152,13 +166,16 @@ def widget_css(request, version, **kwargs):
return resp return resp
def generate_widget_js(version, lang): def generate_widget_js(version, lang, use_vite=False):
code = [] code = []
with language(lang): with language(lang):
# Provide isolation # Provide isolation
code.append('(function (siteglobals) {\n') code.append('(function (siteglobals) {\n')
code.append('var module = {}, exports = {};\n') code.append('var module = {}, exports = {};\n')
code.append('var lang = "%s";\n' % lang) if use_vite:
code.append('const LANG = "%s";\n' % lang)
else:
code.append('var lang = "%s";\n' % lang)
c = JavaScriptCatalog() c = JavaScriptCatalog()
c.translation = DjangoTranslation(lang, domain='djangojs') c.translation = DjangoTranslation(lang, domain='djangojs')
@@ -180,20 +197,25 @@ def generate_widget_js(version, lang):
'plural': plural, 'plural': plural,
}) })
i18n_js = template.render(context) i18n_js = template.render(context)
i18n_js = i18n_js.replace('for (const ', 'for (var ') # remove if we really want to break IE11 for good
i18n_js = i18n_js.replace(r"value.includes(", r"-1 != value.indexOf(") # remove if we really want to break IE11 for good
code.append(i18n_js) code.append(i18n_js)
files = [ if use_vite:
'vuejs/vue.js' if settings.DEBUG else 'vuejs/vue.min.js', vite_js = finders.find('vite/widget/widget.js')
'pretixpresale/js/widget/docready.js', if not vite_js:
'pretixpresale/js/widget/floatformat.js', raise FileNotFoundError('Vite widget build not found. Run: npm run build:widget')
'pretixpresale/js/widget/widget.js' if version == version_max else 'pretixpresale/js/widget/widget.v{}.js'.format(version), with open(vite_js, 'r', encoding='utf-8') as fp:
]
for fname in files:
f = finders.find(fname)
with open(f, 'r', encoding='utf-8') as fp:
code.append(fp.read()) code.append(fp.read())
else:
files = [
'vuejs/vue.js' if settings.DEBUG else 'vuejs/vue.min.js',
'pretixpresale/js/widget/docready.js',
'pretixpresale/js/widget/floatformat.js',
'pretixpresale/js/widget/widget.js' if version == version_max else 'pretixpresale/js/widget/widget.v{}.js'.format(version),
]
for fname in files:
f = finders.find(fname)
with open(f, 'r', encoding='utf-8') as fp:
code.append(fp.read())
if settings.DEBUG: if settings.DEBUG:
code.append('})(this);\n') code.append('})(this);\n')
@@ -214,15 +236,22 @@ def widget_js(request, version, lang, **kwargs):
if version < version_min: if version < version_min:
version = version_min version = version_min
cached_js = cache.get('widget_js_data_v{}_{}'.format(version, lang)) use_vite = _use_vite(request)
variant = 'vite' if use_vite else 'legacy'
cache_prefix = 'widget_js_data_v{}_{}_{}'.format(version, lang, variant)
cached_js = cache.get(cache_prefix)
if cached_js and not settings.DEBUG: if cached_js and not settings.DEBUG:
resp = HttpResponse(cached_js, content_type='text/javascript') resp = HttpResponse(cached_js, content_type='text/javascript')
resp._csp_ignore = True resp._csp_ignore = True
resp['Access-Control-Allow-Origin'] = '*' resp['Access-Control-Allow-Origin'] = '*'
return resp return resp
settings_key = 'widget_file_v{}_{}_{}'.format(version, lang, variant)
checksum_key = 'widget_checksum_v{}_{}_{}'.format(version, lang, variant)
gs = GlobalSettingsObject() gs = GlobalSettingsObject()
fname = gs.settings.get('widget_file_v{}_{}'.format(version, lang)) fname = gs.settings.get(settings_key)
resp = None resp = None
if fname and not settings.DEBUG: if fname and not settings.DEBUG:
if isinstance(fname, File): if isinstance(fname, File):
@@ -230,21 +259,21 @@ def widget_js(request, version, lang, **kwargs):
try: try:
data = default_storage.open(fname).read() data = default_storage.open(fname).read()
resp = HttpResponse(data, content_type='text/javascript') resp = HttpResponse(data, content_type='text/javascript')
cache.set('widget_js_data_v{}_{}'.format(version, lang), data, 3600 * 4) cache.set(cache_prefix, data, 3600 * 4)
except: except:
logger.exception('Failed to open widget.js') logger.exception('Failed to open widget.js')
if not resp: if not resp:
data = generate_widget_js(version, lang).encode() data = generate_widget_js(version, lang, use_vite=use_vite).encode()
checksum = hashlib.sha1(data).hexdigest() checksum = hashlib.sha1(data).hexdigest()
if not settings.DEBUG: if not settings.DEBUG:
newname = default_storage.save( newname = default_storage.save(
'widget/widget.{}.{}.{}.js'.format(version, lang, checksum), 'widget/widget.{}.{}.{}.{}.js'.format(version, lang, variant, checksum),
ContentFile(data) ContentFile(data)
) )
gs.settings.set('widget_file_v{}_{}'.format(version, lang), 'file://' + newname) gs.settings.set(settings_key, 'file://' + newname)
gs.settings.set('widget_checksum_v{}_{}'.format(version, lang), checksum) gs.settings.set(checksum_key, checksum)
cache.set('widget_js_data_v{}_{}'.format(version, lang), data, 3600 * 4) cache.set(cache_prefix, data, 3600 * 4)
resp = HttpResponse(data, content_type='text/javascript') resp = HttpResponse(data, content_type='text/javascript')
resp._csp_ignore = True resp._csp_ignore = True
resp['Access-Control-Allow-Origin'] = '*' resp['Access-Control-Allow-Origin'] = '*'
@@ -676,7 +705,10 @@ class WidgetAPIProductList(EventListMixin, View):
for d in data['days']: for d in data['days']:
d['events'] = self._serialize_events(d['events'] or []) d['events'] = self._serialize_events(d['events'] or [])
else: else:
offset = int(self.request.GET.get("offset", 0)) try:
offset = int(self.request.GET.get("offset", 0))
except ValueError:
raise BadRequest('GET parameter "offset" must be an integer.')
limit = 50 limit = 50
if hasattr(self.request, 'event'): if hasattr(self.request, 'event'):
evs = filter_qs_by_attr( evs = filter_qs_by_attr(
+7
View File
@@ -888,3 +888,10 @@ FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT = 1024 * 1024 * config.getint("pretix
FILE_UPLOAD_MAX_SIZE_OTHER = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_other", fallback=10) FILE_UPLOAD_MAX_SIZE_OTHER = 1024 * 1024 * config.getint("pretix_file_upload", "max_size_other", fallback=10)
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
VITE_DEV_SERVER_PORT = 5173
VITE_DEV_SERVER = f"http://localhost:{VITE_DEV_SERVER_PORT}"
VITE_DEV_MODE = DEBUG
VITE_IGNORE = False # Used to ignore `collectstatic`/`rebuild`
PRETIX_WIDGET_VITE = os.environ.get('PRETIX_WIDGET_VITE', '') not in ('', '0')
File diff suppressed because it is too large Load Diff
-16
View File
@@ -1,16 +0,0 @@
{
"name": "pretix",
"version": "0.0.0",
"private": true,
"scripts": {},
"dependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.29.0",
"@rollup/plugin-babel": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"vue": "^2.7.16",
"rollup": "^2.79.1",
"rollup-plugin-vue": "^5.0.1",
"vue-template-compiler": "^2.7.16"
}
}
@@ -1,23 +0,0 @@
import vue from 'rollup-plugin-vue'
import { getBabelOutputPlugin } from '@rollup/plugin-babel'
export default {
output: {
format: 'iife',
exports: 'named',
},
plugins: [
getBabelOutputPlugin({
presets: ['@babel/preset-env'],
// Running babel on iife output is apparently discouraged since it can lead to global
// variable leaks. Since we didn't get it to work on inputs, let's take that risk.
// (In our tests, it did not leak anything.)
allowAllFormats: true
}),
vue({
css: true,
compileTemplate: true,
needMap: false,
}),
],
};
@@ -1,318 +0,0 @@
$(function () {
var TYPEOPS = {
// Every change to our supported JSON logic must be done
// * in pretix.base.services.checkin
// * in pretix.base.models.checkin
// * in pretix.helpers.jsonlogic_boolalg
// * in checkinrules.js
// * in libpretixsync
// * in pretixscan-ios
'product': {
'inList': {
'label': gettext('is one of'),
'cardinality': 2,
}
},
'variation': {
'inList': {
'label': gettext('is one of'),
'cardinality': 2,
}
},
'gate': {
'inList': {
'label': gettext('is one of'),
'cardinality': 2,
}
},
'datetime': {
'isBefore': {
'label': gettext('is before'),
'cardinality': 2,
},
'isAfter': {
'label': gettext('is after'),
'cardinality': 2,
},
},
'enum_entry_status': {
'==': {
'label': gettext('='),
'cardinality': 2,
},
},
'int_by_datetime': {
'<': {
'label': '<',
'cardinality': 2,
},
'<=': {
'label': '≤',
'cardinality': 2,
},
'>': {
'label': '>',
'cardinality': 2,
},
'>=': {
'label': '≥',
'cardinality': 2,
},
'==': {
'label': '=',
'cardinality': 2,
},
'!=': {
'label': '≠',
'cardinality': 2,
},
},
'int': {
'<': {
'label': '<',
'cardinality': 2,
},
'<=': {
'label': '≤',
'cardinality': 2,
},
'>': {
'label': '>',
'cardinality': 2,
},
'>=': {
'label': '≥',
'cardinality': 2,
},
'==': {
'label': '=',
'cardinality': 2,
},
'!=': {
'label': '≠',
'cardinality': 2,
},
},
};
var VARS = {
'product': {
'label': gettext('Product'),
'type': 'product',
},
'variation': {
'label': gettext('Product variation'),
'type': 'variation',
},
'gate': {
'label': gettext('Gate'),
'type': 'gate',
},
'now': {
'label': gettext('Current date and time'),
'type': 'datetime',
},
'now_isoweekday': {
'label': gettext('Current day of the week (1 = Monday, 7 = Sunday)'),
'type': 'int',
},
'entry_status': {
'label': gettext('Current entry status'),
'type': 'enum_entry_status',
},
'entries_number': {
'label': gettext('Number of previous entries'),
'type': 'int',
},
'entries_today': {
'label': gettext('Number of previous entries since midnight'),
'type': 'int',
},
'entries_since': {
'label': gettext('Number of previous entries since'),
'type': 'int_by_datetime',
},
'entries_before': {
'label': gettext('Number of previous entries before'),
'type': 'int_by_datetime',
},
'entries_days': {
'label': gettext('Number of days with a previous entry'),
'type': 'int',
},
'entries_days_since': {
'label': gettext('Number of days with a previous entry since'),
'type': 'int_by_datetime',
},
'entries_days_before': {
'label': gettext('Number of days with a previous entry before'),
'type': 'int_by_datetime',
},
'minutes_since_last_entry': {
'label': gettext('Minutes since last entry (-1 on first entry)'),
'type': 'int',
},
'minutes_since_first_entry': {
'label': gettext('Minutes since first entry (-1 on first entry)'),
'type': 'int',
},
};
var components = {
CheckinRulesVisualization: CheckinRulesVisualization.default,
}
if (typeof CheckinRule !== "undefined") {
Vue.component('checkin-rule', CheckinRule.default);
components = {
CheckinRulesEditor: CheckinRulesEditor.default,
CheckinRulesVisualization: CheckinRulesVisualization.default,
}
}
var app = new Vue({
el: '#rules-editor',
components: components,
data: function () {
return {
rules: {},
items: [],
all_products: false,
limit_products: [],
TYPEOPS: TYPEOPS,
VARS: VARS,
texts: {
and: gettext('All of the conditions below (AND)'),
or: gettext('At least one of the conditions below (OR)'),
date_from: gettext('Event start'),
date_to: gettext('Event end'),
date_admission: gettext('Event admission'),
date_custom: gettext('custom date and time'),
date_customtime: gettext('custom time'),
date_tolerance: gettext('Tolerance (minutes)'),
condition_add: gettext('Add condition'),
minutes: gettext('minutes'),
duplicate: gettext('Duplicate'),
status_present: pgettext('entry_status', 'present'),
status_absent: pgettext('entry_status', 'absent'),
},
hasRules: false,
};
},
computed: {
missingItems: function () {
// This computed property contains list of item or variation names that
// a) Are allowed on the checkin list according to all_products or include_products
// b) Are not matched by ANY logical branch of the rule.
// The list will be empty if there is a "catch-all" rule.
var products_seen = {};
var variations_seen = {};
var rules = convert_to_dnf(this.rules);
var branch_without_product_filter = false;
if (!rules["or"]) {
rules = {"or": [rules]}
}
for (var part of rules["or"]) {
if (!part["and"]) {
part = {"and": [part]}
}
var this_branch_without_product_filter = true;
for (var subpart of part["and"]) {
if (subpart["inList"]) {
if (subpart["inList"][0]["var"] === "product" && subpart["inList"][1]) {
this_branch_without_product_filter = false;
for (var listentry of subpart["inList"][1]["objectList"]) {
products_seen[parseInt(listentry["lookup"][1])] = true
}
} else if (subpart["inList"][0]["var"] === "variation" && subpart["inList"][1]) {
this_branch_without_product_filter = false;
for (var listentry_ of subpart["inList"][1]["objectList"]) {
variations_seen[parseInt(listentry_["lookup"][1])] = true
}
}
}
}
if (this_branch_without_product_filter) {
branch_without_product_filter = true;
break;
}
}
if (branch_without_product_filter || (!Object.keys(products_seen).length && !Object.keys(variations_seen).length)) {
// At least one branch with no product filters at all that's fine.
return [];
}
var missing = [];
for (var item of this.items) {
if (products_seen[item.id]) continue;
if (!this.all_products && !this.limit_products.includes(item.id)) continue;
if (item.variations.length > 0) {
for (var variation of item.variations) {
if (variations_seen[variation.id]) continue;
missing.push(item.name + " " + variation.name)
}
} else {
missing.push(item.name)
}
}
return missing;
}
},
created: function () {
this.rules = JSON.parse($("#id_rules").val());
if ($("#items").length) {
this.items = JSON.parse($("#items").html());
var root = this.$root
function _update() {
root.all_products = $("#id_all_products").prop("checked")
root.limit_products = $("input[name=limit_products]:checked").map(function () {
return parseInt($(this).val())
}).toArray()
}
$("#id_all_products, input[name=limit_products]").on("change", function () {
_update();
})
_update()
function check_for_invalid_ids(valid_products, valid_variations, rule) {
if (rule["and"]) {
for(const child of rule["and"])
check_for_invalid_ids(valid_products, valid_variations, child);
} else if (rule["or"]) {
for(const child of rule["or"])
check_for_invalid_ids(valid_products, valid_variations, child);
} else if (rule["inList"] && rule["inList"][0]["var"] === "product") {
for(const item of rule["inList"][1]["objectList"]) {
if (!valid_products[item["lookup"][1]])
item["lookup"][2] = "[" + gettext('Error: Product not found!') + "]";
else
item["lookup"][2] = valid_products[item["lookup"][1]];
}
} else if (rule["inList"] && rule["inList"][0]["var"] === "variation") {
for(const item of rule["inList"][1]["objectList"]) {
if (!valid_variations[item["lookup"][1]])
item["lookup"][2] = "[" + gettext('Error: Variation not found!') + "]";
else
item["lookup"][2] = valid_variations[item["lookup"][1]];
}
}
}
check_for_invalid_ids(
Object.fromEntries(this.items.map(p => [p.id, p.name])),
Object.fromEntries(this.items.flatMap(p => p.variations?.map(v => [v.id, p.name + ' ' + v.name]))),
this.rules
);
}
},
watch: {
rules: {
deep: true,
handler: function (newval) {
$("#id_rules").val(JSON.stringify(newval));
}
},
}
})
});
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue'
import { rules as rawRules, items, allProducts, limitProducts } from './django-interop'
import { convertToDNF } from './jsonlogic-boolalg'
import RulesEditor from './checkin-rules-editor.vue'
import RulesVisualization from './checkin-rules-visualization.vue'
const gettext = (window as any).gettext
const missingItems = computed(() => {
// This computed variable contains list of item or variation names that
// a) Are allowed on the checkin list according to all_products or include_products
// b) Are not matched by ANY logical branch of the rule.
// The list will be empty if there is a "catch-all" rule.
let productsSeen = {}
let variationsSeen = {}
let rules = convertToDNF(rawRules.value)
let branchWithoutProductFilter = false
if (!rules.or) {
rules = { or: [rules] }
}
for (let part of rules.or) {
if (!part.and) {
part = { and: [part] }
}
let thisBranchWithoutProductFilter = true
for (let subpart of part.and) {
if (subpart.inList) {
if (subpart.inList[0].var === 'product' && subpart.inList[1]) {
thisBranchWithoutProductFilter = false
for (let listentry of subpart.inList[1].objectList) {
productsSeen[parseInt(listentry.lookup[1])] = true
}
} else if (subpart.inList[0].var === 'variation' && subpart.inList[1]) {
thisBranchWithoutProductFilter = false
for (let listentry_ of subpart.inList[1].objectList) {
variationsSeen[parseInt(listentry_.lookup[1])] = true
}
}
}
}
if (thisBranchWithoutProductFilter) {
branchWithoutProductFilter = true
break
}
}
if (branchWithoutProductFilter || (!Object.keys(productsSeen).length && !Object.keys(variationsSeen).length)) {
// At least one branch with no product filters at all that's fine.
return []
}
let missing = []
for (const item of items.value) {
if (productsSeen[item.id]) continue
if (!allProducts.value && !limitProducts.value.includes(item.id)) continue
if (item.variations.length > 0) {
for (let variation of item.variations) {
if (variationsSeen[variation.id]) continue
missing.push(item.name + ' ' + variation.name)
}
} else {
missing.push(item.name)
}
}
return missing
})
</script>
<template lang="pug">
#rules-editor.form-inline
div
ul.nav.nav-tabs(role="tablist")
li.active(role="presentation")
a(href="#rules-edit", role="tab", data-toggle="tab")
span.fa.fa-edit
// space between icon and string
|
| {{ gettext("Edit") }}
li(role="presentation")
a(href="#rules-viz", role="tab", data-toggle="tab")
span.fa.fa-eye
// space between icon and string
|
| {{ gettext("Visualize") }}
//- Tab panes
.tab-content
#rules-edit.tab-pane.active(v-if="items", role="tabpanel")
RulesEditor
#rules-viz.tab-pane(role="tabpanel")
RulesVisualization
.alert.alert-info(v-if="missingItems.length")
p {{ gettext("Your rule always filters by product or variation, but the following products or variations are not contained in any of your rule parts so people with these tickets will not get in:") }}
ul
li(v-for="h in missingItems", :key="h") {{ h }}
p {{ gettext("Please double-check if this was intentional.") }}
</template>
<style lang="stylus">
</style>
@@ -1,355 +1,365 @@
<template> <script setup lang="ts">
<div v-bind:class="classObject"> /* eslint-disable vue/no-mutating-props */
<div class="btn-group pull-right"> import { computed } from 'vue'
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="duplicate" import { TEXTS, VARS, TYPEOPS } from './constants'
v-if="level > 0" data-toggle="tooltip" :title="texts.duplicate"> import { productSelectURL, variationSelectURL, gateSelectURL } from './django-interop'
<span class="fa fa-copy"></span> import LookupSelect2 from './lookup-select2.vue'
</button> import Datetimefield from './datetimefield.vue'
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="wrapWithOR">OR import Timefield from './timefield.vue'
</button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="wrapWithAND">AND
</button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="cutOut"
v-if="operands && operands.length === 1 && (operator === 'or' || operator === 'and')"><span
class="fa fa-cut"></span></button>
<button type="button" class="checkin-rule-remove btn btn-xs btn-default" @click.prevent="remove"
v-if="level > 0"><span class="fa fa-trash"></span></button>
</div>
<select v-bind:value="variable" v-on:input="setVariable" required class="form-control">
<option value="and">{{texts.and}}</option>
<option value="or">{{texts.or}}</option>
<option v-for="(v, name) in vars" :value="name">{{ v.label }}</option>
</select>
<select v-bind:value="operator" v-on:input="setOperator" required class="form-control"
v-if="operator !== 'or' && operator !== 'and' && vartype !== 'int_by_datetime'">
<option></option>
<option v-for="(v, name) in operators" :value="name">{{ v.label }}</option>
</select>
<select v-bind:value="timeType" v-on:input="setTimeType" required class="form-control"
v-if="vartype === 'datetime' || vartype === 'int_by_datetime'">
<option value="date_from">{{texts.date_from}}</option>
<option value="date_to">{{texts.date_to}}</option>
<option value="date_admission">{{texts.date_admission}}</option>
<option value="custom">{{texts.date_custom}}</option>
<option value="customtime">{{texts.date_customtime}}</option>
</select>
<datetimefield v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'custom'" :value="timeValue"
v-on:input="setTimeValue"></datetimefield>
<timefield v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'customtime'" :value="timeValue"
v-on:input="setTimeValue"></timefield>
<input class="form-control" required type="number"
v-if="vartype === 'datetime' && timeType && timeType !== 'customtime' && timeType !== 'custom'" v-bind:value="timeTolerance"
v-on:input="setTimeTolerance" :placeholder="texts.date_tolerance">
<select v-bind:value="operator" v-on:input="setOperator" required class="form-control"
v-if="vartype === 'int_by_datetime'">
<option></option>
<option v-for="(v, name) in operators" :value="name">{{ v.label }}</option>
</select>
<input class="form-control" required type="number" v-if="(vartype === 'int' || vartype === 'int_by_datetime') && cardinality > 1"
v-bind:value="rightoperand" v-on:input="setRightOperandNumber">
<lookup-select2 required v-if="vartype === 'product' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandProductList"
:url="productSelectURL"></lookup-select2>
<lookup-select2 required v-if="vartype === 'variation' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandVariationList"
:url="variationSelectURL"></lookup-select2>
<lookup-select2 required v-if="vartype === 'gate' && operator === 'inList'" :multiple="true"
:value="rightoperand" v-on:input="setRightOperandGateList"
:url="gateSelectURL"></lookup-select2>
<select required v-if="vartype === 'enum_entry_status' && operator === '=='"
:value="rightoperand" v-on:input="setRightOperandEnum" class="form-control">
<option value="absent">{{ texts.status_absent }}</option>
<option value="present">{{ texts.status_present }}</option>
</select>
<div class="checkin-rule-childrules" v-if="operator === 'or' || operator === 'and'">
<div v-for="(op, opi) in operands">
<checkin-rule :rule="op" :index="opi" :level="level + 1" v-if="typeof op === 'object'"></checkin-rule>
</div>
<button type="button" class="checkin-rule-addchild btn btn-xs btn-default" @click.prevent="addOperand"><span
class="fa fa-plus-circle"></span> {{ texts.condition_add }}
</button>
</div>
</div>
</template>
<script>
export default {
components: {
LookupSelect2: LookupSelect2.default,
Datetimefield: Datetimefield.default,
Timefield: Timefield.default,
},
props: {
rule: Object,
level: Number,
index: Number,
},
computed: {
texts: function () {
return this.$root.texts;
},
variable: function () {
var op = this.operator;
if (op === "and" || op === "or") {
return op;
} else if (this.rule[op] && this.rule[op][0]) {
if (this.rule[op][0]["entries_since"]) {
return "entries_since";
}
if (this.rule[op][0]["entries_before"]) {
return "entries_before";
}
if (this.rule[op][0]["entries_days_since"]) {
return "entries_days_since";
}
if (this.rule[op][0]["entries_days_before"]) {
return "entries_days_before";
}
return this.rule[op][0]["var"];
} else {
return null;
}
},
rightoperand: function () {
var op = this.operator;
if (op === "and" || op === "or") {
return null;
} else if (this.rule[op] && typeof this.rule[op][1] !== "undefined") {
return this.rule[op][1];
} else {
return null;
}
},
operator: function () {
return Object.keys(this.rule)[0];
},
operands: function () {
return this.rule[this.operator];
},
classObject: function () {
var c = {
'checkin-rule': true
};
c['checkin-rule-' + this.variable] = true;
return c;
},
vartype: function () {
if (this.variable && this.$root.VARS[this.variable]) {
return this.$root.VARS[this.variable]['type'];
}
},
timeType: function () {
if (this.vartype === 'int_by_datetime') {
if (this.rule[this.operator][0][this.variable] && this.rule[this.operator][0][this.variable][0]['buildTime']) {
return this.rule[this.operator][0][this.variable][0]['buildTime'][0];
}
} else if (this.rightoperand && this.rightoperand['buildTime']) {
return this.rightoperand['buildTime'][0];
}
},
timeTolerance: function () {
var op = this.operator;
if ((op === "isBefore" || op === "isAfter") && this.rule[op] && typeof this.rule[op][2] !== "undefined") {
return this.rule[op][2];
} else {
return null;
}
},
timeValue: function () {
if (this.vartype === 'int_by_datetime') {
if (this.rule[this.operator][0][this.variable][0]['buildTime']) {
return this.rule[this.operator][0][this.variable][0]['buildTime'][1];
}
} else if (this.rightoperand && this.rightoperand['buildTime']) {
return this.rightoperand['buildTime'][1];
}
},
cardinality: function () {
if (this.vartype && this.$root.TYPEOPS[this.vartype] && this.$root.TYPEOPS[this.vartype][this.operator]) {
return this.$root.TYPEOPS[this.vartype][this.operator]['cardinality'];
}
},
operators: function () {
return this.$root.TYPEOPS[this.vartype];
},
productSelectURL: function () {
return $("#product-select2").text();
},
variationSelectURL: function () {
return $("#variations-select2").text();
},
gateSelectURL: function () {
return $("#gates-select2").text();
},
vars: function () {
return this.$root.VARS;
},
},
methods: {
setVariable: function (event) {
var current_op = Object.keys(this.rule)[0];
var current_val = this.rule[current_op];
if (event.target.value === "and" || event.target.value === "or") { const props = defineProps<{
if (current_val[0] && current_val[0]["var"]) { rule: any
current_val = []; level: number
} index: number
this.$set(this.rule, event.target.value, current_val); }>()
this.$delete(this.rule, current_op);
} else { const emit = defineEmits<{
if (current_val !== "and" && current_val !== "or" && current_val[0] && this.$root.VARS[event.target.value]['type'] === this.vartype) { remove: []
if (this.vartype === "int_by_datetime") { duplicate: []
var current_data = this.rule[current_op][0][this.variable]; }>()
var new_lhs = {};
new_lhs[event.target.value] = JSON.parse(JSON.stringify(current_data)); const operator = computed(() => Object.keys(props.rule)[0])
this.$set(this.rule[current_op], 0, new_lhs); const operands = computed(() => props.rule[operator.value])
} else {
this.$set(this.rule[current_op][0], "var", event.target.value); const variable = computed(() => {
} const op = operator.value
} else if (this.$root.VARS[event.target.value]['type'] === 'int_by_datetime') { if (op === 'and' || op === 'or') {
this.$delete(this.rule, current_op); return op
var o = {}; } else if (props.rule[op]?.[0]) {
o[event.target.value] = [{"buildTime": [null, null]}] if (props.rule[op][0]['entries_since']) return 'entries_since'
this.$set(this.rule, "!!", [o]); if (props.rule[op][0]['entries_before']) return 'entries_before'
} else { if (props.rule[op][0]['entries_days_since']) return 'entries_days_since'
this.$delete(this.rule, current_op); if (props.rule[op][0]['entries_days_before']) return 'entries_days_before'
this.$set(this.rule, "!!", [{"var": event.target.value}]); return props.rule[op][0]['var']
} }
} return null
}, })
setOperator: function (event) {
var current_op = Object.keys(this.rule)[0]; const rightoperand = computed(() => {
var current_val = this.rule[current_op]; const op = operator.value
this.$delete(this.rule, current_op); if (op === 'and' || op === 'or') return null
this.$set(this.rule, event.target.value, current_val); return props.rule[op]?.[1] ?? null
}, })
setRightOperandNumber: function (event) {
if (this.rule[this.operator].length === 1) { const classObject = computed(() => ({
this.rule[this.operator].push(parseInt(event.target.value)); 'checkin-rule': true,
} else { ['checkin-rule-' + variable.value]: true
this.$set(this.rule[this.operator], 1, parseInt(event.target.value)); }))
}
}, const vartype = computed(() => VARS[variable.value]?.type)
setTimeTolerance: function (event) {
if (this.rule[this.operator].length === 2) { const timeType = computed(() => {
this.rule[this.operator].push(parseInt(event.target.value)); if (vartype.value === 'int_by_datetime') {
} else { return props.rule[operator.value]?.[0]?.[variable.value]?.[0]?.buildTime?.[0]
this.$set(this.rule[this.operator], 2, parseInt(event.target.value)); }
} return rightoperand.value?.buildTime?.[0]
}, })
setTimeType: function (event) {
var time = { const timeTolerance = computed(() => {
"buildTime": [event.target.value] const op = operator.value
}; if ((op === 'isBefore' || op === 'isAfter') && props.rule[op]?.[2] !== undefined) {
if (this.vartype === "int_by_datetime") { return props.rule[op][2]
this.$set(this.rule[this.operator][0][this.variable], 0, time); }
} else { return null
if (this.rule[this.operator].length === 1) { })
this.rule[this.operator].push(time);
} else { const timeValue = computed(() => {
this.$set(this.rule[this.operator], 1, time); if (vartype.value === 'int_by_datetime') {
} return props.rule[operator.value]?.[0]?.[variable.value]?.[0]?.buildTime?.[1]
if (event.target.value === "custom") { }
this.$set(this.rule[this.operator], 2, 0); return rightoperand.value?.buildTime?.[1]
} })
}
}, const cardinality = computed(() => TYPEOPS[vartype.value]?.[operator.value]?.cardinality)
setTimeValue: function (val) { const operators = computed(() => TYPEOPS[vartype.value])
if (this.vartype === "int_by_datetime") {
this.$set(this.rule[this.operator][0][this.variable][0]["buildTime"], 1, val); function setVariable (event: Event) {
} else { const target = event.target as HTMLSelectElement
this.$set(this.rule[this.operator][1]["buildTime"], 1, val); const currentOp = Object.keys(props.rule)[0]
} let currentVal = props.rule[currentOp]
},
setRightOperandProductList: function (val) { if (target.value === 'and' || target.value === 'or') {
var products = { if (currentVal[0]?.var) currentVal = []
"objectList": [] props.rule[target.value] = currentVal
}; delete props.rule[currentOp]
for (var i = 0; i < val.length; i++) { } else {
products["objectList"].push({ if (currentVal !== 'and' && currentVal !== 'or' && currentVal[0] && VARS[target.value]?.type === vartype.value) {
"lookup": [ if (vartype.value === 'int_by_datetime') {
"product", const currentData = props.rule[currentOp][0][variable.value]
val[i].id, props.rule[currentOp][0] = { [target.value]: JSON.parse(JSON.stringify(currentData)) }
val[i].text } else {
] props.rule[currentOp][0].var = target.value
}); }
} } else if (VARS[target.value]?.type === 'int_by_datetime') {
if (this.rule[this.operator].length === 1) { delete props.rule[currentOp]
this.rule[this.operator].push(products); props.rule['!!'] = [{ [target.value]: [{ buildTime: [null, null] }] }]
} else { } else {
this.$set(this.rule[this.operator], 1, products); delete props.rule[currentOp]
} props.rule['!!'] = [{ var: target.value }]
}, }
setRightOperandVariationList: function (val) { }
var products = { }
"objectList": []
}; function setOperator (event: Event) {
for (var i = 0; i < val.length; i++) { const target = event.target as HTMLSelectElement
products["objectList"].push({ const currentOp = Object.keys(props.rule)[0]
"lookup": [ const currentVal = props.rule[currentOp]
"variation", delete props.rule[currentOp]
val[i].id, props.rule[target.value] = currentVal
val[i].text }
]
}); function setRightOperandNumber (event: Event) {
} const val = parseInt((event.target as HTMLInputElement).value)
if (this.rule[this.operator].length === 1) { if (props.rule[operator.value].length === 1) {
this.rule[this.operator].push(products); props.rule[operator.value].push(val)
} else { } else {
this.$set(this.rule[this.operator], 1, products); props.rule[operator.value][1] = val
} }
}, }
setRightOperandGateList: function (val) {
var products = { function setTimeTolerance (event: Event) {
"objectList": [] const val = parseInt((event.target as HTMLInputElement).value)
}; if (props.rule[operator.value].length === 2) {
for (var i = 0; i < val.length; i++) { props.rule[operator.value].push(val)
products["objectList"].push({ } else {
"lookup": [ props.rule[operator.value][2] = val
"gate", }
val[i].id, }
val[i].text
] function setTimeType (event: Event) {
}); const val = (event.target as HTMLSelectElement).value
} const time = { buildTime: [val] }
if (this.rule[this.operator].length === 1) { if (vartype.value === 'int_by_datetime') {
this.rule[this.operator].push(products); props.rule[operator.value][0][variable.value][0] = time
} else { } else {
this.$set(this.rule[this.operator], 1, products); if (props.rule[operator.value].length === 1) {
} props.rule[operator.value].push(time)
}, } else {
setRightOperandEnum: function (event) { props.rule[operator.value][1] = time
if (this.rule[this.operator].length === 1) { }
this.rule[this.operator].push(event.target.value); if (val === 'custom') {
} else { props.rule[operator.value][2] = 0
this.$set(this.rule[this.operator], 1, event.target.value); }
} }
}, }
addOperand: function () {
this.rule[this.operator].push({"": []}); function setTimeValue (val: string) {
}, if (vartype.value === 'int_by_datetime') {
wrapWithOR: function () { props.rule[operator.value][0][variable.value][0]['buildTime'][1] = val
var r = JSON.parse(JSON.stringify(this.rule)); } else {
this.$delete(this.rule, this.operator); props.rule[operator.value][1]['buildTime'][1] = val
this.$set(this.rule, "or", [r]); }
}, }
wrapWithAND: function () {
var r = JSON.parse(JSON.stringify(this.rule)); function setRightOperandProductList (val: { id: any; text: string }[]) {
this.$delete(this.rule, this.operator); const products = { objectList: val.map(item => ({ lookup: ['product', item.id, item.text] })) }
this.$set(this.rule, "and", [r]); if (props.rule[operator.value].length === 1) {
}, props.rule[operator.value].push(products)
cutOut: function () { } else {
var cop = Object.keys(this.operands[0])[0]; props.rule[operator.value][1] = products
var r = this.operands[0][cop]; }
this.$delete(this.rule, this.operator); }
this.$set(this.rule, cop, r);
}, function setRightOperandVariationList (val: { id: any; text: string }[]) {
remove: function () { const products = { objectList: val.map(item => ({ lookup: ['variation', item.id, item.text] })) }
this.$parent.rule[this.$parent.operator].splice(this.index, 1); if (props.rule[operator.value].length === 1) {
}, props.rule[operator.value].push(products)
duplicate: function () { } else {
var r = JSON.parse(JSON.stringify(this.rule)); props.rule[operator.value][1] = products
this.$parent.rule[this.$parent.operator].splice(this.index, 0, r); }
}, }
}
} function setRightOperandGateList (val: { id: any; text: string }[]) {
const products = { objectList: val.map(item => ({ lookup: ['gate', item.id, item.text] })) }
if (props.rule[operator.value].length === 1) {
props.rule[operator.value].push(products)
} else {
props.rule[operator.value][1] = products
}
}
function setRightOperandEnum (event: Event) {
const val = (event.target as HTMLSelectElement).value
if (props.rule[operator.value].length === 1) {
props.rule[operator.value].push(val)
} else {
props.rule[operator.value][1] = val
}
}
function addOperand () {
props.rule[operator.value].push({ '': [] })
}
function wrapWithOR () {
const r = JSON.parse(JSON.stringify(props.rule))
delete props.rule[operator.value]
props.rule.or = [r]
}
function wrapWithAND () {
const r = JSON.parse(JSON.stringify(props.rule))
delete props.rule[operator.value]
props.rule.and = [r]
}
function cutOut () {
const cop = Object.keys(operands.value[0])[0]
const r = operands.value[0][cop]
delete props.rule[operator.value]
props.rule[cop] = r
}
function remove () {
emit('remove')
}
function duplicate () {
emit('duplicate')
}
function removeChild (index: number) {
props.rule[operator.value].splice(index, 1)
}
function duplicateChild (index: number) {
const r = JSON.parse(JSON.stringify(props.rule[operator.value][index]))
props.rule[operator.value].splice(index, 0, r)
}
</script> </script>
<template lang="pug">
div(:class="classObject")
.btn-group.pull-right
button.checkin-rule-remove.btn.btn-xs.btn-default(
v-if="level > 0",
type="button",
data-toggle="tooltip",
:title="TEXTS.duplicate",
@click.prevent="duplicate"
)
span.fa.fa-copy
button.checkin-rule-remove.btn.btn-xs.btn-default(
type="button",
@click.prevent="wrapWithOR"
) OR
button.checkin-rule-remove.btn.btn-xs.btn-default(
type="button",
@click.prevent="wrapWithAND"
) AND
button.checkin-rule-remove.btn.btn-xs.btn-default(
v-if="operands && operands.length === 1 && (operator === 'or' || operator === 'and')",
type="button",
@click.prevent="cutOut"
)
span.fa.fa-cut
button.checkin-rule-remove.btn.btn-xs.btn-default(
v-if="level > 0",
type="button",
@click.prevent="remove"
)
span.fa.fa-trash
select.form-control(:value="variable", required, @input="setVariable")
option(value="and") {{ TEXTS.and }}
option(value="or") {{ TEXTS.or }}
option(v-for="(v, name) in VARS", :key="name", :value="name") {{ v.label }}
select.form-control(
v-if="operator !== 'or' && operator !== 'and' && vartype !== 'int_by_datetime'",
:value="operator",
required,
@input="setOperator"
)
option
option(v-for="(v, name) in operators", :key="name", :value="name") {{ v.label }}
select.form-control(
v-if="vartype === 'datetime' || vartype === 'int_by_datetime'",
:value="timeType",
required,
@input="setTimeType"
)
option(value="date_from") {{ TEXTS.date_from }}
option(value="date_to") {{ TEXTS.date_to }}
option(value="date_admission") {{ TEXTS.date_admission }}
option(value="custom") {{ TEXTS.date_custom }}
option(value="customtime") {{ TEXTS.date_customtime }}
Datetimefield(
v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'custom'",
:value="timeValue",
@input="setTimeValue"
)
Timefield(
v-if="(vartype === 'datetime' || vartype === 'int_by_datetime') && timeType === 'customtime'",
:value="timeValue",
@input="setTimeValue"
)
input.form-control(
v-if="vartype === 'datetime' && timeType && timeType !== 'customtime' && timeType !== 'custom'",
required,
type="number",
:value="timeTolerance",
:placeholder="TEXTS.date_tolerance",
@input="setTimeTolerance"
)
select.form-control(
v-if="vartype === 'int_by_datetime'",
:value="operator",
required,
@input="setOperator"
)
option
option(v-for="(v, name) in operators", :key="name", :value="name") {{ v.label }}
input.form-control(
v-if="(vartype === 'int' || vartype === 'int_by_datetime') && cardinality > 1",
required,
type="number",
:value="rightoperand",
@input="setRightOperandNumber"
)
LookupSelect2(
v-if="vartype === 'product' && operator === 'inList'",
required,
:multiple="true",
:value="rightoperand",
:url="productSelectURL",
@input="setRightOperandProductList"
)
LookupSelect2(
v-if="vartype === 'variation' && operator === 'inList'",
required,
:multiple="true",
:value="rightoperand",
:url="variationSelectURL",
@input="setRightOperandVariationList"
)
LookupSelect2(
v-if="vartype === 'gate' && operator === 'inList'",
required,
:multiple="true",
:value="rightoperand",
:url="gateSelectURL",
@input="setRightOperandGateList"
)
select.form-control(
v-if="vartype === 'enum_entry_status' && operator === '=='",
required,
:value="rightoperand",
@input="setRightOperandEnum"
)
option(value="absent") {{ TEXTS.status_absent }}
option(value="present") {{ TEXTS.status_present }}
.checkin-rule-childrules(v-if="operator === 'or' || operator === 'and'")
div(v-for="(op, opi) in operands", :key="opi")
CheckinRule(
v-if="typeof op === 'object'",
:rule="op",
:index="opi",
:level="level + 1",
@remove="removeChild(opi)",
@duplicate="duplicateChild(opi)"
)
button.checkin-rule-addchild.btn.btn-xs.btn-default(
type="button",
@click.prevent="addOperand"
)
span.fa.fa-plus-circle
| {{ TEXTS.condition_add }}
</template>
@@ -1,25 +1,23 @@
<template> <script setup lang="ts">
<div class="checkin-rules-editor"> import { computed } from 'vue'
<checkin-rule :rule="this.$root.rules" :level="0" :index="0" v-if="hasRules"></checkin-rule> import { TEXTS } from './constants'
<button type="button" class="checkin-rule-addchild btn btn-xs btn-default" v-if="!hasRules" import { rules } from './django-interop'
@click.prevent="addRule"><span class="fa fa-plus-circle"></span> {{ this.$root.texts.condition_add }} import CheckinRule from './checkin-rule.vue'
</button>
</div> const hasRules = computed(() => !!Object.keys(rules.value).length)
</template>
<script> function addRule () {
export default { rules.value.and = []
components: { }
CheckinRule: CheckinRule.default,
},
computed: {
hasRules: function () {
return !!Object.keys(this.$root.rules).length;
}
},
methods: {
addRule: function () {
this.$set(this.$root.rules, "and", []);
},
},
}
</script> </script>
<template lang="pug">
.checkin-rules-editor
CheckinRule(v-if="hasRules", :rule="rules", :level="0", :index="0")
button.checkin-rule-addchild.btn.btn-xs.btn-default(
v-if="!hasRules",
type="button",
@click.prevent="addRule"
)
span.fa.fa-plus-circle
| {{ TEXTS.condition_add }}
</template>
@@ -1,255 +1,276 @@
<template> <script setup lang="ts">
<div :class="'checkin-rules-visualization ' + (maximized ? 'maximized' : '')"> import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
<div class="tools"> import { rules } from './django-interop'
<button v-if="maximized" class="btn btn-default" type="button" @click.prevent="maximized = false"><span class="fa fa-window-close"></span></button> import VizNode from './viz-node.vue'
<button v-if="!maximized" class="btn btn-default" type="button" @click.prevent="maximized = true"><span class="fa fa-window-maximize"></span></button>
</div>
<svg :width="graph.columns * (boxWidth + marginX) + 2 * paddingX" :height="graph.height * (boxHeight + marginY)"
:viewBox="viewBox" ref="svg">
<g :transform="zoomTransform.toString()">
<viz-node v-for="(node, nodeid) in graph.nodes_by_id" :key="nodeid" :node="node"
:children="node.children.map(n => graph.nodes_by_id[n])" :nodeid="nodeid"
:boxWidth="boxWidth" :boxHeight="boxHeight" :marginX="marginX" :marginY="marginY"
:paddingX="paddingX"></viz-node>
</g>
</svg>
</div>
</template>
<script>
export default {
components: {
VizNode: VizNode.default,
},
computed: {
boxWidth() {
return 300
},
boxHeight() {
return 62
},
paddingX() {
return 50
},
marginX() {
return 50
},
marginY() {
return 20
},
contentWidth() {
return this.graph.columns * (this.boxWidth + this.marginX) + 2 * this.paddingX
},
contentHeight() {
return this.graph.height * (this.boxHeight + this.marginY)
},
viewBox() {
return `0 0 ${this.contentWidth} ${this.contentHeight}`
},
graph() {
/**
* Converts a JSON logic rule into a "flow chart".
*
* A JSON logic rule has a structure like an operator tree:
*
* OR
* |-- AND
* |-- A
* |-- B
* |-- AND
* |-- OR
* |-- C
* |-- D
* |-- E
*
* For our visualization, we want to visualize that tree as a graph one can follow along to reach a
* decision, which has the structure of a directed graph:
*
* --- A --- B --- OK!
* /
* /
* /
* --
* \
* \ --- C ---
* \ / \
* --- --- E --- OK!
* \ /
* --- D ---
*/
const graph = {
nodes_by_id: {},
children: [],
columns: -1,
}
// Step 1: Start building the graph by finding all nodes and edges declare const d3: any
let counter = 0;
const _add_to_graph = (rule) => { // returns [heads, tails]
if (typeof rule !== 'object' || rule === null) {
const node_id = (counter++).toString()
graph.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
const operator = Object.keys(rule)[0] const svg = ref<SVGSVGElement | null>(null)
const operands = rule[operator] const maximized = ref(false)
const zoom = ref<any>(null)
const defaultScale = ref(1)
const zoomTransform = ref(d3.zoomTransform({ k: 1, x: 0, y: 0 }))
if (operator === "and") { const boxWidth = 300
let children = [] const boxHeight = 62
let tails = null const paddingX = 50
operands.reverse() const marginX = 50
for (let operand of operands) { const marginY = 20
let [new_children, new_tails] = _add_to_graph(operand)
for (let new_child of new_tails) {
graph.nodes_by_id[new_child].children.push(...children)
for (let c of children) {
graph.nodes_by_id[c].parent = graph.nodes_by_id[new_child]
}
}
if (tails === null) {
tails = new_tails
}
children = new_children
}
return [children, tails]
} else if (operator === "or") {
const children = []
const tails = []
for (let operand of operands) {
let [new_children, new_tails] = _add_to_graph(operand)
children.push(...new_children)
tails.push(...new_tails)
}
return [children, tails]
} else {
const node_id = (counter++).toString()
graph.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
} interface GraphNode {
graph.children = _add_to_graph(JSON.parse(JSON.stringify(this.$root.rules)))[0] rule: any
column: number
// Step 2: We compute the "column" of every node, which is the maximum number of hops required to reach the children: string[]
// node from the root node y?: number
const _set_column_to_min = (nodes, mincol) => { parent?: GraphNode
for (let node of nodes) {
if (mincol > node.column) {
node.column = mincol
graph.columns = Math.max(mincol + 1, graph.columns)
_set_column_to_min(node.children.map(nid => graph.nodes_by_id[nid]), mincol + 1)
}
}
}
_set_column_to_min(graph.children.map(nid => graph.nodes_by_id[nid]), 0)
// Step 3: Align each node on a grid. The x position is already given by the column computed above, but we still
// need the y position. This part of the algorithm is opinionated and probably not yet the nicest solution we
// can use!
const _set_y = (node, offset) => {
if (typeof node.y === "undefined") {
// We only take the first value we found for each node
node.y = offset
}
let used = 0
for (let cid of node.children) {
used += Math.max(0, _set_y(graph.nodes_by_id[cid], offset + used) - 1)
used++
}
return used
}
_set_y(graph, 0)
// Step 4: Compute the "height" of the graph by looking at the node with the highest y value
graph.height = 1
for (let node of [...Object.values(graph.nodes_by_id)]) {
graph.height = Math.max(graph.height, node.y + 1)
}
return graph
}
},
mounted() {
this.createZoom()
},
created() {
window.addEventListener('resize', this.createZoom)
},
destroyed() {
window.removeEventListener('resize', this.createZoom)
},
watch: {
maximized() {
this.$nextTick(() => {
this.createZoom()
})
}
},
methods: {
createZoom() {
if (!this.$refs.svg) return
const viewportHeight = this.$refs.svg.clientHeight
const viewportWidth = this.$refs.svg.clientWidth
this.defaultScale = 1
this.zoom = d3
.zoom()
.scaleExtent([Math.min(this.defaultScale * 0.5, 1), Math.max(5, this.contentHeight / viewportHeight, this.contentWidth / viewportWidth)])
.extent([[0, 0], [viewportWidth, viewportHeight]])
.filter(event => {
const wheeled = event.type === 'wheel'
const mouseDrag =
event.type === 'mousedown' ||
event.type === 'mouseup' ||
event.type === 'mousemove'
const touch =
event.type === 'touchstart' ||
event.type === 'touchmove' ||
event.type === 'touchstop'
return (wheeled || mouseDrag || touch) && this.maximized
})
.wheelDelta(event => {
// In contrast to default implementation, do not use a factor 10 if ctrl is pressed
return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002)
})
.on('zoom', (event) => {
this.zoomTransform = event.transform
})
const initTransform = d3.zoomIdentity
.scale(this.defaultScale)
.translate(
0,
0
)
this.zoomTransform = initTransform
// This sets correct d3 internal state for the initial centering
d3.select(this.$refs.svg)
.call(this.zoom.transform, initTransform)
const svg = d3.select(this.$refs.svg).call(this.zoom)
svg.on('touchmove.zoom', null)
// TODO touch support
},
},
data() {
return {
maximized: false,
zoom: null,
defaultScale: 1,
zoomTransform: d3.zoomTransform({k: 1, x: 0, y: 0}),
}
}
} }
interface Graph {
nodes_by_id: Record<string, GraphNode>
children: string[]
columns: number
height: number
y?: number
}
const graph = computed<Graph>(() => {
/**
* Converts a JSON logic rule into a "flow chart".
*
* A JSON logic rule has a structure like an operator tree:
*
* OR
* |-- AND
* |-- A
* |-- B
* |-- AND
* |-- OR
* |-- C
* |-- D
* |-- E
*
* For our visualization, we want to visualize that tree as a graph one can follow along to reach a
* decision, which has the structure of a directed graph:
*
* --- A --- B --- OK!
* /
* /
* /
* --
* \
* \ --- C ---
* \ / \
* --- --- E --- OK!
* \ /
* --- D ---
*/
const graphData: Graph = {
nodes_by_id: {},
children: [],
columns: -1,
height: 1,
}
// Step 1: Start building the graph by finding all nodes and edges
let counter = 0
const _add_to_graph = (rule: any): [string[], string[]] => { // returns [heads, tails]
if (typeof rule !== 'object' || rule === null) {
const node_id = (counter++).toString()
graphData.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
const operator = Object.keys(rule)[0]
const operands = rule[operator]
if (operator === 'and') {
let children: string[] = []
let tails: string[] | null = null
operands.reverse()
for (const operand of operands) {
const [new_children, new_tails] = _add_to_graph(operand)
for (const new_child of new_tails) {
graphData.nodes_by_id[new_child].children.push(...children)
for (const c of children) {
graphData.nodes_by_id[c].parent = graphData.nodes_by_id[new_child]
}
}
if (tails === null) {
tails = new_tails
}
children = new_children
}
return [children, tails!]
} else if (operator === 'or') {
const children: string[] = []
const tails: string[] = []
for (const operand of operands) {
const [new_children, new_tails] = _add_to_graph(operand)
children.push(...new_children)
tails.push(...new_tails)
}
return [children, tails]
} else {
const node_id = (counter++).toString()
graphData.nodes_by_id[node_id] = {
rule: rule,
column: -1,
children: [],
}
return [[node_id], [node_id]]
}
}
graphData.children = _add_to_graph(JSON.parse(JSON.stringify(rules.value)))[0]
// Step 2: We compute the "column" of every node, which is the maximum number of hops required to reach the
// node from the root node
const _set_column_to_min = (nodes: GraphNode[], mincol: number) => {
for (const node of nodes) {
if (mincol > node.column) {
node.column = mincol
graphData.columns = Math.max(mincol + 1, graphData.columns)
_set_column_to_min(node.children.map(nid => graphData.nodes_by_id[nid]), mincol + 1)
}
}
}
_set_column_to_min(graphData.children.map(nid => graphData.nodes_by_id[nid]), 0)
// Step 3: Align each node on a grid. The x position is already given by the column computed above, but we still
// need the y position. This part of the algorithm is opinionated and probably not yet the nicest solution we
// can use!
const _set_y = (node: Graph | GraphNode, offset: number): number => {
if (typeof node.y === 'undefined') {
// We only take the first value we found for each node
node.y = offset
}
let used = 0
for (const cid of node.children) {
used += Math.max(0, _set_y(graphData.nodes_by_id[cid], offset + used) - 1)
used++
}
return used
}
_set_y(graphData, 0)
// Step 4: Compute the "height" of the graph by looking at the node with the highest y value
graphData.height = 1
for (const node of [...Object.values(graphData.nodes_by_id)]) {
graphData.height = Math.max(graphData.height, (node.y ?? 0) + 1)
}
return graphData
})
const contentWidth = computed(() => {
return graph.value.columns * (boxWidth + marginX) + 2 * paddingX
})
const contentHeight = computed(() => {
return graph.value.height * (boxHeight + marginY)
})
const viewBox = computed(() => {
return `0 0 ${contentWidth.value} ${contentHeight.value}`
})
function createZoom () {
if (!svg.value) return
const viewportHeight = svg.value.clientHeight
const viewportWidth = svg.value.clientWidth
defaultScale.value = 1
zoom.value = d3
.zoom()
.scaleExtent([Math.min(defaultScale.value * 0.5, 1), Math.max(5, contentHeight.value / viewportHeight, contentWidth.value / viewportWidth)])
.extent([[0, 0], [viewportWidth, viewportHeight]])
.filter((event: any) => {
const wheeled = event.type === 'wheel'
const mouseDrag
= event.type === 'mousedown'
|| event.type === 'mouseup'
|| event.type === 'mousemove'
const touch
= event.type === 'touchstart'
|| event.type === 'touchmove'
|| event.type === 'touchstop'
return (wheeled || mouseDrag || touch) && maximized.value
})
.wheelDelta((event: any) => {
// In contrast to default implementation, do not use a factor 10 if ctrl is pressed
return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002)
})
.on('zoom', (event: any) => {
zoomTransform.value = event.transform
})
const initTransform = d3.zoomIdentity
.scale(defaultScale.value)
.translate(0, 0)
zoomTransform.value = initTransform
// This sets correct d3 internal state for the initial centering
d3.select(svg.value)
.call(zoom.value.transform, initTransform)
const svgSelection = d3.select(svg.value).call(zoom.value)
svgSelection.on('touchmove.zoom', null)
// TODO touch support
}
watch(maximized, () => {
nextTick(() => {
createZoom()
})
})
onMounted(() => {
createZoom()
window.addEventListener('resize', createZoom)
})
onUnmounted(() => {
window.removeEventListener('resize', createZoom)
})
</script> </script>
<template lang="pug">
div(:class="'checkin-rules-visualization ' + (maximized ? 'maximized' : '')")
.tools
button.btn.btn-default(
v-if="maximized",
type="button",
@click.prevent="maximized = false"
)
span.fa.fa-window-close
button.btn.btn-default(
v-if="!maximized",
type="button",
@click.prevent="maximized = true"
)
span.fa.fa-window-maximize
svg(
ref="svg",
:width="contentWidth",
:height="contentHeight",
:viewBox="viewBox"
)
g(:transform="zoomTransform.toString()")
VizNode(
v-for="(node, nodeid) in graph.nodes_by_id",
:key="nodeid",
:node="node",
:children="node.children.map((n: string) => graph.nodes_by_id[n])",
:nodeid="nodeid",
:boxWidth="boxWidth",
:boxHeight="boxHeight",
:marginX="marginX",
:marginY="marginY",
:paddingX="paddingX"
)
</template>
@@ -0,0 +1,193 @@
/* global gettext, pgettext */
export const TEXTS = {
and: gettext('All of the conditions below (AND)'),
or: gettext('At least one of the conditions below (OR)'),
date_from: gettext('Event start'),
date_to: gettext('Event end'),
date_admission: gettext('Event admission'),
date_custom: gettext('custom date and time'),
date_customtime: gettext('custom time'),
date_tolerance: gettext('Tolerance (minutes)'),
condition_add: gettext('Add condition'),
minutes: gettext('minutes'),
duplicate: gettext('Duplicate'),
status_present: pgettext('entry_status', 'present'),
status_absent: pgettext('entry_status', 'absent'),
}
export const TYPEOPS = {
// Every change to our supported JSON logic must be done
// * in pretix.base.services.checkin
// * in pretix.base.models.checkin
// * in pretix.helpers.jsonlogic_boolalg
// * in checkinrules.js
// * in libpretixsync
// * in pretixscan-ios
product: {
inList: {
label: gettext('is one of'),
cardinality: 2,
}
},
variation: {
inList: {
label: gettext('is one of'),
cardinality: 2,
}
},
gate: {
inList: {
label: gettext('is one of'),
cardinality: 2,
}
},
datetime: {
isBefore: {
label: gettext('is before'),
cardinality: 2,
},
isAfter: {
label: gettext('is after'),
cardinality: 2,
},
},
enum_entry_status: {
'==': {
label: gettext('='),
cardinality: 2,
},
},
int_by_datetime: {
'<': {
label: '<',
cardinality: 2,
},
'<=': {
label: '≤',
cardinality: 2,
},
'>': {
label: '>',
cardinality: 2,
},
'>=': {
label: '≥',
cardinality: 2,
},
'==': {
label: '=',
cardinality: 2,
},
'!=': {
label: '≠',
cardinality: 2,
},
},
int: {
'<': {
label: '<',
cardinality: 2,
},
'<=': {
label: '≤',
cardinality: 2,
},
'>': {
label: '>',
cardinality: 2,
},
'>=': {
label: '≥',
cardinality: 2,
},
'==': {
label: '=',
cardinality: 2,
},
'!=': {
label: '≠',
cardinality: 2,
},
},
}
export const VARS = {
product: {
label: gettext('Product'),
type: 'product',
},
variation: {
label: gettext('Product variation'),
type: 'variation',
},
gate: {
label: gettext('Gate'),
type: 'gate',
},
now: {
label: gettext('Current date and time'),
type: 'datetime',
},
now_isoweekday: {
label: gettext('Current day of the week (1 = Monday, 7 = Sunday)'),
type: 'int',
},
entry_status: {
label: gettext('Current entry status'),
type: 'enum_entry_status',
},
entries_number: {
label: gettext('Number of previous entries'),
type: 'int',
},
entries_today: {
label: gettext('Number of previous entries since midnight'),
type: 'int',
},
entries_since: {
label: gettext('Number of previous entries since'),
type: 'int_by_datetime',
},
entries_before: {
label: gettext('Number of previous entries before'),
type: 'int_by_datetime',
},
entries_days: {
label: gettext('Number of days with a previous entry'),
type: 'int',
},
entries_days_since: {
label: gettext('Number of days with a previous entry since'),
type: 'int_by_datetime',
},
entries_days_before: {
label: gettext('Number of days with a previous entry before'),
type: 'int_by_datetime',
},
minutes_since_last_entry: {
label: gettext('Minutes since last entry (-1 on first entry)'),
type: 'int',
},
minutes_since_first_entry: {
label: gettext('Minutes since first entry (-1 on first entry)'),
type: 'int',
},
}
export const DATETIME_OPTIONS = {
format: document.body.dataset.datetimeformat,
locale: document.body.dataset.datetimelocale,
useCurrent: false,
icons: {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down',
previous: 'fa fa-chevron-left',
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot',
clear: 'fa fa-trash',
close: 'fa fa-remove'
}
}
@@ -1,55 +1,45 @@
<template> <script setup lang="ts">
<input class="form-control"> import { ref, watch, onMounted, onUnmounted } from 'vue'
</template> import { DATETIME_OPTIONS } from './constants'
<script>
export default { const props = defineProps<{
props: ["required", "value"], required?: boolean
template: (''), value?: string
mounted: function () { }>()
var vm = this;
var multiple = this.multiple; const emit = defineEmits<{
$(this.$el) input: [value: string]
.datetimepicker(this.opts()) }>()
.trigger("change")
.on("dp.change", function (e) { const input = ref<HTMLInputElement | null>(null)
vm.$emit("input", $(this).data('DateTimePicker').date().toISOString());
}); watch(() => props.value, (val) => {
if (!vm.value) { $(input.value).data('DateTimePicker').date(moment(val))
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0)); })
} else {
$(this.$el).data("DateTimePicker").date(moment(vm.value)); onMounted(() => {
} $(input.value)
}, .datetimepicker({
methods: { ...DATETIME_OPTIONS,
opts: function () { showClear: props.required,
return { })
format: $("body").attr("data-datetimeformat"), .trigger('change')
locale: $("body").attr("data-datetimelocale"), .on('dp.change', function (this: HTMLElement) {
useCurrent: false, emit('input', $(this).data('DateTimePicker').date().toISOString())
showClear: this.required, })
icons: { if (!props.value) {
time: 'fa fa-clock-o', $(input.value).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
date: 'fa fa-calendar', } else {
up: 'fa fa-chevron-up', $(input.value).data('DateTimePicker').date(moment(props.value))
down: 'fa fa-chevron-down', }
previous: 'fa fa-chevron-left', })
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot', onUnmounted(() => {
clear: 'fa fa-trash', $(input.value)
close: 'fa fa-remove' .off()
} .datetimepicker('destroy')
}; })
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(moment(val));
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
</script> </script>
<template lang="pug">
input.form-control(ref="input")
</template>
@@ -0,0 +1,68 @@
import { ref, watch } from 'vue'
export const allProducts = ref(false)
export const limitProducts = ref<number[]>([])
function updateProducts () {
allProducts.value = document.querySelector<HTMLInputElement>('#id_all_products')?.checked ?? false
limitProducts.value = Array.from(document.querySelectorAll<HTMLInputElement>('input[name=limit_products]:checked')).map(el => parseInt(el.value))
}
// listen to change events for products
document.querySelectorAll('#id_all_products, input[name=limit_products]').forEach(el => el.addEventListener('change', updateProducts))
updateProducts()
export const rules = ref<any>({})
// grab rules from hidden input
const rulesInput = document.querySelector<HTMLInputElement>('#id_rules')
if (rulesInput?.value) {
rules.value = JSON.parse(rulesInput.value) ?? {}
}
// sync back to hidden input
watch(rules, (newVal) => {
if (!rulesInput) return
rulesInput.value = JSON.stringify(newVal)
}, { deep: true })
export const items = ref<any[]>([])
const itemsEl = document.querySelector('#items')
if (itemsEl?.textContent) {
items.value = JSON.parse(itemsEl.textContent || '[]')
function checkForInvalidIds (validProducts: Record<string, string>, validVariations: Record<string, string>, rule: any) {
if (rule['and']) {
for (const child of rule['and'])
checkForInvalidIds(validProducts, validVariations, child)
} else if (rule['or']) {
for (const child of rule['or'])
checkForInvalidIds(validProducts, validVariations, child)
} else if (rule['inList'] && rule['inList'][0]['var'] === 'product') {
for (const item of rule['inList'][1]['objectList']) {
if (!validProducts[item['lookup'][1]])
item['lookup'][2] = '[' + gettext('Error: Product not found!') + ']'
else
item['lookup'][2] = validProducts[item['lookup'][1]]
}
} else if (rule['inList'] && rule['inList'][0]['var'] === 'variation') {
for (const item of rule['inList'][1]['objectList']) {
if (!validVariations[item['lookup'][1]])
item['lookup'][2] = '[' + gettext('Error: Variation not found!') + ']'
else
item['lookup'][2] = validVariations[item['lookup'][1]]
}
}
}
checkForInvalidIds(
Object.fromEntries(items.value.map(p => [p.id, p.name])),
Object.fromEntries(items.value.flatMap(p => p.variations?.map(v => [v.id, p.name + ' ' + v.name]) ?? [])),
rules.value
)
}
export const productSelectURL = ref(document.querySelector('#product-select2')?.textContent)
export const variationSelectURL = ref(document.querySelector('#variations-select2')?.textContent)
export const gateSelectURL = ref(document.querySelector('#gate-select2')?.textContent)
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#rules-editor')
app.config.errorHandler = (error, _vm, info) => {
// vue fatals on errors by default, which is a weird choice
// https://github.com/vuejs/core/issues/3525
// https://github.com/vuejs/router/discussions/2435
console.error('[VUE]', info, error)
}
@@ -1,93 +1,93 @@
function convert_to_dnf(rules) { export function convertToDNF (rules) {
// Converts a set of rules to disjunctive normal form, i.e. returns something of the form // Converts a set of rules to disjunctive normal form, i.e. returns something of the form
// `(a AND b AND c) OR (a AND d AND f)` // `(a AND b AND c) OR (a AND d AND f)`
// without further nesting. // without further nesting.
if (typeof rules !== "object" || Array.isArray(rules) || rules === null) { if (typeof rules !== 'object' || Array.isArray(rules) || rules === null) {
return rules return rules
} }
function _distribute_or_over_and(r) { function _distribute_or_over_and (r) {
var operator = Object.keys(r)[0] let operator = Object.keys(r)[0]
var values = r[operator] let values = r[operator]
if (operator === "and") { if (operator === 'and') {
var arg_to_distribute = null let arg_to_distribute = null
var other_args = [] let other_args = []
for (var arg of values) { for (let arg of values) {
if (typeof arg === "object" && !Array.isArray(arg) && typeof arg["or"] !== "undefined" && arg_to_distribute === null) { if (typeof arg === 'object' && !Array.isArray(arg) && typeof arg['or'] !== 'undefined' && arg_to_distribute === null) {
arg_to_distribute = arg arg_to_distribute = arg
} else { } else {
other_args.push(arg) other_args.push(arg)
} }
} }
if (arg_to_distribute === null) { if (arg_to_distribute === null) {
return r return r
} }
var or_operands = [] let or_operands = []
for (var dval of arg_to_distribute["or"]) { for (let dval of arg_to_distribute['or']) {
or_operands.push({"and": other_args.concat([dval])}) or_operands.push({ and: other_args.concat([dval]) })
} }
return { return {
"or": or_operands or: or_operands
} }
} else if (!operator) { } else if (!operator) {
return r return r
} else if (operator === "!" || operator === "!!" || operator === "?:" || operator === "if") { } else if (operator === '!' || operator === '!!' || operator === '?:' || operator === 'if') {
console.warn("Operator " + operator + " currently unsupported by convert_to_dnf") console.warn('Operator ' + operator + ' currently unsupported by convert_to_dnf')
return r return r
} else { } else {
return r return r
} }
} }
function _simplify_chained_operators(r) { function _simplify_chained_operators (r) {
// Simplify `(a OR b) OR (c or d)` to `a OR b OR c OR d` and the same with `AND` // Simplify `(a OR b) OR (c or d)` to `a OR b OR c OR d` and the same with `AND`
if (typeof r !== "object" || Array.isArray(r)) { if (typeof r !== 'object' || Array.isArray(r)) {
return r return r
} }
var operator = Object.keys(r)[0] let operator = Object.keys(r)[0]
var values = r[operator] let values = r[operator]
if (operator !== "or" && operator !== "and") { if (operator !== 'or' && operator !== 'and') {
return r return r
} }
var new_values = [] let new_values = []
for (var v of values) { for (let v of values) {
if (typeof v !== "object" || Array.isArray(v) || typeof v[operator] === "undefined") { if (typeof v !== 'object' || Array.isArray(v) || typeof v[operator] === 'undefined') {
new_values.push(v) new_values.push(v)
} else { } else {
new_values.push(...v[operator]) new_values.push(...v[operator])
} }
} }
var result = {} let result = {}
result[operator] = new_values result[operator] = new_values
return result return result
} }
// Run _distribute_or_over_and on until it no longer changes anything. Do so recursively // Run _distribute_or_over_and on until it no longer changes anything. Do so recursively
// for the full expression tree. // for the full expression tree.
var old_rules = rules let old_rules = rules
while (true) { while (true) {
rules = _distribute_or_over_and(rules) rules = _distribute_or_over_and(rules)
var operator = Object.keys(rules)[0] let operator = Object.keys(rules)[0]
var values = rules[operator] let values = rules[operator]
var no_list = false let no_list = false
if (!Array.isArray(values)) { if (!Array.isArray(values)) {
values = [values] values = [values]
no_list = true no_list = true
} }
rules = {} rules = {}
if (!no_list) { if (!no_list) {
rules[operator] = [] rules[operator] = []
for (var v of values) { for (let v of values) {
rules[operator].push(convert_to_dnf(v)) rules[operator].push(convertToDNF(v))
} }
} else { } else {
rules[operator] = convert_to_dnf(values[0]) rules[operator] = convertToDNF(values[0])
} }
if (JSON.stringify(old_rules) === JSON.stringify(rules)) { // Let's hope this is good enough... if (JSON.stringify(old_rules) === JSON.stringify(rules)) { // Let's hope this is good enough...
break break
} }
old_rules = rules old_rules = rules
} }
rules = _simplify_chained_operators(rules) rules = _simplify_chained_operators(rules)
return rules return rules
} }
@@ -1,97 +1,116 @@
<template> <script setup lang="ts">
<select> import { ref, watch, onMounted, onUnmounted } from 'vue'
<slot></slot>
</select> declare const $: any
</template>
<script> export interface ObjectListItem {
export default { lookup: [string, number | string, string]
props: ["required", "value", "placeholder", "url", "multiple"], }
template: ('<select>\n' +
' <slot></slot>\n' + export interface ObjectList {
' </select>'), objectList: ObjectListItem[]
mounted: function () { }
this.build();
}, const props = defineProps<{
methods: { required?: boolean
build: function () { value?: ObjectList
var vm = this; placeholder?: string
var multiple = this.multiple; url?: string
$(this.$el) multiple?: boolean
.empty() }>()
.select2(this.opts())
.val(this.value || "") const emit = defineEmits<{
.trigger("change") input: [value: any[]]
// emit event on change. }>()
.on("change", function (e) {
vm.$emit("input", $(this).select2('data')); const select = ref<HTMLSelectElement | null>(null)
});
if (vm.value) { function opts () {
for (var i = 0; i < vm.value["objectList"].length; i++) { return {
var option = new Option(vm.value["objectList"][i]["lookup"][2], vm.value["objectList"][i]["lookup"][1], true, true); theme: 'bootstrap',
$(vm.$el).append(option); delay: 100,
} width: '100%',
} multiple: true,
$(vm.$el).trigger("change"); allowClear: props.required,
}, language: $('body').attr('data-select2-locale'),
opts: function () { ajax: {
return { url: props.url,
theme: "bootstrap", data: function (params: { term: string; page?: number }) {
delay: 100, return {
width: '100%', query: params.term,
multiple: true, page: params.page || 1
allowClear: this.required, }
language: $("body").attr("data-select2-locale"), }
ajax: { },
url: this.url, templateResult: function (res: { id?: string; text: string }) {
data: function (params) { if (!res.id) {
return { return res.text
query: params.term, }
page: params.page || 1 const $ret = $('<span>').append(
} $('<span>').addClass('primary').append($('<div>').text(res.text).html())
} )
}, return $ret
templateResult: function (res) { },
if (!res.id) { }
return res.text; }
}
var $ret = $("<span>").append( function build () {
$("<span>").addClass("primary").append($("<div>").text(res.text).html()) $(select.value)
); .empty()
return $ret; .select2(opts())
}, .val(props.value || '')
}; .trigger('change')
} .on('change', function (this: HTMLElement) {
}, emit('input', $(this).select2('data'))
watch: { })
placeholder: function (val) { if (props.value) {
$(this.$el).select2("destroy"); for (let i = 0; i < props.value.objectList.length; i++) {
this.build(); const option = new Option(props.value.objectList[i].lookup[2], String(props.value.objectList[i].lookup[1]), true, true)
}, $(select.value).append(option)
required: function (val) { }
$(this.$el).select2("destroy"); }
this.build(); $(select.value).trigger('change')
}, }
url: function (val) {
$(this.$el).select2("destroy"); watch(() => props.placeholder, () => {
this.build(); $(select.value).select2('destroy')
}, build()
value: function (newval, oldval) { })
if (JSON.stringify(newval) !== JSON.stringify(oldval)) {
$(this.$el).empty(); watch(() => props.required, () => {
if (newval) { $(select.value).select2('destroy')
for (var i = 0; i < newval["objectList"].length; i++) { build()
var option = new Option(newval["objectList"][i]["lookup"][2], newval["objectList"][i]["lookup"][1], true, true); })
$(this.$el).append(option);
} watch(() => props.url, () => {
} $(select.value).select2('destroy')
$(this.$el).trigger("change"); build()
} })
},
}, watch(() => props.value, (newval, oldval) => {
destroyed: function () { if (JSON.stringify(newval) !== JSON.stringify(oldval)) {
$(this.$el) $(select.value).empty()
.off() if (newval) {
.select2("destroy"); for (let i = 0; i < newval.objectList.length; i++) {
} const option = new Option(newval.objectList[i].lookup[2], String(newval.objectList[i].lookup[1]), true, true)
} $(select.value).append(option)
}
}
$(select.value).trigger('change')
}
})
onMounted(() => {
build()
})
onUnmounted(() => {
$(select.value)
.off()
.select2('destroy')
})
</script> </script>
<template lang="pug">
select(ref="select")
slot
</template>
@@ -1,55 +1,45 @@
<template> <script setup lang="ts">
<input class="form-control"> import { ref, watch, onMounted, onUnmounted } from 'vue'
</template> import { DATETIME_OPTIONS } from './constants'
<script>
export default { const props = defineProps<{
props: ["required", "value"], required?: boolean
template: (''), value?: string
mounted: function () { }>()
var vm = this;
var multiple = this.multiple; const emit = defineEmits<{
$(this.$el) input: [value: string]
.datetimepicker(this.opts()) }>()
.trigger("change")
.on("dp.change", function (e) { const input = ref<HTMLInputElement | null>(null)
vm.$emit("input", $(this).data('DateTimePicker').date().format("HH:mm:ss"));
}); watch(() => props.value, (val) => {
if (!vm.value) { $(input.value).data('DateTimePicker').date(val)
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0)); })
} else {
$(this.$el).data("DateTimePicker").date(vm.value); onMounted(() => {
} $(input.value)
}, .datetimepicker({
methods: { ...DATETIME_OPTIONS,
opts: function () { showClear: props.required,
return { })
format: $("body").attr("data-timeformat"), .trigger('change')
locale: $("body").attr("data-datetimelocale"), .on('dp.change', function (this: HTMLElement) {
useCurrent: false, emit('input', $(this).data('DateTimePicker').date().format('HH:mm:ss'))
showClear: this.required, })
icons: { if (!props.value) {
time: 'fa fa-clock-o', $(input.value).data('DateTimePicker').viewDate(moment().hour(0).minute(0).second(0).millisecond(0))
date: 'fa fa-calendar', } else {
up: 'fa fa-chevron-up', $(input.value).data('DateTimePicker').date(props.value)
down: 'fa fa-chevron-down', }
previous: 'fa fa-chevron-left', })
next: 'fa fa-chevron-right',
today: 'fa fa-screenshot', onUnmounted(() => {
clear: 'fa fa-trash', $(input.value)
close: 'fa fa-remove' .off()
} .datetimepicker('destroy')
}; })
}
},
watch: {
value: function (val) {
$(this.$el).data('DateTimePicker').date(val);
},
},
destroyed: function () {
$(this.$el)
.off()
.datetimepicker("destroy");
}
}
</script> </script>
<template lang="pug">
input.form-control(ref="input")
</template>
@@ -1,255 +1,242 @@
<template> <script setup lang="ts">
<g> import { computed } from 'vue'
<path v-for="e in edges" :d="e" class="edge"></path> import { TEXTS, VARS, TYPEOPS } from './constants'
<path v-if="rootEdge" :d="rootEdge" class="edge"></path>
<path v-if="!node.children.length" :d="checkEdge" class="edge"></path>
<rect :width="boxWidth" :height="boxHeight" :x="x" :y="y" :class="nodeClass" rx="5">
</rect>
<foreignObject :width="boxWidth - 10" :height="boxHeight - 10" :x="x + 5" :y="y + 5"> declare const $: any
<div xmlns="http://www.w3.org/1999/xhtml" class="text"> declare const moment: any
<span v-if="vardata && vardata.type === 'int'">
<span v-if="variable.startsWith('entries_')" class="fa fa-sign-in"></span>
{{ vardata.label }}
<br>
<span v-if="varresult !== null">
{{varresult}}
</span>
<strong>
{{ op.label }} {{ rightoperand }}
</strong>
</span>
<span v-else-if="vardata && vardata.type === 'int_by_datetime'">
<span v-if="variable.startsWith('entries_')" class="fa fa-sign-in"></span>
{{ vardata.label }}
<span v-if="node.rule[operator][0][variable][0].buildTime[0] === 'custom'">
{{ df(node.rule[operator][0][variable][0].buildTime[1]) }}
</span>
<span v-else-if="node.rule[operator][0][variable][0].buildTime[0] === 'customtime'">
{{ tf(node.rule[operator][0][variable][0].buildTime[1]) }}
</span>
<span v-else>
{{ this.$root.texts[node.rule[operator][0][variable][0].buildTime[0]] }}
</span>
<br>
<span v-if="varresult !== null">
{{varresult}}
</span>
<strong>
{{ op.label }} {{ rightoperand }}
</strong>
</span>
<span v-else-if="vardata && variable === 'now'">
<span class="fa fa-clock-o"></span> {{ vardata.label }}<br>
<span v-if="varresult !== null">
{{varresult}}
</span>
<strong>
{{ op.label }}<br>
<span v-if="rightoperand.buildTime[0] === 'custom'">
{{ df(rightoperand.buildTime[1]) }}
</span>
<span v-else-if="rightoperand.buildTime[0] === 'customtime'">
{{ tf(rightoperand.buildTime[1]) }}
</span>
<span v-else>
{{ this.$root.texts[rightoperand.buildTime[0]] }}
</span>
<span v-if="operands[2]">
<span v-if="operator === 'isBefore'">+</span>
<span v-else>-</span>
{{ operands[2] }}
{{ this.$root.texts.minutes }}
</span>
</strong>
</span>
<span v-else-if="vardata && operator === 'inList'">
<span class="fa fa-sign-in" v-if="variable === 'gate'"></span>
<span class="fa fa-ticket" v-else></span>
{{ vardata.label }}
<span v-if="varresult !== null">
({{varresult}})
</span>
<br>
<strong>
{{ rightoperand.objectList.map((o) => o.lookup[2]).join(", ") }}
</strong>
</span>
<span v-else-if="vardata && vardata.type === 'enum_entry_status'">
<span class="fa fa-check-circle-o"></span>
{{ vardata.label }}
<span v-if="varresult !== null">
({{varresult}})
</span>
<br>
<strong>
{{ op.label }} {{ rightoperand }}
</strong>
</span>
</div>
</foreignObject>
<g v-if="result === false" :transform="`translate(${x + boxWidth - 15}, ${y - 10})`"> interface GraphNode {
<ellipse fill="#fff" cx="14.685823" cy="14.318233" rx="12.140151" ry="11.55523" /> rule: any
<path d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z" column: number
class="error" /> children: string[]
</g> y: number
<g v-if="result === true" :transform="`translate(${x + boxWidth - 15}, ${y - 10})`"> parent?: GraphNode
<ellipse fill="#fff" cx="14.685823" cy="14.318233" rx="12.140151" ry="11.55523" /> }
<path d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z"
class="check"/>
</g>
<g v-if="!node.children.length && (resultInclParents === null || resultInclParents === true)" :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`">
<path d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z"
class="check"/>
</g>
<g v-if="!node.children.length && (resultInclParents === false)" :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`">
<path d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z"
class="error" />
</g>
</g>
</template>
<script>
export default {
props: { const props = defineProps<{
node: Object, node: GraphNode
nodeid: String, nodeid: string
children: Array, children: GraphNode[]
boxWidth: Number, boxWidth: number
boxHeight: Number, boxHeight: number
marginX: Number, marginX: number
marginY: Number, marginY: number
paddingX: Number, paddingX: number
}, }>()
computed: {
x() {
return this.node.column * (this.boxWidth + this.marginX) + this.marginX / 2 + this.paddingX
},
y() {
return this.node.y * (this.boxHeight + this.marginY) + this.marginY / 2
},
edges() {
const startX = this.x + this.boxWidth + 1
const startY = this.y + this.boxHeight / 2
return this.children.map((c) => {
const endX = (c.column * (this.boxWidth + this.marginX) + this.marginX / 2 + this.paddingX) - 1
const endY = (c.y * (this.boxHeight + this.marginY) + this.marginY / 2) + this.boxHeight / 2
return ` const x = computed(() => {
return props.node.column * (props.boxWidth + props.marginX) + props.marginX / 2 + props.paddingX
})
const y = computed(() => {
return props.node.y * (props.boxHeight + props.marginY) + props.marginY / 2
})
const edges = computed(() => {
const startX = x.value + props.boxWidth + 1
const startY = y.value + props.boxHeight / 2
return props.children.map((c) => {
const endX = (c.column * (props.boxWidth + props.marginX) + props.marginX / 2 + props.paddingX) - 1
const endY = (c.y * (props.boxHeight + props.marginY) + props.marginY / 2) + props.boxHeight / 2
return `
M ${startX} ${startY} M ${startX} ${startY}
L ${endX - 50} ${startY} L ${endX - 50} ${startY}
C ${endX - 25} ${startY} ${endX - 25} ${startY} ${endX - 25} ${startY + 25 * Math.sign(endY - startY)} C ${endX - 25} ${startY} ${endX - 25} ${startY} ${endX - 25} ${startY + 25 * Math.sign(endY - startY)}
L ${endX - 25} ${endY - 25 * Math.sign(endY - startY)} L ${endX - 25} ${endY - 25 * Math.sign(endY - startY)}
C ${endX - 25} ${endY} ${endX - 25} ${endY} ${endX} ${endY} C ${endX - 25} ${endY} ${endX - 25} ${endY} ${endX} ${endY}
` `
}) })
}, })
checkEdge() {
const startX = this.x + this.boxWidth + 1
const startY = this.y + this.boxHeight / 2
return `M ${startX} ${startY} L ${startX + 25} ${startY}` const checkEdge = computed(() => {
}, const startX = x.value + props.boxWidth + 1
rootEdge() { const startY = y.value + props.boxHeight / 2
if (this.node.column > 0) {
return
}
const startX = 0
const startY = this.boxHeight / 2 + this.marginY / 2
const endX = this.x - 1
const endY = this.y + this.boxHeight / 2
return ` return `M ${startX} ${startY} L ${startX + 25} ${startY}`
})
const rootEdge = computed(() => {
if (props.node.column > 0) {
return
}
const startX = 0
const startY = props.boxHeight / 2 + props.marginY / 2
const endX = x.value - 1
const endY = y.value + props.boxHeight / 2
return `
M ${startX} ${startY} M ${startX} ${startY}
L ${endX - 50} ${startY} L ${endX - 50} ${startY}
C ${endX - 25} ${startY} ${endX - 25} ${startY} ${endX - 25} ${startY + 25 * Math.sign(endY - startY)} C ${endX - 25} ${startY} ${endX - 25} ${startY} ${endX - 25} ${startY + 25 * Math.sign(endY - startY)}
L ${endX - 25} ${endY - 25 * Math.sign(endY - startY)} L ${endX - 25} ${endY - 25 * Math.sign(endY - startY)}
C ${endX - 25} ${endY} ${endX - 25} ${endY} ${endX} ${endY} C ${endX - 25} ${endY} ${endX - 25} ${endY} ${endX} ${endY}
` `
}, })
variable () {
const op = this.operator;
if (this.node.rule[op] && this.node.rule[op][0]) {
if (this.node.rule[op][0]["entries_since"]) {
return "entries_since";
}
if (this.node.rule[op][0]["entries_before"]) {
return "entries_before";
}
if (this.node.rule[op][0]["entries_days_since"]) {
return "entries_days_since";
}
if (this.node.rule[op][0]["entries_days_before"]) {
return "entries_days_before";
}
return this.node.rule[op][0]["var"];
} else {
return "";
}
},
vardata () {
return this.$root.VARS[this.variable];
},
varresult () {
const op = this.operator;
if (this.node.rule[op] && this.node.rule[op][0]) {
if (typeof this.node.rule[op][0]["__result"] === "undefined")
return null;
return this.node.rule[op][0]["__result"];
} else {
return "";
}
},
rightoperand () {
const op = this.operator;
if (this.node.rule[op] && typeof this.node.rule[op][1] !== "undefined") {
return this.node.rule[op][1];
} else {
return null;
}
},
op: function () {
return this.$root.TYPEOPS[this.vardata.type][this.operator]
},
operands: function () {
return this.node.rule[this.operator]
},
operator: function () {
return Object.keys(this.node.rule).filter(function (k) { return !k.startsWith("__") })[0];
},
result: function () {
return typeof this.node.rule.__result == "undefined" ? null : !!this.node.rule.__result
},
resultInclParents: function () {
if (typeof this.node.rule.__result == "undefined")
return null
function _p(node) { const operator = computed(() => {
if (node.parent) { return Object.keys(props.node.rule).filter((k) => !k.startsWith('__'))[0]
return node.rule.__result && _p(node.parent) })
}
return node.rule.__result const variable = computed(() => {
} const op = operator.value
return _p(this.node) if (props.node.rule[op] && props.node.rule[op][0]) {
}, if (props.node.rule[op][0]['entries_since']) {
nodeClass: function () { return 'entries_since'
return { }
"node": true, if (props.node.rule[op][0]['entries_before']) {
"node-true": this.result === true, return 'entries_before'
"node-false": this.result === false, }
} if (props.node.rule[op][0]['entries_days_since']) {
} return 'entries_days_since'
}, }
methods: { if (props.node.rule[op][0]['entries_days_before']) {
df (val) { return 'entries_days_before'
const format = $("body").attr("data-datetimeformat") }
return moment(val).format(format) return props.node.rule[op][0]['var']
}, } else {
tf (val) { return ''
const format = $("body").attr("data-timeformat") }
return moment(val, "HH:mm:ss").format(format) })
}
}, const vardata = computed(() => {
} return VARS[variable.value as keyof typeof VARS]
})
const varresult = computed(() => {
const op = operator.value
if (props.node.rule[op] && props.node.rule[op][0]) {
if (typeof props.node.rule[op][0]['__result'] === 'undefined')
return null
return props.node.rule[op][0]['__result']
} else {
return ''
}
})
const rightoperand = computed(() => {
const op = operator.value
if (props.node.rule[op] && typeof props.node.rule[op][1] !== 'undefined') {
return props.node.rule[op][1]
} else {
return null
}
})
const op = computed(() => {
return TYPEOPS[vardata.value.type as keyof typeof TYPEOPS]?.[operator.value as any]
})
const operands = computed(() => {
return props.node.rule[operator.value]
})
const result = computed(() => {
return typeof props.node.rule.__result === 'undefined' ? null : !!props.node.rule.__result
})
const resultInclParents = computed(() => {
if (typeof props.node.rule.__result === 'undefined') return null
function _p (node: GraphNode): boolean {
if (node.parent) {
return node.rule.__result && _p(node.parent)
}
return node.rule.__result
}
return _p(props.node)
})
const nodeClass = computed(() => {
return {
node: true,
'node-true': result.value === true,
'node-false': result.value === false,
}
})
function df (val: string) {
const format = $('body').attr('data-datetimeformat')
return moment(val).format(format)
}
function tf (val: string) {
const format = $('body').attr('data-timeformat')
return moment(val, 'HH:mm:ss').format(format)
}
</script> </script>
<template lang="pug">
g
path.edge(v-for="e in edges", :key="e", :d="e")
path.edge(v-if="rootEdge", :d="rootEdge")
path.edge(v-if="!node.children.length", :d="checkEdge")
rect(:width="boxWidth", :height="boxHeight", :x="x", :y="y", :class="nodeClass", rx="5")
foreignObject(:width="boxWidth - 10", :height="boxHeight - 10", :x="x + 5", :y="y + 5")
div.text(xmlns="http://www.w3.org/1999/xhtml")
span(v-if="vardata && vardata.type === 'int'")
span.fa.fa-sign-in(v-if="variable.startsWith('entries_')")
| {{ vardata.label }}
br
span(v-if="varresult !== null") {{ varresult }}
strong
| {{ op?.label}} {{ rightoperand }}
span(v-else-if="vardata && vardata.type === 'int_by_datetime'")
span.fa.fa-sign-in(v-if="variable.startsWith('entries_')")
| {{ vardata.label }}
span(v-if="node.rule[operator][0][variable][0].buildTime[0] === 'custom'")
| {{ df(node.rule[operator][0][variable][0].buildTime[1]) }}
span(v-else-if="node.rule[operator][0][variable][0].buildTime[0] === 'customtime'")
| {{ tf(node.rule[operator][0][variable][0].buildTime[1]) }}
span(v-else)
| {{ TEXTS[node.rule[operator][0][variable][0].buildTime[0]] }}
br
span(v-if="varresult !== null") {{ varresult }}
strong
| {{ op?.label }} {{ rightoperand }}
span(v-else-if="vardata && variable === 'now'")
span.fa.fa-clock-o
| {{ vardata.label }}
br
span(v-if="varresult !== null") {{ varresult }}
strong
| {{ op?.label }}
br
span(v-if="rightoperand?.buildTime[0] === 'custom'")
| {{ df(rightoperand?.buildTime[1]) }}
span(v-else-if="rightoperand?.buildTime[0] === 'customtime'")
| {{ tf(rightoperand?.buildTime[1]) }}
span(v-else)
| {{ TEXTS[rightoperand?.buildTime[0]] }}
span(v-if="operands[2]")
span(v-if="operator === 'isBefore'") +
span(v-else) -
| {{ operands[2] }}
| {{ TEXTS.minutes }}
span(v-else-if="vardata && operator === 'inList'")
span.fa.fa-sign-in(v-if="variable === 'gate'")
span.fa.fa-ticket(v-else)
| {{ vardata.label }}
span(v-if="varresult !== null") ({{ varresult }})
br
strong
| {{ rightoperand?.objectList.map((o: any) => o.lookup[2]).join(", ") }}
span(v-else-if="vardata && vardata.type === 'enum_entry_status'")
span.fa.fa-check-circle-o
| {{ vardata.label }}
span(v-if="varresult !== null") ({{ varresult }})
br
strong
| {{ op?.label }} {{ rightoperand }}
g(v-if="result === false", :transform="`translate(${x + boxWidth - 15}, ${y - 10})`")
ellipse(fill="#fff", cx="14.685823", cy="14.318233", rx="12.140151", ry="11.55523")
path.error(d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z")
g(v-if="result === true", :transform="`translate(${x + boxWidth - 15}, ${y - 10})`")
ellipse(fill="#fff", cx="14.685823", cy="14.318233", rx="12.140151", ry="11.55523")
path.check(d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z")
g(v-if="!node.children.length && (resultInclParents === null || resultInclParents === true)", :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`")
path.check(d="m 25.078125,11.835938 c 0,-0.332032 -0.117188,-0.664063 -0.351563,-0.898438 L 22.949219,9.1796875 c -0.234375,-0.234375 -0.546875,-0.3710937 -0.878907,-0.3710937 -0.332031,0 -0.644531,0.1367187 -0.878906,0.3710937 L 13.222656,17.128906 8.8085938,12.714844 C 8.5742188,12.480469 8.2617188,12.34375 7.9296875,12.34375 c -0.3320313,0 -0.6445313,0.136719 -0.8789063,0.371094 l -1.7773437,1.757812 c -0.234375,0.234375 -0.3515625,0.566407 -0.3515625,0.898438 0,0.332031 0.1171875,0.644531 0.3515625,0.878906 l 7.0703125,7.070312 c 0.234375,0.234375 0.566406,0.371094 0.878906,0.371094 0.332032,0 0.664063,-0.136719 0.898438,-0.371094 L 24.726562,12.714844 c 0.234375,-0.234375 0.351563,-0.546875 0.351563,-0.878906 z M 30,15 C 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 23.28125,0 30,6.71875 30,15 Z")
g(v-if="!node.children.length && (resultInclParents === false)", :transform="`translate(${x + boxWidth + 25}, ${y + boxHeight/2 - 15})`")
path.error(d="M 15,0 C 23.28125,0 30,6.71875 30,15 30,23.28125 23.28125,30 15,30 6.71875,30 0,23.28125 0,15 0,6.71875 6.71875,0 15,0 Z m 2.5,24.35547 V 20.64453 C 17.5,20.29297 17.22656,20 16.89453,20 h -3.75 C 12.79297,20 12.5,20.29297 12.5,20.64453 v 3.71094 C 12.5,24.70703 12.79297,25 13.14453,25 h 3.75 C 17.22656,25 17.5,24.70703 17.5,24.35547 Z M 17.4609,17.63672 17.81246,5.50781 c 0,-0.13672 -0.0586,-0.27343 -0.19531,-0.35156 C 17.49996,5.05855 17.32418,5 17.1484,5 h -4.29688 c -0.17578,0 -0.35156,0.0586 -0.46875,0.15625 -0.13672,0.0781 -0.19531,0.21484 -0.19531,0.35156 l 0.33203,12.12891 c 0,0.27344 0.29297,0.48828 0.66406,0.48828 h 3.61329 c 0.35156,0 0.64453,-0.21484 0.66406,-0.48828 z")
</template>
@@ -114,8 +114,13 @@ var setCookie = function (cname, cvalue, exdays) {
var expires = "expires=Thu, 01 Jan 1970 00:00:00 GMT"; var expires = "expires=Thu, 01 Jan 1970 00:00:00 GMT";
cvalue = ""; cvalue = "";
} }
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/"; var same_site = "";
if (site_is_secure()) {
same_site = ";SameSite=None;Secure"
}
document.cookie = cname + "=" + cvalue + ";" + expires + same_site + ";path=/";
}; };
var getCookie = function (name) { var getCookie = function (name) {
var value = "; " + document.cookie; var value = "; " + document.cookie;
var parts = value.split("; " + name + "="); var parts = value.split("; " + name + "=");
@@ -2052,11 +2057,16 @@ var shared_root_methods = {
}) })
}, },
get_cart_id: function() { get_cart_id: function() {
if (this.$root.keep_cart) { if (!this.$root.keep_cart) {
return getCookie(this.$root.cookieName); return null
} }
if (this.$root.cart_id) {
return this.$root.cart_id
}
return getCookie(this.$root.cookieName);
}, },
set_cart_id: function(newValue) { set_cart_id: function(newValue) {
this.$root.cart_id = newValue
setCookie(this.$root.cookieName, newValue, 30); setCookie(this.$root.cookieName, newValue, 30);
}, },
}; };
@@ -2359,6 +2369,7 @@ var create_widget = function (element, html_id=null) {
has_seating_plan_waitinglist: false, has_seating_plan_waitinglist: false,
meta_filter_fields: [], meta_filter_fields: [],
keep_cart: true, keep_cart: true,
cart_id: null
} }
}, },
created: function () { created: function () {
@@ -2450,6 +2461,7 @@ var create_button = function (element, html_id=null) {
html_id: html_id, html_id: html_id,
button_text: button_text, button_text: button_text,
keep_cart: keep_cart || items.length > 0, keep_cart: keep_cart || items.length > 0,
cart_id: null
} }
}, },
created: function () { created: function () {
@@ -2525,7 +2537,8 @@ window.PretixWidget.open = function (target_url, voucher, subevent, items, widge
widget_data: all_widget_data, widget_data: all_widget_data,
widget_id: 'pretix-widget-' + widget_id, widget_id: 'pretix-widget-' + widget_id,
button_text: "", button_text: "",
keep_cart: true keep_cart: true,
cart_id: null
} }
}, },
created: function () { created: function () {
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pretix Widget</title>
<link id="widget-css" rel="stylesheet" type="text/css" crossorigin>
</head>
<body>
<div id="widget-container"></div>
<script>
{
const params = new URLSearchParams(window.location.search)
const knownParams = new Set(['type', 'host', 'org', 'event', 'mode', 'lang', 'button-text'])
const type = params.get('type') || 'widget'
const host = params.get('host') || 'http://localhost:8000'
const org = params.get('org') || 'testorg'
const event = params.get('event') || 'testevent'
const mode = params.get('mode') || 'dev'
const lang = params.get('lang') || 'de'
const baseUrl = `${host}/${org}/${event}`
document.getElementById('widget-css').href = `${baseUrl}/widget/v2.css`
const el = document.createElement(type === 'button' ? 'pretix-button' : 'pretix-widget')
el.setAttribute('event', `${baseUrl}/`)
if (type === 'button') {
el.textContent = params.get('button-text') || 'Buy tickets!'
}
for (const [key, value] of params) {
if (knownParams.has(key)) continue
el.setAttribute(key, value)
}
document.getElementById('widget-container').appendChild(el)
const script = document.createElement('script')
if (mode === 'prod') {
Object.assign(script, { type: 'text/javascript', src: `${host}/widget/v2.${lang}.js`, async: true, crossOrigin: 'anonymous' })
} else {
Object.assign(script, { type: 'module', src: '/src/main.ts' })
}
document.body.appendChild(script)
}
</script>
</body>
</html>
@@ -0,0 +1,93 @@
import type { Category, DayEntry, EventEntry, MetaFilterField } from '~/types'
export class ApiError extends Error {
status: number
responseUrl: string
constructor (status: number, responseUrl: string) {
super(`HTTP ${status}`)
this.status = status
this.responseUrl = responseUrl
}
}
// --- Product list ---
export interface ProductListResponse {
target_url?: string
subevent?: string | number
name?: string
frontpage_text?: string
date_range?: string
location?: string
items_by_category?: Category[]
currency?: string
display_net_prices?: boolean
voucher_explanation_text?: string
error?: string
display_add_to_cart?: boolean
waiting_list_enabled?: boolean
show_variations_expanded?: boolean
cart_exists?: boolean
vouchers_exist?: boolean
has_seating_plan?: boolean
has_seating_plan_waitinglist?: boolean
itemnum?: number
poweredby?: string
events?: EventEntry[]
has_more_events?: boolean
meta_filter_fields?: MetaFilterField[]
weeks?: DayEntry[][]
date?: string
days?: DayEntry[]
week?: [number, number]
}
export async function fetchProductList (url: string) {
const response = await fetch(url)
if (!response.ok) {
throw new ApiError(response.status, response.url)
}
return {
data: await response.json() as ProductListResponse,
responseUrl: response.url,
}
}
export interface CartResponse {
redirect?: string
cart_id?: string
success?: boolean
message?: string
has_cart?: boolean
async_id?: string
check_url?: string
}
export async function submitCart (endpoint: string, formData: FormData) {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(formData as any).toString(),
})
if (!response.ok) {
throw new ApiError(response.status, response.url)
}
return await response.json() as CartResponse
}
export async function checkAsyncTask (url: string) {
const response = await fetch(url)
if (!response.ok) {
throw new ApiError(response.status, response.url)
}
return await response.json() as CartResponse
}
export async function createCart (url: string) {
const response = await fetch(url)
if (!response.ok) {
throw new ApiError(response.status, response.url)
}
return await response.json() as CartResponse
}
@@ -0,0 +1,67 @@
import { createApp, type App } from 'vue'
import ButtonComponent from '~/components/Button.vue'
import { createWidgetStore, StoreKey } from '~/sharedStore'
import { makeid } from '~/utils'
import type { WidgetData } from '~/types'
export function createButtonInstance (element: Element, htmlId?: string): App {
let targetUrl = element.attributes.event.value
if (!targetUrl.match(/\/$/)) {
targetUrl += '/'
}
const widgetData: WidgetData = JSON.parse(JSON.stringify(window.PretixWidget.widget_data))
for (const attr of Array.from(element.attributes)) {
if (attr.name.match(/^data-.*$/)) {
widgetData[attr.name.replace(/^data-/, '')] = attr.value
}
}
const rawItems = element.attributes.items?.value || ''
// Parse items string (format: "item_1=2,item_3=1")
const buttonItems: { item: string; count: string }[] = []
for (const itemStr of rawItems.split(',')) {
if (itemStr.includes('=')) {
const [item, count] = itemStr.split('=')
buttonItems.push({ item, count })
}
}
const store = createWidgetStore({
targetUrl,
voucher: element.attributes.voucher?.value || null,
subevent: element.attributes.subevent?.value || null,
skipSsl: 'skip-ssl-check' in element.attributes,
disableIframe: 'disable-iframe' in element.attributes,
widgetData,
htmlId: htmlId || element.id || makeid(16),
isButton: true,
buttonItems,
buttonText: element.innerHTML,
keepCart: 'keep-cart' in element.attributes || buttonItems.length > 0,
})
const observer = new MutationObserver((mutationList) => {
for (const mutation of mutationList) {
if (mutation.type === 'attributes' && mutation.attributeName?.startsWith('data-')) {
const attrName = mutation.attributeName.substring(5)
const attrValue = (mutation.target as Element).getAttribute(mutation.attributeName)
if (attrValue !== null) {
store.widgetData[attrName] = attrValue
}
}
}
})
const app = createApp(ButtonComponent)
app.provide(StoreKey, store)
app.config.errorHandler = (error, _vm, info) => {
console.error('[pretix-button]', info, error)
}
app.mount(element)
observer.observe(element, { attributes: true })
return app
}
@@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed, ref, inject, onMounted } from 'vue'
import type { Item, Variation } from '~/types'
import { StoreKey, globalWidgetId } from '~/sharedStore'
import { STRINGS } from '~/i18n'
const props = defineProps<{
item: Item
variation?: Variation
}>()
const store = inject(StoreKey)!
const quantity = ref<HTMLInputElement>()
const avail = computed(() => props.item.has_variations ? props.variation.avail : props.item.avail)
const orderMax = computed(() => props.item.has_variations ? props.variation.order_max : props.item.order_max)
const inputName = computed(() => {
if (props.item.has_variations) {
return `variation_${props.item.id}_${props.variation.id}`
}
return `item_${props.item.id}`
})
const unavailabilityReasonMessage = computed(() => {
const reason = props.item.current_unavailability_reason || props.variation?.current_unavailability_reason
if (reason) {
return STRINGS[`unavailable_${reason}`] || reason
}
return ''
})
const voucherJumpLink = computed(() => `#${store.htmlId}-voucher-input`)
const ariaLabelledby = computed(() => `${store.htmlId}-item-label-${props.item.id}`)
const decLabel = computed(() => {
// TODO
const name = props.item.has_variations ? props.variation.value : props.item.name
return `- ${name}: ${STRINGS.quantity_dec}`
})
const incLabel = computed(() => {
const name = props.item.has_variations ? props.variation.value : props.item.name
return `+ ${name}: ${STRINGS.quantity_inc}`
})
const labelSelectItem = computed(() => {
if (props.item.has_variations) return STRINGS.select_variant.replace('%s', props.variation.value)
return STRINGS.select_item.replace('%s', props.item.name)
})
const waitingListShow = computed(() => avail.value[0] < 100 && store.waitingListEnabled && props.item.allow_waitinglist)
const waitingListUrl = computed(() => {
let u = `${store.targetUrl}w/${globalWidgetId}/waitinglist/?locale=${LANG}&item=${props.item.id}`
if (props.item.has_variations && props.variation) {
u += `&var=${props.variation.id}`
}
if (store.subevent) {
u += `&subevent=${store.subevent}`
}
u += `&widget_data=${encodeURIComponent(store.widgetDataJson)}`
u += store.consentParameter
return u
})
function onStep (e: Event) {
const target = e.target as HTMLElement
const button = target.tagName === 'BUTTON' ? target : target.closest('button')
if (!button || !quantity.value) return
const step = parseFloat(button.getAttribute('data-step') || '0')
const input = quantity.value
const min = parseFloat(input.min) || 0
const max = parseFloat(input.max) || Number.MAX_SAFE_INTEGER
const currentValue = parseInt(input.value || '0')
input.value = String(Math.max(min, Math.min(max, currentValue + step)))
input.dispatchEvent(new CustomEvent('change', { bubbles: true }))
}
onMounted(() => {
// Auto-select first item if single item with no variations
if (
!store.cartExists
&& store.itemnum === 1
&& (!store.categories[0]?.items[0]?.has_variations || store.categories[0]?.items[0]?.variations.length < 2)
&& !store.hasSeatingPlan
&& quantity.value
) {
quantity.value.value = '1'
if (orderMax.value === 1 && quantity.value.type === 'checkbox') {
;(quantity.value as HTMLInputElement).checked = true
}
}
})
</script>
<template lang="pug">
.pretix-widget-availability-box
.pretix-widget-availability-unavailable(v-if="item.current_unavailability_reason === 'require_voucher'")
small
a(:href="voucherJumpLink", :aria-describedby="ariaLabelledby") {{ unavailabilityReasonMessage }}
.pretix-widget-availability-unavailable(v-else-if="unavailabilityReasonMessage")
small {{ unavailabilityReasonMessage }}
.pretix-widget-availability-unavailable(v-else-if="avail[0] < 100 && avail[0] > 10") {{ STRINGS.reserved }}
.pretix-widget-availability-gone(v-else-if="avail[0] <= 10") {{ STRINGS.sold_out }}
.pretix-widget-waiting-list-link(v-if="waitingListShow && !unavailabilityReasonMessage")
a(:href="waitingListUrl", target="_blank", @click="$root.open_link_in_frame") {{ STRINGS.waiting_list }}
.pretix-widget-availability-available(v-if="!unavailabilityReasonMessage && avail[0] === 100")
label.pretix-widget-item-count-single-label.pretix-widget-btn-checkbox(v-if="orderMax === 1")
input(
ref="quantity",
type="checkbox",
value="1",
:name="inputName",
:aria-label="labelSelectItem"
)
span.pretix-widget-icon-cart(aria-hidden="true")
| {{ STRINGS.select }}
.pretix-widget-item-count-group(v-else, role="group", :aria-label="item.name")
button.pretix-widget-btn-default.pretix-widget-item-count-dec(
type="button",
data-step="-1",
:data-controls="`input_${inputName}`",
:aria-label="decLabel",
@click.prevent.stop="onStep"
)
span -
input.pretix-widget-item-count-multiple(
:id="`input_${inputName}`",
ref="quantity",
type="number",
inputmode="numeric",
pattern="\\d*",
placeholder="0",
min="0",
:max="orderMax",
:name="inputName",
:aria-labelledby="ariaLabelledby"
)
button.pretix-widget-btn-default.pretix-widget-item-count-inc(
type="button",
data-step="1",
:data-controls="`input_${inputName}`",
:aria-label="incLabel",
@click.prevent.stop="onStep"
)
span +
</template>
<style lang="sass">
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { StoreKey } from '~/sharedStore'
import Overlay from './Overlay.vue'
const lang = LANG // we need this so the template sees the variable
const store = inject(StoreKey)!
const form = ref<HTMLFormElement>()
const formMethod = computed(() => {
if (!store.useIframe && store.isButton && store.items.length === 0) {
return 'get'
}
return 'post'
})
function handleBuy (event: Event) {
if (form.value) {
const formData = new FormData(form.value)
store.buy(formData, event)
}
}
defineExpose({
form,
buy: handleBuy,
})
</script>
<template lang="pug">
.pretix-widget-wrapper
.pretix-widget-button-container
form(ref="form", :method="formMethod", :action="store.formAction", :target="store.formTarget")
input(v-if="store.voucherCode", type="hidden", name="_voucher_code", :value="store.voucherCode")
input(v-if="store.voucherCode", type="hidden", name="voucher", :value="store.voucherCode")
input(type="hidden", name="subevent", :value="store.subevent")
input(type="hidden", name="locale", :value="lang")
input(type="hidden", name="widget_data", :value="store.widgetDataJson")
input(v-if="store.consentParameterValue", type="hidden", name="consent", :value="store.consentParameterValue")
input(
v-for="item in store.items",
:key="item.item",
type="hidden",
:name="item.item",
:value="item.count"
)
button.pretix-button(@click="handleBuy", v-html="store.buttonText")
.pretix-widget-clear
Overlay
</template>
<style lang="sass">
</style>

Some files were not shown because too many files have changed in this diff Show More