Compare commits

...
Author SHA1 Message Date
luelistaandGitHub d6b2e109cf Only set vat_id_validated if vat_id non-empty 2026-05-22 14:40:39 +02:00
Richard SchreiberandGitHub 909ce5b27d Add NoUrlValidator to validators (#6208) 2026-05-22 13:51:28 +02:00
Raphael MichelandGitHub c7b82fdc97 Subevent update: Save SubEvent model before saving plugin forms (#6209) 2026-05-22 13:22:04 +02:00
pajowuandGitHub da380ed75e Show invoice_dirty status on order details page (Z#23230731) (#6174) 2026-05-22 10:25:30 +02:00
George HickmanandGitHub 687c7e3ccf Pin node version to 24 with .node-version (#6207) 2026-05-22 09:34:30 +02:00
robbi5andGitHub 484b7141d9 Show device name on order print log (#6198) 2026-05-22 09:02:59 +02:00
Richard SchreiberandGitHub f60031d67b Make disable_url optional for notifications (#6202) 2026-05-22 07:59:56 +02:00
dd29063a84 banktransfer import: Fix prefix confusion if shorter event name contains dash (Z#23234167) (#6189)
Co-authored-by: luelista <weller@rami.io>
2026-05-21 13:03:30 +02:00
Phumraphee Sae-tangandRaphael Michel f37dfbd21a Translations: Update Thai
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-05-21 10:59:46 +02:00
Phumraphee Sae-tangandRaphael Michel bb8ef00d49 Translations: Update Thai
Currently translated at 36.4% (2297 of 6295 strings)

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

powered by weblate
2026-05-21 10:59:46 +02:00
NikolaiandRaphael Michel d13c654596 Translations: Update Danish
Currently translated at 60.7% (3825 of 6295 strings)

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

powered by weblate
2026-05-21 10:59:46 +02:00
Khalid ShaheenandRaphael Michel 2cc73baa99 Translations: Update Arabic
Currently translated at 58.3% (3673 of 6295 strings)

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

powered by weblate
2026-05-21 10:59:46 +02:00
Richard SchreiberandGitHub f740d46d47 Add template-filter human_readable_locale (#6193) 2026-05-20 12:57:57 +02:00
Martin Gross 412a5adf8f Control: Fix typo in gateSelectURL 2026-05-19 22:03:35 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e4da2e5e03 Update reportlab requirement from ==4.4.* to ==4.5.* (#6138)
Updates the requirements on [reportlab](https://www.reportlab.com/) to permit the latest version.

---
updated-dependencies:
- dependency-name: reportlab
  dependency-version: 4.5.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 18:03:32 +02:00
9d7038b127 Force async_task_is_download to be downloaded if in iframe (Z#23234427) (#6194)
* Force async_task_is_download to be downloaded if in iframe (Z#23234427)

* Update src/pretix/static/pretixbase/js/asynctask.js

Co-authored-by: luelista <weller@rami.io>

---------

Co-authored-by: luelista <weller@rami.io>
2026-05-19 17:07:54 +02:00
Richard SchreiberandGitHub ce5af572cc Improve error messages for name-parts inputs (Z#23234440) (#6187)
* Improve error messages for name-parts inputs (Z#23234440)

* fix isort after flake8

* correct spelling of .med in user-provided title/name

* fix search instead of match
2026-05-19 15:23:30 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6d293e544e Bump django-formtools from 2.5.1 to 2.6.1 (#6191)
Bumps [django-formtools](https://github.com/jazzband/django-formtools) from 2.5.1 to 2.6.1.
- [Changelog](https://github.com/jazzband/django-formtools/blob/master/docs/changelog.rst)
- [Commits](https://github.com/jazzband/django-formtools/compare/2.5.1...2.6.1)

---
updated-dependencies:
- dependency-name: django-formtools
  dependency-version: 2.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 11:36:49 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
28a8032adf Update sentry-sdk requirement from ==2.59.* to ==2.60.* (#6190)
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.59.0a1...2.60.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.60.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 11:36:25 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Raphael Michel
d765a89139 Update djangorestframework requirement from ==3.16.* to ==3.17.* (#6013)
* Update djangorestframework requirement from ==3.16.* to ==3.17.*

Updates the requirements on [djangorestframework](https://github.com/encode/django-rest-framework) to permit the latest version.
- [Release notes](https://github.com/encode/django-rest-framework/releases)
- [Commits](https://github.com/encode/django-rest-framework/compare/3.16.0...3.17.0)

---
updated-dependencies:
- dependency-name: djangorestframework
  dependency-version: 3.17.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fix failing test

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@rami.io>
2026-05-18 17:58:43 +02:00
Richard SchreiberandGitHub 3df5b1d075 Add cssclass for footer-nav and improve button-style in footer (#6167) 2026-05-18 13:16:50 +02:00
Martin GrossandGitHub 857791445f Docs: Add Exhibitor API docs (Z#23225216) (#6184)
* Docs: Add Exhibitor API docs

* Docs: Add Resource-table for Exhibitor vouchers
2026-05-18 12:02:27 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Raphael Michel
52b28997a2 Update fakeredis requirement from ==2.34.* to ==2.35.* (#6072)
* Update fakeredis requirement from ==2.34.* to ==2.35.*

Updates the requirements on [fakeredis](https://github.com/cunla/fakeredis-py) to permit the latest version.
- [Release notes](https://github.com/cunla/fakeredis-py/releases)
- [Commits](https://github.com/cunla/fakeredis-py/compare/v2.34.0...v2.35.0)

---
updated-dependencies:
- dependency-name: fakeredis
  dependency-version: 2.35.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update class name

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@rami.io>
2026-05-18 09:48:51 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f65a6aa11f Update cryptography requirement from >=47.0.0 to >=48.0.0 (#6177)
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/47.0.0...48.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 16:39:18 +02:00
dependabot[bot]andRaphael Michel 9faca5ea24 Update sentry-sdk requirement from ==2.58.* to ==2.59.*
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.58.0a1...2.59.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.59.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:36:35 +02:00
dependabot[bot]andRaphael Michel 867512eee5 Bump picomatch
Bumps  and [picomatch](https://github.com/micromatch/picomatch). These dependencies needed to be updated together.

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:34:46 +02:00
Raphael MichelandRaphael Michel 1436b65347 Add webhooks for quota changes (Z#23232443) 2026-05-17 16:33:20 +02:00
sweenuandRaphael Michel cc06588991 Rephrase refund info paragraph 2026-05-17 16:33:15 +02:00
dependabot[bot]andRaphael Michel 32bd9fa265 Bump vite from 8.0.0 to 8.0.12
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.0 to 8.0.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.12
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:32:56 +02:00
dependabot[bot]andRaphael Michel bdc9b155f9 Bump flatted from 3.3.3 to 3.4.2
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:32:49 +02:00
dependabot[bot]andRaphael Michel 1af2941594 Bump postcss from 8.5.8 to 8.5.14
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.14.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.14)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:32:45 +02:00
dependabot[bot]andRaphael Michel 11dc1e6f70 Bump arabic-reshaper from 3.0.0 to 3.0.1
Bumps [arabic-reshaper](https://github.com/mpcabd/python-arabic-reshaper) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/mpcabd/python-arabic-reshaper/releases)
- [Commits](https://github.com/mpcabd/python-arabic-reshaper/compare/v3.0.0...v3.0.1)

---
updated-dependencies:
- dependency-name: arabic-reshaper
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 16:17:45 +02:00
NikolaiandRaphael Michel e08243e3b2 Translations: Update Danish
Currently translated at 59.1% (3725 of 6295 strings)

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

powered by weblate
2026-05-17 15:33:11 +02:00
Yasunobu YesNo KawaguchiandRaphael Michel 3a4e30f2ec Translations: Update Japanese
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-05-17 15:33:11 +02:00
Yasunobu YesNo KawaguchiandRaphael Michel ea2fa741f5 Translations: Update Japanese
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-17 15:33:11 +02:00
Stefano CampusandRaphael Michel 20d1bb9d32 Translations: Update Italian
Currently translated at 40.0% (2521 of 6295 strings)

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

powered by weblate
2026-05-17 15:33:11 +02:00
Hijiri UmemotoandRaphael Michel ad48d592e7 Translations: Update Japanese
Currently translated at 100.0% (6295 of 6295 strings)

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

powered by weblate
2026-05-17 15:33:11 +02:00
pajowuandGitHub 4861aca640 Fix timepicker in checkinrules (#6182)
The timepickers format was changed by accident to the datetimeformat in the vue3 migration
2026-05-12 16:17:24 +02:00
pajowuandGitHub 82450c8250 Handle related fields in export_form_data (Z#23233538) (#6157) 2026-05-12 14:55:25 +02:00
Richard SchreiberandGitHub b21b69b2b8 Fix playwright install on CI (#6180) 2026-05-12 13:14:05 +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
141 changed files with 14459 additions and 9708 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 -1
View File
@@ -1 +1 @@
17 24
+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
+184
View File
@@ -844,3 +844,187 @@ You can also fetch existing leads (if you are authorized to do so):
:statuscode 200: No error :statuscode 200: No error
:statuscode 401: Invalid authentication code :statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data :statuscode 403: Not permitted to access bulk data
Retrieving Vouchers
"""""""""""""""""""
Vouchers returned by the App API use a different format than described in :ref:`rest-vouchers`.
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the voucher
code string The voucher code that is required to redeem the voucher
max_usages integer The maximum number of times this voucher can be
redeemed (default: 1).
redeemed integer The number of times this voucher already has been
redeemed.
valid_until datetime The voucher expiration date (or ``null``).
subevent string Name of the date inside an event series this voucher belongs to (or ``null``).
tag string A string that is used for grouping vouchers
comment string An internal exhibitor comment on the voucher.
items list of strings A list of items this voucher is restricted to (or ``null``).
price_mode string Determines how this voucher affects product prices.
Possible values:
* ``none`` No effect on price
* ``set`` The product price is set to the given ``value``
* ``subtract`` The product price is determined by the original price *minus* the given ``value``
* ``percent`` The product price is determined by the original price reduced by the percentage given in ``value``
value decimal (string) The value (see ``price_mode``)
redemptions list of objects A list of objects, where each object represents an order position that has been purchased using the voucher.
Each entry will contains the fields ``attendee_fields``, ``redemption_date`` and ``subevent``.
The attendee data in the ``attendee_fields`` that is shown is based on the event's configuration, and each entry
contains the fields ``id``, ``label``, ``value``, and ``details``. ``details`` is usually empty
except in a few cases where it contains an additional list of objects
with ``value`` and ``label`` keys (e.g. splitting of names).
===================================== ========================== =======================================================
.. http:get:: /exhibitors/api/v1/vouchers/
Returns a list of all vouchers connected to the exhibitor.
Note that the ``attendee_fields`` array can contain any number of dynamic keys!
Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details.
The app should dynamically show these values (read-only) with the labels sent by the server.
**Example request**:
.. sourcecode:: http
GET /exhibitors/api/v1/vouchers/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
"valid_until": null,
"subevent": null,
"tag": "testvoucher",
"comment": "",
"items": [
"All"
],
"price_mode": "set",
"value": "12.00",
"redemptions": [
{
"attendee_fields": [
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com",
"details": []
}
],
"redemption_date": "2026-05-06",
"subevent": null
},
]
}
]
}
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data
.. http:get:: /exhibitors/api/v1/vouchers/(id)/
Returns the details of a single, specific voucher connected to the exhibitor.
Note that the ``attendee_fields`` array can contain any number of dynamic keys!
Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details.
The app should dynamically show these values (read-only) with the labels sent by the server.
**Example request**:
.. sourcecode:: http
GET /exhibitors/api/v1/vouchers/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
"valid_until": null,
"subevent": null,
"tag": "testvoucher",
"comment": "",
"items": [
"All"
],
"price_mode": "set",
"value": "12.00",
"redemptions": [
{
"attendee_fields": [
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com",
"details": []
}
],
"redemption_date": "2026-05-06",
"subevent": null
},
]
}
:param id: The ``id`` field of the voucher to fetch
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data
:statuscode 404: Voucher not found in system
+1
View File
@@ -70,6 +70,7 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.subevent.changed`` * ``pretix.subevent.changed``
* ``pretix.subevent.deleted`` * ``pretix.subevent.deleted``
* ``pretix.event.item.*`` * ``pretix.event.item.*``
* ``pretix.event.quota.*``
* ``pretix.event.live.activated`` * ``pretix.event.live.activated``
* ``pretix.event.live.deactivated`` * ``pretix.event.live.deactivated``
* ``pretix.event.testmode.activated`` * ``pretix.event.testmode.activated``
+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',
},
},
},
])
+4781
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"
}
}
+9 -7
View File
@@ -27,13 +27,13 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab "arabic-reshaper==3.0.1", # Support for Arabic in reportlab
"babel", "babel",
"BeautifulSoup4==4.14.*", "BeautifulSoup4==4.14.*",
"bleach==6.3.*", "bleach==6.3.*",
"celery==5.6.*", "celery==5.6.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=47.0.0", "cryptography>=48.0.0",
"css-inline==0.20.*", "css-inline==0.20.*",
"defusedcsv>=3.0.0", "defusedcsv>=3.0.0",
"dnspython==2.*", "dnspython==2.*",
@@ -43,7 +43,7 @@ dependencies = [
"django-countries==8.2.*", "django-countries==8.2.*",
"django-filter==25.1", "django-filter==25.1",
"django-formset-js-improved==0.5.0.5", "django-formset-js-improved==0.5.0.5",
"django-formtools==2.5.1", "django-formtools==2.6.1",
"django-hierarkey==2.0.*,>=2.0.1", "django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*", "django-hijack==3.7.*",
"django-i18nfield==1.11.*", "django-i18nfield==1.11.*",
@@ -56,7 +56,7 @@ dependencies = [
"django-redis==6.0.*", "django-redis==6.0.*",
"django-scopes==2.0.*", "django-scopes==2.0.*",
"django-statici18n==2.7.*", "django-statici18n==2.7.*",
"djangorestframework==3.16.*", "djangorestframework==3.17.*",
"dnspython==2.8.*", "dnspython==2.8.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
"geoip2==5.*", "geoip2==5.*",
@@ -91,9 +91,9 @@ dependencies = [
"pyuca", "pyuca",
"qrcode==8.2", "qrcode==8.2",
"redis==7.4.*", "redis==7.4.*",
"reportlab==4.4.*", "reportlab==4.5.*",
"requests==2.32.*", "requests==2.32.*",
"sentry-sdk==2.58.*", "sentry-sdk==2.60.*",
"sepaxml==2.7.*", "sepaxml==2.7.*",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
@@ -111,7 +111,7 @@ dev = [
"aiohttp==3.13.*", "aiohttp==3.13.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.34.*", "fakeredis==2.35.*",
"flake8==7.3.*", "flake8==7.3.*",
"freezegun", "freezegun",
"isort==8.0.*", "isort==8.0.*",
@@ -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
+26 -20
View File
@@ -133,37 +133,43 @@ class JobRunSerializer(serializers.Serializer):
return not bool(self._errors) return not bool(self._errors)
class ExportFormDataField(serializers.Field):
def get_attribute(self, instance):
return (instance.export_identifier, instance.export_form_data)
def to_representation(self, value):
export_identifier, export_form_data = value
exporter = self.context['exporters'].get(export_identifier)
if exporter:
return JobRunSerializer(exporter=exporter).to_representation(export_form_data)
else:
return export_form_data
def get_value(self, dictionary):
return dictionary
def to_internal_value(self, data):
if "export_form_data" in data:
identifier = data.get('export_identifier', self.parent.instance.export_identifier if self.parent.instance else None)
exporter = self.context['exporters'].get(identifier)
if exporter:
return JobRunSerializer(exporter=exporter).to_internal_value(data["export_form_data"])
else:
return data['export_form_data']
class ScheduledExportSerializer(serializers.ModelSerializer): class ScheduledExportSerializer(serializers.ModelSerializer):
schedule_next_run = serializers.DateTimeField(read_only=True) schedule_next_run = serializers.DateTimeField(read_only=True)
export_identifier = serializers.ChoiceField(choices=[]) export_identifier = serializers.ChoiceField(choices=[])
locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en') locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en')
owner = serializers.SlugRelatedField(slug_field='email', read_only=True) owner = serializers.SlugRelatedField(slug_field='email', read_only=True)
error_counter = serializers.IntegerField(read_only=True) error_counter = serializers.IntegerField(read_only=True)
export_form_data = ExportFormDataField()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['export_identifier'].choices = [(e, e) for e in self.context['exporters']] self.fields['export_identifier'].choices = [(e, e) for e in self.context['exporters']]
def validate(self, attrs):
if attrs.get("export_form_data"):
identifier = attrs.get('export_identifier', self.instance.export_identifier if self.instance else None)
exporter = self.context['exporters'].get(identifier)
if exporter:
try:
attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
except ValidationError as e:
raise ValidationError({"export_form_data": e.detail})
else:
raise ValidationError({"export_identifier": ["Unknown exporter."]})
return attrs
def to_representation(self, instance):
repr = super().to_representation(instance)
exporter = self.context['exporters'].get(instance.export_identifier)
if exporter:
repr["export_form_data"] = JobRunSerializer(exporter=exporter).to_representation(repr["export_form_data"])
return repr
def validate_mail_additional_recipients(self, value): def validate_mail_additional_recipients(self, value):
d = value.replace(' ', '') d = value.replace(' ', '')
if len(d.split(',')) > 25: if len(d.split(',')) > 25:
+6
View File
@@ -45,6 +45,12 @@ class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
return value return value
return super().to_representation(value) return super().to_representation(value)
def to_internal_value(self, data):
value = super().to_internal_value(data)
if value is not None:
return value.pk
return value
class FormFieldWrapperField(serializers.Field): class FormFieldWrapperField(serializers.Field):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
+6
View File
@@ -408,6 +408,12 @@ def register_default_webhook_events(sender, **kwargs):
_('This includes product added or deleted and changes to nested objects like ' _('This includes product added or deleted and changes to nested objects like '
'variations or bundles.'), 'variations or bundles.'),
), ),
ParametrizedItemWebhookEvent(
'pretix.event.quota.*',
_('Quota changed'),
_('This includes related events like creation, deletion, opening or closing of quotas. '
'No webhook is sent for changes to the resulting availability.'),
),
ParametrizedEventWebhookEvent( ParametrizedEventWebhookEvent(
'pretix.event.live.activated', 'pretix.event.live.activated',
_('Shop taken live'), _('Shop taken live'),
+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')) -
+34 -20
View File
@@ -35,6 +35,7 @@
import copy import copy
import json import json
import logging import logging
import re
from datetime import timedelta from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from io import BytesIO from io import BytesIO
@@ -47,9 +48,7 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import ( from django.core.validators import MaxValueValidator, MinValueValidator
MaxValueValidator, MinValueValidator, RegexValidator,
)
from django.db.models import QuerySet from django.db.models import QuerySet
from django.forms import Select, widgets from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION from django.forms.widgets import FILE_INPUT_CONTRADICTION
@@ -220,20 +219,6 @@ class NamePartsFormField(forms.MultiValueField):
defaults = { defaults = {
'widget': self.widget, 'widget': self.widget,
'max_length': kwargs.pop('max_length', None), 'max_length': kwargs.pop('max_length', None),
'validators': [
RegexValidator(
# The following characters should never appear in a name anywhere of
# the world. However, they commonly appear in inputs generated by spam
# bots.
r'^[^$€/%§{}<>~]*$',
message=_('Please do not use special characters in names.')
),
RegexValidator(
URL_RE,
inverse_match=True,
message=_('Please do not use special characters in names.')
)
]
} }
self.max_length = defaults['max_length'] self.max_length = defaults['max_length']
self.scheme_name = kwargs.pop('scheme') self.scheme_name = kwargs.pop('scheme')
@@ -255,7 +240,6 @@ class NamePartsFormField(forms.MultiValueField):
if fname == 'title' and self.scheme_titles: if fname == 'title' and self.scheme_titles:
d = dict(defaults) d = dict(defaults)
d.pop('max_length', None) d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField( field = forms.ChoiceField(
**d, **d,
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]] choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
@@ -264,7 +248,6 @@ class NamePartsFormField(forms.MultiValueField):
elif fname == 'salutation': elif fname == 'salutation':
d = dict(defaults) d = dict(defaults)
d.pop('max_length', None) d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField( field = forms.ChoiceField(
**d, **d,
choices=[ choices=[
@@ -296,6 +279,37 @@ class NamePartsFormField(forms.MultiValueField):
if sum(len(v) for v in value.values() if v) > (self.max_length or 250): if sum(len(v) for v in value.values() if v) > (self.max_length or 250):
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length') raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
for fname, label, size in self.scheme['fields']:
if fname == 'salutation' or (fname == 'title' and self.scheme_titles):
continue
v = value.get(fname)
if not v:
continue
special_chars = re.findall('[$€/%§{}<>~]', v)
if special_chars:
raise forms.ValidationError(
_('The field "%(label)s" may not contain special characters such as "%(chars)s".'),
code='name_special_chars',
params={
"label": label,
"chars": "".join(special_chars),
},
)
# URL_RE checks for valid domain names, including one special TLD med, which can be part of a title
if ".med" in v:
v = v.replace(".med", ". med")
value[fname] = v
url_matched = URL_RE.search(v)
if url_matched:
raise forms.ValidationError(
_('The field "%(label)s" may not contain an URL (%(url)s).'),
code='url_in_title',
params={
"label": label,
"url": url_matched.group(0),
}
)
if value.get("salutation") == "empty": if value.get("salutation") == "empty":
value["salutation"] = "" value["salutation"] = ""
@@ -1384,7 +1398,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
elif self.validate_vat_id and vat_id_applicable: elif self.validate_vat_id and vat_id_applicable:
try: try:
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country'))) normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
self.instance.vat_id_validated = True self.instance.vat_id_validated = bool(normalized_id)
self.instance.vat_id = data['vat_id'] = normalized_id self.instance.vat_id = data['vat_id'] = normalized_id
except VATIDFinalError as e: except VATIDFinalError as e:
if self.all_optional: if self.all_optional:
@@ -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'):
File diff suppressed because one or more lines are too long
@@ -55,10 +55,12 @@
{% trans "You receive these emails based on your notification settings." %}<br> {% trans "You receive these emails based on your notification settings." %}<br>
<a href="{{ settings_url }}"> <a href="{{ settings_url }}">
{% trans "Click here to view and change your notification settings" %} {% trans "Click here to view and change your notification settings" %}
</a><br>
<a href="{{ disable_url }}">
{% trans "Click here disable all notifications immediately." %}
</a> </a>
{% if disable_url %}<br>
<a href="{{ disable_url }}">
{% trans "Click here disable all notifications immediately." %}
</a>
{% endif %}
</div> </div>
<!--[if gte mso 9]> <!--[if gte mso 9]>
</td></tr></table> </td></tr></table>
@@ -14,5 +14,6 @@
{% trans "You receive these emails based on your notification settings." %} {% trans "You receive these emails based on your notification settings." %}
{% trans "Click here to view and change your notification settings:" %} {% trans "Click here to view and change your notification settings:" %}
{{ settings_url }} {{ settings_url }}
{% trans "Click here disable all notifications immediately:" %} {% if disable_url %}{% trans "Click here disable all notifications immediately:" %}
{{ disable_url }} {{ disable_url }}
{% endif %}
+32
View File
@@ -0,0 +1,32 @@
#
# 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/>.
#
from django import template
from django.conf import settings
register = template.Library()
@register.filter
def human_readable_locale(value):
if not value:
return ''
return dict(settings.LANGUAGES).get(value, '')
+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>'
+30 -1
View File
@@ -24,10 +24,12 @@ import calendar
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_email from django.core.validators import RegexValidator, validate_email
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.templatetags.rich_text import URL_RE
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of # This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>. # the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
# #
@@ -113,6 +115,33 @@ def multimail_validate(val):
return s return s
class RegexValidatorInverseMatchAndParam(RegexValidator):
inverse_match = True
def __call__(self, value):
regex_matches = self.regex.search(str(value))
if regex_matches:
raise ValidationError(
self.message,
code=self.code,
params={
"value": value,
"match": regex_matches.group(0) if regex_matches else "",
}
)
class NoUrlValidator(RegexValidatorInverseMatchAndParam):
regex = URL_RE
def __init__(self, **kwargs):
if not kwargs.get("message"):
kwargs["message"] = _('You entered an URL, which is not allowed. Please remove %(match)s from your input.')
if not kwargs.get("code"):
kwargs["code"] = "contains_url"
super().__init__(**kwargs)
class RRuleValidator: class RRuleValidator:
def __init__(self, enforce_simple=False): def __init__(self, enforce_simple=False):
self.enforce_simple = enforce_simple self.enforce_simple = enforce_simple
@@ -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)
+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 %}
@@ -134,6 +134,39 @@
</div> </div>
{% endif %} {% endif %}
{% if invoice_qualified and order.invoice_dirty %}
<div class="alert alert-warning">
<p>
{% blocktrans trimmed %}
This order was changed after the last invoice was generated. A new invoice was not generated yet, because invoices are configured to be generated on payment or if required by the payment method.
A new invoice will be generated once the customer pays the invoice or selects a payment method that requires an invoice.
{% endblocktrans %}
</p>
{% if "event.orders:write" in request.eventpermset %}
<p>
{% if uncancelled_invoice %}
<form action="{% url "control:event.order.reissueinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=uncancelled_invoice.pk %}"
method="post">
{% csrf_token %}
<button class="btn btn-default" type="submit">
{% blocktrans trimmed %}
Reissue invoice
{% endblocktrans %}
</button>
</form>
{% elif can_generate_invoice %}
<form method="post" action="{% url "control:event.order.geninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% csrf_token %}
<button class="btn btn-default">
{% trans "Generate invoice" %}
</button>
</form>
{% endif %}
</p>
{% endif %}
</div>
{% endif %}
<div class="row"> <div class="row">
<div class="col-xs-12 col-lg-10"> <div class="col-xs-12 col-lg-10">
{% for cr in order.cancellation_requests.all %} {% for cr in order.cancellation_requests.all %}
@@ -553,7 +586,7 @@
<span class="fa fa-print"></span> <span class="fa fa-print"></span>
{{ pl.datetime|date:"SHORT_DATETIME_FORMAT" }} {{ pl.datetime|date:"SHORT_DATETIME_FORMAT" }}
{{ pl.get_type_display }} {{ pl.get_type_display }}
({{ pl.source }}{% if pl.device %}, #{{ pl.device.device_id }}{% endif %}) ({{ pl.source }}{% if pl.device %}, {{ pl.device.name }} - #{{ pl.device.device_id }}{% endif %})
{% if not pl.successful %}<span class="fa fa-warning fa-fw"></span>{% endif %} {% if not pl.successful %}<span class="fa fa-warning fa-fw"></span>{% endif %}
<br> <br>
{% endfor %} {% endfor %}
@@ -23,9 +23,9 @@
<legend>{% trans "How should the refund be sent?" %}</legend> <legend>{% trans "How should the refund be sent?" %}</legend>
<p> <p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
Any payments that you selected for automatical refunds will be immediately communicate the refund Any payments you selected for automatic refunds will have the refund request sent immediately to the
request to the respective payment provider. Manual refunds will be created as pending refunds, you respective payment provider. Manual refunds will be created as pending refunds, which you can later
can then later mark them as done once you actually transferred the money back to the customer. mark as done once you have actually transferred the money back to the customer.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
@@ -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 }}
+3
View File
@@ -554,6 +554,9 @@ class OrderDetail(OrderView):
ctx['download_buttons'] = self.download_buttons ctx['download_buttons'] = self.download_buttons
ctx['payment_refund_sum'] = self.order.payment_refund_sum ctx['payment_refund_sum'] = self.order.payment_refund_sum
ctx['pending_sum'] = self.order.pending_sum ctx['pending_sum'] = self.order.pending_sum
ctx['uncancelled_invoice'] = self.order.invoices.exclude(
Exists(self.order.invoices.filter(refers=OuterRef('pk'), is_cancellation=True))
).exclude(is_cancellation=True).first()
return ctx return ctx
+2 -1
View File
@@ -531,6 +531,7 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
self.object = form.save()
self.save_formset(self.object) self.save_formset(self.object)
self.save_cl_formset(self.object) self.save_cl_formset(self.object)
self.save_meta() self.save_meta()
@@ -569,7 +570,7 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi
f.subevent = self.object f.subevent = self.object
f.save() f.save()
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk}) tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk})
return super().form_valid(form) return HttpResponseRedirect(self.get_success_url())
def get_success_url(self) -> str: def get_success_url(self) -> str:
return reverse('control:event.subevents', kwargs={ return reverse('control:event.subevents', kwargs={
-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)
+38 -84
View File
@@ -8,18 +8,17 @@ 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:03+0000\n" "POT-Creation-Date: 2026-04-28 09:03+0000\n"
"PO-Revision-Date: 2025-04-08 18:00+0000\n" "PO-Revision-Date: 2026-05-19 04:16+0000\n"
"Last-Translator: Menaouer Chaabi " "Last-Translator: Khalid Shaheen <khalid.shaheen@gmail.com>\n"
"<98581961+DerJimno@users.noreply.github.com>\n" "Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix/"
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix/ar/" "ar/>\n"
">\n"
"Language: ar\n" "Language: ar\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=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " "Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n" "&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n"
"X-Generator: Weblate 5.10.4\n" "X-Generator: Weblate 2026.5\n"
#: pretix/_base_settings.py:87 #: pretix/_base_settings.py:87
msgid "English" msgid "English"
@@ -39,7 +38,7 @@ msgstr "العربية"
#: pretix/_base_settings.py:91 #: pretix/_base_settings.py:91
msgid "Basque" msgid "Basque"
msgstr "" msgstr "الباسكية"
#: pretix/_base_settings.py:92 #: pretix/_base_settings.py:92
msgid "Catalan" msgid "Catalan"
@@ -59,7 +58,7 @@ msgstr "التشيكية"
#: pretix/_base_settings.py:96 #: pretix/_base_settings.py:96
msgid "Croatian" msgid "Croatian"
msgstr "" msgstr "الكرواتية"
#: pretix/_base_settings.py:97 #: pretix/_base_settings.py:97
msgid "Danish" msgid "Danish"
@@ -91,7 +90,7 @@ msgstr "اليونانية"
#: pretix/_base_settings.py:104 #: pretix/_base_settings.py:104
msgid "Hebrew" msgid "Hebrew"
msgstr "" msgstr "العبرية"
#: pretix/_base_settings.py:105 #: pretix/_base_settings.py:105
msgid "Indonesian" msgid "Indonesian"
@@ -103,7 +102,7 @@ msgstr "الإيطالية"
#: pretix/_base_settings.py:107 #: pretix/_base_settings.py:107
msgid "Japanese" msgid "Japanese"
msgstr "" msgstr "اليابانية"
#: pretix/_base_settings.py:108 #: pretix/_base_settings.py:108
msgid "Latvian" msgid "Latvian"
@@ -147,7 +146,7 @@ msgstr "الأسبانية"
#: pretix/_base_settings.py:118 #: pretix/_base_settings.py:118
msgid "Spanish (Latin America)" msgid "Spanish (Latin America)"
msgstr "" msgstr "الإسبانية (أميركا اللاتينية)"
#: pretix/_base_settings.py:119 #: pretix/_base_settings.py:119
msgid "Turkish" msgid "Turkish"
@@ -293,41 +292,28 @@ msgid "The bundled item must not have bundles on its own."
msgstr "يجب ألا يحتوي العنصر المجمع على حزم بمفرده." msgstr "يجب ألا يحتوي العنصر المجمع على حزم بمفرده."
#: pretix/api/serializers/item.py:235 #: pretix/api/serializers/item.py:235
#, fuzzy
#| msgid "The payment is too late to be accepted."
msgid "The program start must not be empty." msgid "The program start must not be empty."
msgstr "فات الأوان لقبول الدفع." msgstr "يجب ألا يكون موعد بداية البرنامج فارغ."
#: pretix/api/serializers/item.py:239 #: pretix/api/serializers/item.py:239
#, fuzzy
#| msgid "The payment is too late to be accepted."
msgid "The program end must not be empty." msgid "The program end must not be empty."
msgstr "فات الأوان لقبول الدفع." msgstr "يجب ألا يكون موعد نهاية البرنامج فارغ."
#: pretix/api/serializers/item.py:242 pretix/base/models/items.py:2322 #: pretix/api/serializers/item.py:242 pretix/base/models/items.py:2322
#, fuzzy
#| msgid "The maximum count needs to be greater than the minimum count."
msgid "The program end must not be before the program start." msgid "The program end must not be before the program start."
msgstr "يجب أن يكون الحد الأقصى للعدد أكبر من الحد الأدنى للعد." msgstr "يجب ألا يكون موعد نهاية البرنامج سابق لموعد بدايته."
#: pretix/api/serializers/item.py:247 pretix/base/models/items.py:2316 #: pretix/api/serializers/item.py:247 pretix/base/models/items.py:2316
#, fuzzy
#| msgid "You can not select a subevent if your event is not an event series."
msgid "You cannot use program times on an event series." msgid "You cannot use program times on an event series."
msgstr "" msgstr "لا يمكنك استخدام أوقات البرنامج لسلسلة من الفعاليات."
"لا يمكنك تحديد فعالية فرعية إذا لم تكن الفعالية الخاصة بك سلسلة فعاليات."
#: pretix/api/serializers/item.py:337 #: pretix/api/serializers/item.py:337
#, fuzzy
#| msgid ""
#| "Updating add-ons, bundles, or variations via PATCH/PUT is not supported. "
#| "Please use the dedicated nested endpoint."
msgid "" msgid ""
"Updating add-ons, bundles, program times or variations via PATCH/PUT is not " "Updating add-ons, bundles, program times or variations via PATCH/PUT is not "
"supported. Please use the dedicated nested endpoint." "supported. Please use the dedicated nested endpoint."
msgstr "" msgstr ""
"تحديث الإضافات، أو الحزم، أو المتغيرات عن طريق PATCH/PUT غير مدعوم. الرجاء " "لا يوجد دعم لتحديث الإضافات، أو الحزم، أو مواعيد البرامج، أو المتغيرات عبر "
"استخدام نقطة نهاية المتداخلة المخصصة." "طريقتي PATCH/PUT. يُرجى استخدام نقطة النهاية المتداخلة المخصصة لذلك."
#: pretix/api/serializers/item.py:345 #: pretix/api/serializers/item.py:345
msgid "Only admission products can currently be personalized." msgid "Only admission products can currently be personalized."
@@ -411,10 +397,8 @@ msgstr "توجد مسبقا بطاقة هدايا بنفس السر في حسا
#: pretix/api/serializers/organizer.py:495 #: pretix/api/serializers/organizer.py:495
#: pretix/control/views/organizer.py:1039 #: pretix/control/views/organizer.py:1039
#, fuzzy
#| msgid "Account information"
msgid "Account invitation" msgid "Account invitation"
msgstr "معلومات الحساب" msgstr "دعوة إلى فتح حساب"
#: pretix/api/serializers/organizer.py:516 #: pretix/api/serializers/organizer.py:516
#: pretix/control/views/organizer.py:1138 #: pretix/control/views/organizer.py:1138
@@ -499,47 +483,35 @@ msgstr "تم تغيير عنوان اتصال الطلب"
#: pretix/api/webhooks.py:331 pretix/base/notifications.py:281 #: pretix/api/webhooks.py:331 pretix/base/notifications.py:281
#: pretix/control/templates/pretixcontrol/event/mail.html:102 #: pretix/control/templates/pretixcontrol/event/mail.html:102
msgid "Order changed" msgid "Order changed"
msgstr "تم تغيير الطلب." msgstr "تم تغيير الطلب"
#: pretix/api/webhooks.py:335 #: pretix/api/webhooks.py:335
#, fuzzy
#| msgid "Enable payment method"
msgid "Refund of payment created" msgid "Refund of payment created"
msgstr "تمكين طريقة الدفع" msgstr "تم إنشاء طلب استرداد الدفع"
#: pretix/api/webhooks.py:339 pretix/base/notifications.py:293 #: pretix/api/webhooks.py:339 pretix/base/notifications.py:293
msgid "External refund of payment" msgid "External refund of payment"
msgstr "استرداد الدفع الخارجي" msgstr "استرداد خارجي للمدفوعات"
#: pretix/api/webhooks.py:343 #: pretix/api/webhooks.py:343
#, fuzzy
#| msgid "Text (requested by user)"
msgid "Refund of payment requested by customer" msgid "Refund of payment requested by customer"
msgstr "النص (عن طريق المستخدم المطلوب)" msgstr "استرداد المبلغ بناءً على طلب العميل"
#: pretix/api/webhooks.py:347 #: pretix/api/webhooks.py:347
#, fuzzy
#| msgid "Payment completed."
msgid "Refund of payment completed" msgid "Refund of payment completed"
msgstr "تم السداد." msgstr "تم إتمام استرداد الدفع"
#: pretix/api/webhooks.py:351 #: pretix/api/webhooks.py:351
#, fuzzy
#| msgid "Refund {local_id} has been canceled."
msgid "Refund of payment canceled" msgid "Refund of payment canceled"
msgstr "تم إلغاء استرداد {local_id}." msgstr "إلغاء استرداد الدفع"
#: pretix/api/webhooks.py:355 #: pretix/api/webhooks.py:355
#, fuzzy
#| msgid "Refund order"
msgid "Refund of payment failed" msgid "Refund of payment failed"
msgstr "أجل استرداد" msgstr "فشل استرداد الدفع"
#: pretix/api/webhooks.py:359 #: pretix/api/webhooks.py:359
#, fuzzy
#| msgid "Payment confirmation date"
msgid "Payment confirmed" msgid "Payment confirmed"
msgstr اريخ الدفع تأكيدا" msgstr م تأكيد الدفع"
#: pretix/api/webhooks.py:363 #: pretix/api/webhooks.py:363
msgid "Order approved" msgid "Order approved"
@@ -550,14 +522,12 @@ msgid "Order denied"
msgstr "تم رفض الطلب" msgstr "تم رفض الطلب"
#: pretix/api/webhooks.py:371 #: pretix/api/webhooks.py:371
#, fuzzy
#| msgid "Order denied"
msgid "Order deleted" msgid "Order deleted"
msgstr "تم رفض الطلب" msgstr "تم حذف الطلب"
#: pretix/api/webhooks.py:375 #: pretix/api/webhooks.py:375
msgid "Ticket checked in" msgid "Ticket checked in"
msgstr "تم تسجيل التذكرة" msgstr "تم تسجيل دخول التذكرة"
#: pretix/api/webhooks.py:379 #: pretix/api/webhooks.py:379
msgid "Ticket check-in reverted" msgid "Ticket check-in reverted"
@@ -572,10 +542,8 @@ msgid "Event details changed"
msgstr "تم تغيير تفاصيل الفعالية" msgstr "تم تغيير تفاصيل الفعالية"
#: pretix/api/webhooks.py:391 #: pretix/api/webhooks.py:391
#, fuzzy
#| msgid "Event date"
msgid "Event deleted" msgid "Event deleted"
msgstr اريخ الفعالية" msgstr م حذف الفعالية"
#: pretix/api/webhooks.py:395 #: pretix/api/webhooks.py:395
msgctxt "subevent" msgctxt "subevent"
@@ -593,58 +561,44 @@ msgid "Event series date deleted"
msgstr "تم حذف تاريخ سلسلة الفعاليات" msgstr "تم حذف تاريخ سلسلة الفعاليات"
#: pretix/api/webhooks.py:407 #: pretix/api/webhooks.py:407
#, fuzzy
#| msgid "Product name"
msgid "Product changed" msgid "Product changed"
msgstr "اسم المنتج" msgstr "تم تغيير المنتج"
#: pretix/api/webhooks.py:408 #: pretix/api/webhooks.py:408
msgid "" msgid ""
"This includes product added or deleted and changes to nested objects like " "This includes product added or deleted and changes to nested objects like "
"variations or bundles." "variations or bundles."
msgstr "" msgstr ""
"يشمل ذلك المنتجات التي تمت إضافتها أو حذفها، والتغييرات التي طرأت على "
"الكائنات المتداخلة، مثل المتغيرات أو الحزم."
#: pretix/api/webhooks.py:413 #: pretix/api/webhooks.py:413
#, fuzzy
#| msgid "Shop not live"
msgid "Shop taken live" msgid "Shop taken live"
msgstr سوق لا يعيش" msgstr م إطلاق المتجر"
#: pretix/api/webhooks.py:417 #: pretix/api/webhooks.py:417
#, fuzzy
#| msgid "The shop has been taken offline."
msgid "Shop taken offline" msgid "Shop taken offline"
msgstr "وقد اتخذت المحل حاليا." msgstr "تم إيقاف المتجر مؤقتاً"
#: pretix/api/webhooks.py:421 #: pretix/api/webhooks.py:421
#, fuzzy
#| msgid "The order has been reactivated."
msgid "Test-Mode of shop has been activated" msgid "Test-Mode of shop has been activated"
msgstr "تم إعادة تنشيط الطلب." msgstr "تم تفعيل وضع الاختبار للمتجر"
#: pretix/api/webhooks.py:425 #: pretix/api/webhooks.py:425
#, fuzzy
#| msgid "The order has been reactivated."
msgid "Test-Mode of shop has been deactivated" msgid "Test-Mode of shop has been deactivated"
msgstr "تم إعادة تنشيط الطلب." msgstr "تم إلغاء تفعيل وضع الاختبار للمتجر"
#: pretix/api/webhooks.py:429 #: pretix/api/webhooks.py:429
#, fuzzy
#| msgid "Waiting list entry"
msgid "Waiting list entry added" msgid "Waiting list entry added"
msgstr "دخول قائمة الانتظار" msgstr "تم إضافة قيد إلى قائمة الانتظار"
#: pretix/api/webhooks.py:433 #: pretix/api/webhooks.py:433
#, fuzzy
#| msgid "Waiting list entry"
msgid "Waiting list entry changed" msgid "Waiting list entry changed"
msgstr خول قائمة الانتظار" msgstr "تم تغيير قيد قائمة الانتظار"
#: pretix/api/webhooks.py:437 #: pretix/api/webhooks.py:437
#, fuzzy
#| msgid "Waiting list entry"
msgid "Waiting list entry deleted" msgid "Waiting list entry deleted"
msgstr خول قائمة الانتظار" msgstr "تم حذف قيد قائمة الانتظار"
#: pretix/api/webhooks.py:441 #: pretix/api/webhooks.py:441
#, fuzzy #, fuzzy
File diff suppressed because it is too large Load Diff
+13 -4
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-03-27 09:03+0000\n" "PO-Revision-Date: 2026-05-12 04:00+0000\n"
"Last-Translator: Ivano Voghera <ivano.voghera@gmail.com>\n" "Last-Translator: Stefano Campus <stefano.campus@regione.piemonte.it>\n"
"Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix/" "Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix/"
"it/>\n" "it/>\n"
"Language: it\n" "Language: it\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"
@@ -8598,6 +8598,8 @@ msgid ""
"Includes the ability to give someone (including oneself) additional " "Includes the ability to give someone (including oneself) additional "
"permissions." "permissions."
msgstr "" msgstr ""
"Consente di assegnare a qualcuno (compreso se stessi) autorizzazioni "
"aggiuntive."
#: pretix/base/permissions.py:298 pretix/control/navigation.py:608 #: pretix/base/permissions.py:298 pretix/control/navigation.py:608
#: pretix/control/templates/pretixcontrol/organizers/customers.html:6 #: pretix/control/templates/pretixcontrol/organizers/customers.html:6
@@ -8609,13 +8611,14 @@ msgstr "Indirizzi Email (file di testo)"
#: pretix/base/permissions.py:310 pretix/control/navigation.py:666 #: pretix/base/permissions.py:310 pretix/control/navigation.py:666
#: pretix/control/navigation.py:673 #: pretix/control/navigation.py:673
msgid "Devices" msgid "Devices"
msgstr "" msgstr "Dispositivi"
#: pretix/base/permissions.py:316 #: pretix/base/permissions.py:316
msgid "" msgid ""
"Includes the ability to give access to events and data oneself does not have " "Includes the ability to give access to events and data oneself does not have "
"access to." "access to."
msgstr "" msgstr ""
"Consente di concedere l'accesso a eventi e dati a cui non si ha accesso."
#: pretix/base/permissions.py:321 #: pretix/base/permissions.py:321
#, fuzzy #, fuzzy
@@ -8747,6 +8750,8 @@ msgid ""
"Some products can no longer be purchased and have been removed from your " "Some products can no longer be purchased and have been removed from your "
"cart for the following reason: %s" "cart for the following reason: %s"
msgstr "" msgstr ""
"Alcuni prodotti non sono più disponibili e sono stati rimossi dal tuo "
"carrello per il seguente motivo: %s"
#: pretix/base/services/cart.py:117 #: pretix/base/services/cart.py:117
msgid "" msgid ""
@@ -10060,6 +10065,8 @@ msgid ""
"For business customers, compute taxes based on net total. For individuals, " "For business customers, compute taxes based on net total. For individuals, "
"use line-based rounding" "use line-based rounding"
msgstr "" msgstr ""
"Per i clienti aziendali, calcolare le imposte sul totale al netto. Per i "
"privati, applicare l'arrotondamento per singola voce"
#: pretix/base/settings.py:85 #: pretix/base/settings.py:85
msgid "Compute taxes based on net total with stable gross prices" msgid "Compute taxes based on net total with stable gross prices"
@@ -10096,6 +10103,8 @@ msgstr ""
#: pretix/base/settings.py:190 #: pretix/base/settings.py:190
msgid "Require login to access order confirmation pages" msgid "Require login to access order confirmation pages"
msgstr "" msgstr ""
"È necessario effettuare l'accesso per visualizzare le pagine di conferma "
"dell'ordine"
#: pretix/base/settings.py:191 #: pretix/base/settings.py:191
msgid "" msgid ""
+26 -36
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-20 08:07+0000\n" "PO-Revision-Date: 2026-05-12 06:34+0000\n"
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n" "Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/" "Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
"ja/>\n" "ja/>\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=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 5.17\n" "X-Generator: Weblate 5.17.1\n"
#: pretix/_base_settings.py:87 #: pretix/_base_settings.py:87
msgid "English" msgid "English"
@@ -4441,7 +4441,7 @@ msgstr "全ての製品(新規に作成されたものを含む)"
#: pretix/base/models/checkin.py:56 pretix/plugins/badges/exporters.py:436 #: pretix/base/models/checkin.py:56 pretix/plugins/badges/exporters.py:436
#: pretix/plugins/checkinlists/exporters.py:854 #: pretix/plugins/checkinlists/exporters.py:854
msgid "Limit to products" msgid "Limit to products"
msgstr "商品の上限" msgstr "対象製品を限定"
#: pretix/base/models/checkin.py:60 #: pretix/base/models/checkin.py:60
msgid "" msgid ""
@@ -6896,8 +6896,8 @@ msgstr "免税輸出品目、VAT非課税"
msgctxt "tax_code" msgctxt "tax_code"
msgid "VAT exempt for EEA intra-community supply of goods and services" msgid "VAT exempt for EEA intra-community supply of goods and services"
msgstr "" msgstr ""
"EEA欧州経済領域域内事業者間取引における品・サービス供給のVAT(付加価値" "EEA(欧州経済領域)域内事業者間取引における品・サービス供給のVAT(付加価値税)"
"税)免税" "免税"
#: pretix/base/models/tax.py:186 #: pretix/base/models/tax.py:186
msgid "Special cases" msgid "Special cases"
@@ -7144,10 +7144,10 @@ msgid ""
"usages in some cases can be lower than this limit, e.g. in case of " "usages in some cases can be lower than this limit, e.g. in case of "
"cancellations." "cancellations."
msgstr "" msgstr ""
"複数(1を超える値)に設定した場合、バウチャーは初回使用時にこの数の製品と引き" "1より大きい値を設定すると、バウチャーを最初に使用する際に、この数の製品に対し"
"換える必要があります。その後の使用では、より少ない数の製品にも使用できます。" "て引き換える必要があります。2回目以降の使用では、これより少ない数の製品に対し"
"ただし、キャンセルなどの場合には、合計使用回数がこの限を下回ることがありま" "ても使用できます。この場合、キャンセルなどにより、合計使用回数がこの限を"
"。" "下回ることがある点にご注意ください。"
#: pretix/base/models/vouchers.py:217 #: pretix/base/models/vouchers.py:217
msgid "" msgid ""
@@ -8059,10 +8059,8 @@ msgstr ""
"2x アドオン2" "2x アドオン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 "アドオンのリスト" msgstr "チェックイン済みアドオン一覧"
#: 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
@@ -9019,10 +9017,8 @@ msgid "Czech National Bank"
msgstr "チェコ国立銀行" msgstr "チェコ国立銀行"
#: 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 "チェコ国立銀行" msgstr "ポーランド国立銀行"
#: 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 ""
@@ -10068,14 +10064,10 @@ msgid ""
msgstr "チェコ国立銀行の日次レートに基づいて、請求書の金額がCZK以外の場合。" msgstr "チェコ国立銀行の日次レートに基づいて、請求書の金額が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 "チェコ国立銀行の日次レートに基づいて、請求書の金額がCZK以外の場合。" msgstr "ポーランド国立銀行の日次レートに基づいて、請求書の金額がPLN以外の場合。"
#: pretix/base/settings.py:597 #: pretix/base/settings.py:597
msgid "Require invoice address" msgid "Require invoice address"
@@ -15956,10 +15948,8 @@ msgid "Allow to overbook quotas when performing this operation"
msgstr "この操作を実行する際にクォータの超過予約を許可する" msgstr "この操作を実行する際にクォータの超過予約を許可する"
#: 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 "注文数" msgstr "追加する製品の数"
#: pretix/control/forms/orders.py:344 #: pretix/control/forms/orders.py:344
msgid "Add-on to" msgid "Add-on to"
@@ -15991,10 +15981,8 @@ msgstr ""
"さい" "さい"
#: 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 "同じ席を複数回選択することはできません。" msgstr "複数の製品を同時に追加する場合、座席を選択することはできません。"
#: 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
@@ -16596,7 +16584,7 @@ msgstr "週末の日"
#: 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 "既存の日付と重複する日付をスキップする"
#: pretix/control/forms/subevents.py:109 #: pretix/control/forms/subevents.py:109
msgctxt "subevent" msgctxt "subevent"
@@ -16606,6 +16594,9 @@ 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 ""
"これは、すべての日付が同じ場所で行われ、既存の特別イベントと競合して重複した"
"日付が作成されない場合に有用です。これは、非アクティブな日付さえも尊重し、す"
"べての日付に開始時刻と終了時刻の両方がある場合に最も効果的です。"
#: pretix/control/forms/subevents.py:128 #: pretix/control/forms/subevents.py:128
msgid "Keep the current values" msgid "Keep the current values"
@@ -22963,12 +22954,11 @@ msgid ""
"total number of tickets sold and the number of a specific ticket type at the " "total number of tickets sold and the number of a specific ticket type at the "
"same time." "same time."
msgstr "" msgstr ""
"製品を実際に利用可能にするには、クォータも必要です。クォータは、pretixが製品" "製品を実際に販売可能にするには、クォータも必要です。クォータは、製品をどれだ"
"のインスタンスをいくつ販売するかを定義します。これにより、イベント無制限" "けpretixが販売するかを定義します。これにより、イベントの参加者数を無制限にす"
"参加者を受け入れることができるか、参加者数が制限されるかを設定できます。より" "るか、人数を制限するかを設定できます。1つの製品を複数のクォータに割り当てるこ"
"複雑な要件を満たすために、製品を複数のクォータに割り当てることができます。例" "とで、より複雑な要件にも対応できます。たとえば、販売するチケットの総数と特定"
"えば、販売されるチケットの総数と特定のチケット種別の数を同時に制限したい場合" "のチケット種別の数を同時に制限したい場合などです。"
"などです。"
#: pretix/control/templates/pretixcontrol/items/quotas.html:25 #: pretix/control/templates/pretixcontrol/items/quotas.html:25
msgid "Your search did not match any quotas." msgid "Your search did not match any quotas."
@@ -23685,7 +23675,7 @@ msgid ""
"this product was part of the discount calculation for a different product in " "this product was part of the discount calculation for a different product in "
"this order." "this order."
msgstr "" msgstr ""
"自動割引によりこの品の価格が引き下げられたか、同じ注文内の別の品に対する" "自動割引によりこの品の価格が引き下げられたか、同じ注文内の別の品に対する"
"割引計算の対象になっています。" "割引計算の対象になっています。"
#: pretix/control/templates/pretixcontrol/order/index.html:496 #: pretix/control/templates/pretixcontrol/order/index.html:496
@@ -29425,7 +29415,7 @@ msgstr "一度に10万以上の日付を作成しないでください。"
#: 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 "すべての日付は、既存の日付と衝突するため、スキップされます。"
#: pretix/control/views/subevents.py:1102 #: pretix/control/views/subevents.py:1102
#, python-brace-format #, python-brace-format
@@ -34613,7 +34603,7 @@ msgid ""
"changed because they are not on sale:" "changed because they are not on sale:"
msgstr "" msgstr ""
"このアドオンカテゴリで選択された製品の中には、現在セール対象外のため変更でき" "このアドオンカテゴリで選択された製品の中には、現在セール対象外のため変更でき"
"ない品があります" "ない品があります:"
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:392 #: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:392
msgid "There are no add-ons available for this product." msgid "There are no add-ons available for this product."
+7 -7
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:04+0000\n" "POT-Creation-Date: 2026-04-28 09:04+0000\n"
"PO-Revision-Date: 2026-03-23 21:00+0000\n" "PO-Revision-Date: 2026-05-12 06:34+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n" "Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix-" "Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix-"
"js/ja/>\n" "js/ja/>\n"
"Language: ja\n" "Language: ja\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=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 5.16.2\n" "X-Generator: Weblate 5.17.1\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
@@ -572,7 +572,7 @@ msgstr "未入場"
#: pretix/static/pretixcontrol/js/ui/checkinrules.js:289 #: pretix/static/pretixcontrol/js/ui/checkinrules.js:289
msgid "Error: Product not found!" msgid "Error: Product not found!"
msgstr "エラー:品が見つかりません!" msgstr "エラー:品が見つかりません!"
#: pretix/static/pretixcontrol/js/ui/checkinrules.js:296 #: pretix/static/pretixcontrol/js/ui/checkinrules.js:296
msgid "Error: Variation not found!" msgid "Error: Variation not found!"
@@ -743,7 +743,7 @@ msgstr "カートの有効期限が近づいています。"
#: pretix/static/pretixpresale/js/ui/cart.js:62 #: pretix/static/pretixpresale/js/ui/cart.js:62
msgid "The items in your cart are reserved for you for one minute." msgid "The items in your cart are reserved for you for one minute."
msgid_plural "The items in your cart are reserved for you for {num} minutes." msgid_plural "The items in your cart are reserved for you for {num} minutes."
msgstr[0] "カート内の品はあと {num} 分間確保されています。" msgstr[0] "カート内の品はあと {num} 分間確保されています。"
#: pretix/static/pretixpresale/js/ui/cart.js:83 #: pretix/static/pretixpresale/js/ui/cart.js:83
msgid "Your cart has expired." msgid "Your cart has expired."
@@ -754,7 +754,7 @@ msgid ""
"The items in your cart are no longer reserved for you. You can still " "The items in your cart are no longer reserved for you. You can still "
"complete your order as long as they're available." "complete your order as long as they're available."
msgstr "" msgstr ""
"カート内の品の確保期限が切れました。在庫があれば、このまま注文を完了するこ" "カート内の品の確保期限が切れました。在庫があれば、このまま注文を完了するこ"
"とができます。" "とができます。"
#: pretix/static/pretixpresale/js/ui/cart.js:87 #: pretix/static/pretixpresale/js/ui/cart.js:87
@@ -987,7 +987,7 @@ msgid ""
"You currently have an active cart for this event. If you select more " "You currently have an active cart for this event. If you select more "
"products, they will be added to your existing cart." "products, they will be added to your existing cart."
msgstr "" msgstr ""
"このイベントのカートに品が入っています。品を追加すると、既存のカートに追" "このイベントのカートに品が入っています。品を追加すると、既存のカートに追"
"加されます。" "加されます。"
#: pretix/static/pretixpresale/js/widget/widget.js:57 #: pretix/static/pretixpresale/js/widget/widget.js:57
+105 -133
View File
@@ -8,16 +8,16 @@ 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-02-14 06:10+0000\n" "PO-Revision-Date: 2026-05-20 10:58+0000\n"
"Last-Translator: Nate Horst <nate@agcthailand.org>\n" "Last-Translator: Phumraphee Sae-tang <phumraphee@gmail.com>\n"
"Language-Team: Thai <https://translate.pretix.eu/projects/pretix/pretix/th/" "Language-Team: Thai <https://translate.pretix.eu/projects/pretix/pretix/th/>"
">\n" "\n"
"Language: th\n" "Language: th\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=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 5.15.2\n" "X-Generator: Weblate 2026.5\n"
#: pretix/_base_settings.py:87 #: pretix/_base_settings.py:87
msgid "English" msgid "English"
@@ -395,10 +395,8 @@ msgstr "มีบัตรของขวัญที่มีรหัสลั
#: pretix/api/serializers/organizer.py:495 #: pretix/api/serializers/organizer.py:495
#: pretix/control/views/organizer.py:1039 #: pretix/control/views/organizer.py:1039
#, fuzzy
#| msgid "Account information"
msgid "Account invitation" msgid "Account invitation"
msgstr "ข้อมูลบัญชี" msgstr "คำเชิญเข้าร่วมบัญชี"
#: pretix/api/serializers/organizer.py:516 #: pretix/api/serializers/organizer.py:516
#: pretix/control/views/organizer.py:1138 #: pretix/control/views/organizer.py:1138
@@ -634,22 +632,16 @@ msgid "Customer account anonymized"
msgstr "กำหนดบัญชีลูกค้าเป็นแบบไม่ระบุตัวตนแล้ว" msgstr "กำหนดบัญชีลูกค้าเป็นแบบไม่ระบุตัวตนแล้ว"
#: pretix/api/webhooks.py:470 #: pretix/api/webhooks.py:470
#, fuzzy
#| msgid "Gift card code"
msgid "Gift card added" msgid "Gift card added"
msgstr "รหัสบัตรของขวัญ" msgstr "บัตรของขวัญได้ถูกเพิ่มแล้ว"
#: pretix/api/webhooks.py:474 #: pretix/api/webhooks.py:474
#, fuzzy
#| msgid "Gift card code"
msgid "Gift card modified" msgid "Gift card modified"
msgstr "รหัสบัตรของขวัญ" msgstr "บัตรของขวัญได้ถูกแก้ไขแล้ว"
#: pretix/api/webhooks.py:478 #: pretix/api/webhooks.py:478
#, fuzzy
#| msgid "Gift card transactions"
msgid "Gift card used in transaction" msgid "Gift card used in transaction"
msgstr "ธุรกรรมบัตรของขวัญ" msgstr "บัตรของขวัญที่ถูกใช้ในธุรกรรม"
#: pretix/base/addressvalidation.py:100 pretix/base/addressvalidation.py:103 #: pretix/base/addressvalidation.py:100 pretix/base/addressvalidation.py:103
#: pretix/base/addressvalidation.py:108 pretix/base/forms/questions.py:1060 #: pretix/base/addressvalidation.py:108 pretix/base/forms/questions.py:1060
@@ -721,7 +713,7 @@ msgid "Your password may not be the same as your previous password."
msgid_plural "" msgid_plural ""
"Your password may not be the same as one of your %(history_length)s previous " "Your password may not be the same as one of your %(history_length)s previous "
"passwords." "passwords."
msgstr[0] "" msgstr[0] "รหัสผ่านของคุณอาจจะไม่เหมือนกับรหัสผ่านเก่าของคุณ %(history_length)s"
#: pretix/base/channels.py:168 #: pretix/base/channels.py:168
msgid "Online shop" msgid "Online shop"
@@ -2548,10 +2540,8 @@ msgid "Voucher budget usage"
msgstr "ยอดการใช้เวาเชอร์" msgstr "ยอดการใช้เวาเชอร์"
#: pretix/base/exporters/orderlist.py:656 #: pretix/base/exporters/orderlist.py:656
#, fuzzy
#| msgid "Voucher"
msgid "Voucher tag" msgid "Voucher tag"
msgstr "เวาเชอร์" msgstr "ป้ายคูปอง"
#: pretix/base/exporters/orderlist.py:657 #: pretix/base/exporters/orderlist.py:657
msgid "Pseudonymization ID" msgid "Pseudonymization ID"
@@ -3351,39 +3341,34 @@ msgid "Street and Number"
msgstr "ถนนและบ้านเลขที่" msgstr "ถนนและบ้านเลขที่"
#: pretix/base/forms/questions.py:899 #: pretix/base/forms/questions.py:899
#, fuzzy, python-brace-format #, python-brace-format
#| msgid "Please enter a shorter name."
msgid "Please enter a date between {min} and {max}." msgid "Please enter a date between {min} and {max}."
msgstr "โปรดระบุชื่อที่สั้นกว่านี้" msgstr "กรุณาระบุวันที่ระหว่าง {min} และ {max}"
#: pretix/base/forms/questions.py:905 #: pretix/base/forms/questions.py:905
#, fuzzy, python-brace-format #, python-brace-format
#| msgid "Please enter a valid sales channel."
msgid "Please enter a date no earlier than {min}." msgid "Please enter a date no earlier than {min}."
msgstr "โปรดระบุช่องทางการขายที่ถูกต้อง" msgstr "โปรดระบุวันที่ตั้งแต่ {min} เป็นต้นไป"
#: pretix/base/forms/questions.py:910 #: pretix/base/forms/questions.py:910
#, fuzzy, python-brace-format #, python-brace-format
#| msgid "Please enter a shorter name."
msgid "Please enter a date no later than {max}." msgid "Please enter a date no later than {max}."
msgstr "โปรดระบุชื่อที่สั้นกว่านี้" msgstr "กรุณาระบุวันที่ก่อนวันที่ {max}"
#: pretix/base/forms/questions.py:948 #: pretix/base/forms/questions.py:948
#, python-brace-format #, python-brace-format
msgid "Please enter a date and time between {min} and {max}." msgid "Please enter a date and time between {min} and {max}."
msgstr "" msgstr "กรุณาระบุวันที่และเวลาระหว่าง {min} และ {max}"
#: pretix/base/forms/questions.py:954 #: pretix/base/forms/questions.py:954
#, fuzzy, python-brace-format #, python-brace-format
#| msgid "Please enter a valid sales channel."
msgid "Please enter a date and time no earlier than {min}." msgid "Please enter a date and time no earlier than {min}."
msgstr "โปรดระบุช่องทางการขายที่ถูกต้อง" msgstr "กรุณาระบุวันที่และเวลาตั้งแต่ {min} เป็นต้นไป"
#: pretix/base/forms/questions.py:959 #: pretix/base/forms/questions.py:959
#, fuzzy, python-brace-format #, python-brace-format
#| msgid "Please enter a valid state."
msgid "Please enter a date and time no later than {max}." msgid "Please enter a date and time no later than {max}."
msgstr "โปรดระบุรัฐ/จังหวัที่ถูกต้อง" msgstr "กรุณาระบวัที่และเวลาก่อน {max}"
#: pretix/base/forms/questions.py:1178 #: pretix/base/forms/questions.py:1178
msgid "" msgid ""
@@ -3570,27 +3555,27 @@ msgstr "รับไฟล์ PDF ทางอีเมล"
#: pretix/base/invoicing/national.py:37 #: pretix/base/invoicing/national.py:37
msgctxt "italian_invoice" msgctxt "italian_invoice"
msgid "Italian Exchange System (SdI)" msgid "Italian Exchange System (SdI)"
msgstr "" msgstr "Italian Exchange System (SdI)"
#: pretix/base/invoicing/national.py:38 #: pretix/base/invoicing/national.py:38
msgctxt "italian_invoice" msgctxt "italian_invoice"
msgid "Exchange System (SdI)" msgid "Exchange System (SdI)"
msgstr "" msgstr "Exchange System (SdI)"
#: pretix/base/invoicing/national.py:51 #: pretix/base/invoicing/national.py:51
msgctxt "italian_invoice" msgctxt "italian_invoice"
msgid "Fiscal code" msgid "Fiscal code"
msgstr "" msgstr "Fiscal code"
#: pretix/base/invoicing/national.py:55 #: pretix/base/invoicing/national.py:55
msgctxt "italian_invoice" msgctxt "italian_invoice"
msgid "Address for certified electronic mail" msgid "Address for certified electronic mail"
msgstr "" msgstr "อีเมลสำหรับการติดต่อทางอิเล็กทรอนิกส์แบบรับรอง"
#: pretix/base/invoicing/national.py:59 #: pretix/base/invoicing/national.py:59
msgctxt "italian_invoice" msgctxt "italian_invoice"
msgid "Recipient code" msgid "Recipient code"
msgstr "" msgstr "รหัสผู้รับปลายทาง"
#: pretix/base/invoicing/national.py:83 #: pretix/base/invoicing/national.py:83
msgctxt "italian_invoice" msgctxt "italian_invoice"
@@ -3600,6 +3585,10 @@ msgid ""
"in accordance with the procedures and terms set forth in No. 89757/2018 of " "in accordance with the procedures and terms set forth in No. 89757/2018 of "
"April 30, 2018, issued by the Director of the Revenue Agency." "April 30, 2018, issued by the Director of the Revenue Agency."
msgstr "" msgstr ""
"เอกสาร PDF นี้เป็นเพียงสำเนาเพื่อการแสดงผลของใบแจ้งหนี้ และไม่ใช่ใบแจ้งหนี้ที่ใช้เพื่อวัตถุประสงค์ด้"
"านภาษีมูลค่าเพิ่ม (VAT)ใบแจ้งหนี้ฉบับสมบูรณ์ถูกจัดทำในรูปแบบ XML และส่งตามหลักเกณฑ์และวิธีการที่กำ"
"หนดในประกาศเลขที่ 89757/2018 ลงวันที่ 30 เมษายน 2018 ซึ่งออกโดยผู้อำนวยการสำนักงานสรรพา"
"กร"
#: pretix/base/invoicing/pdf.py:142 #: pretix/base/invoicing/pdf.py:142
#, python-format #, python-format
@@ -3847,6 +3836,7 @@ msgstr "วันที่จัดกิจกรรม: {date_range}"
msgid "" msgid ""
"A Peppol participant ID always starts with a prefix, followed by a colon (:)." "A Peppol participant ID always starts with a prefix, followed by a colon (:)."
msgstr "" msgstr ""
"รหัสผู้เข้าร่วม Peppol ต้องเริ่มต้นด้วย prefix และตามด้วยเครื่องหมาย \":\""
#: pretix/base/invoicing/peppol.py:140 #: pretix/base/invoicing/peppol.py:140
#, python-format #, python-format
@@ -3854,6 +3844,8 @@ msgid ""
"The Peppol participant ID prefix %(number)s is not known to our system. " "The Peppol participant ID prefix %(number)s is not known to our system. "
"Please reach out to us if you are sure this ID is correct." "Please reach out to us if you are sure this ID is correct."
msgstr "" msgstr ""
"ระบบของเราไม่รองรับ prefix ของ Peppol participant ID หมายเลข %(number)s หากท่านมั่"
"นใจว่ารหัสดังกล่าวถูกต้อง กรุณาติดต่อเราเพื่อขอความช่วยเหลือ"
#: pretix/base/invoicing/peppol.py:144 #: pretix/base/invoicing/peppol.py:144
#, python-format #, python-format
@@ -3861,23 +3853,25 @@ msgid ""
"The Peppol participant ID does not match the validation rules for the prefix " "The Peppol participant ID does not match the validation rules for the prefix "
"%(number)s. Please reach out to us if you are sure this ID is correct." "%(number)s. Please reach out to us if you are sure this ID is correct."
msgstr "" msgstr ""
"รหัส Peppol participant ID ไม่ถูกต้องตามรูปแบบของ prefix 1%(number)s หากรหัสดังกล่าวถู"
"กต้อง กรุณาติดต่อเรา"
#: pretix/base/invoicing/peppol.py:166 #: pretix/base/invoicing/peppol.py:166
msgid "The Peppol participant ID is not registered on the Peppol network." msgid "The Peppol participant ID is not registered on the Peppol network."
msgstr "" msgstr "Peppol participant ID นี้ไม่ได้ลงทะเบียนอยู่ในเครือข่าย Peppol"
#: pretix/base/invoicing/peppol.py:192 #: pretix/base/invoicing/peppol.py:192
msgid "Peppol participant ID" msgid "Peppol participant ID"
msgstr "" msgstr "Peppol participant ID นี้ไม่ได้ลงทะเบียนอยู่ในเครือข่าย Peppol"
#: pretix/base/invoicing/peppol.py:211 #: pretix/base/invoicing/peppol.py:211
msgid "The Peppol participant ID does not match your VAT ID." msgid "The Peppol participant ID does not match your VAT ID."
msgstr "" msgstr "Peppol participant ID ไม่ตรงกับหมายเลข VAT ID ของคุณ"
#: pretix/base/invoicing/peppol.py:214 #: pretix/base/invoicing/peppol.py:214
msgctxt "peppol_invoice" msgctxt "peppol_invoice"
msgid "Visual copy" msgid "Visual copy"
msgstr "" msgstr "สำเนาเพื่อแสดงผล"
#: pretix/base/invoicing/peppol.py:219 #: pretix/base/invoicing/peppol.py:219
msgctxt "peppol_invoice" msgctxt "peppol_invoice"
@@ -3886,12 +3880,16 @@ msgid ""
"invoice for VAT purposes. The original invoice is issued in XML format and " "invoice for VAT purposes. The original invoice is issued in XML format and "
"transmitted through the Peppol network." "transmitted through the Peppol network."
msgstr "" msgstr ""
"เอกสาร PDF นี้เป็นเพียงสำเนาเพื่อการแสดงผลของใบแจ้งหนี้ และไม่ถือเป็นใบแจ้งหนี้เพื่อวัตถุประสงค์ด้"
"านภาษีมูลค่าเพิ่ม (VAT) ใบแจ้งหนี้ต้นฉบับถูกจัดทำในรูปแบบ XML และส่งผ่านเครือข่าย Peppol"
#: pretix/base/logentrytype_registry.py:43 #: pretix/base/logentrytype_registry.py:43
msgid "" msgid ""
"The relevant plugin is currently not active. To activate it, click here to " "The relevant plugin is currently not active. To activate it, click here to "
"go to the plugin settings." "go to the plugin settings."
msgstr "" msgstr ""
"ปลั๊กอินที่เกี่ยวข้องยังไม่ได้เปิดใช้งานในขณะนี้ หากต้องการเปิดใช้งาน คลิกที่นี่เพื่อไปยังหน้าการตั้งค่าปลั๊ก"
"อิน"
#: pretix/base/logentrytype_registry.py:53 #: pretix/base/logentrytype_registry.py:53
msgid "The relevant plugin is currently not active." msgid "The relevant plugin is currently not active."
@@ -4229,23 +4227,21 @@ msgstr "อนุญาตให้ข้ามการตรวจสอบโ
#: pretix/control/templates/pretixcontrol/vouchers/detail.html:70 #: pretix/control/templates/pretixcontrol/vouchers/detail.html:70
#: pretix/control/views/vouchers.py:121 #: pretix/control/views/vouchers.py:121
msgid "Price effect" msgid "Price effect"
msgstr "" msgstr "ผลต่างด้านราคา"
#: pretix/base/modelimport_vouchers.py:150 #: pretix/base/modelimport_vouchers.py:150
#, fuzzy, python-brace-format #, python-brace-format
#| msgid "Could not parse {value} as a price mode, use one of {options}."
msgid "Could not parse {value} as a price effect, use one of {options}." msgid "Could not parse {value} as a price effect, use one of {options}."
msgstr "ไม่สามารถประมวลผล {value} เป็นโหมดราคาได้ โปรดใช้ค่าใดค่าหนึ่งจาก {options}" msgstr ""
"ไม่สามารถประมวลผล {value} เป็นค่าผลต่างด้านราคาได้ โปรดใช้ค่าใดค่าหนึ่งจาก {options}"
#: pretix/base/modelimport_vouchers.py:160 pretix/base/models/vouchers.py:248 #: pretix/base/modelimport_vouchers.py:160 pretix/base/models/vouchers.py:248
msgid "Voucher value" msgid "Voucher value"
msgstr "มูลค่าเวาเชอร์" msgstr "มูลค่าเวาเชอร์"
#: pretix/base/modelimport_vouchers.py:165 #: pretix/base/modelimport_vouchers.py:165
#, fuzzy
#| msgid "It is pointless to set a value without a price mode."
msgid "It is pointless to set a value without a price effect." msgid "It is pointless to set a value without a price effect."
msgstr "การกำหนดมูลค่าจะไม่มีผลหากไม่ได้กำหนดโหมดราคา" msgstr "การกำหนดมูลค่าจะไม่มีผลหากไม่ได้กำหนดผลต่างด้านราคา"
#: pretix/base/modelimport_vouchers.py:237 pretix/base/models/items.py:2121 #: pretix/base/modelimport_vouchers.py:237 pretix/base/models/items.py:2121
#: pretix/base/models/vouchers.py:275 #: pretix/base/models/vouchers.py:275
@@ -6177,46 +6173,41 @@ msgstr "สิ้นสุด"
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:38 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:38
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:139 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:139
msgid "queued" msgid "queued"
msgstr "" msgstr "อยู่ในคิว"
#: pretix/base/models/mail.py:53 #: pretix/base/models/mail.py:53
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:40 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:40
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:141 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:141
msgid "being sent" msgid "being sent"
msgstr "" msgstr "กำลังส่ง"
#: pretix/base/models/mail.py:54 #: pretix/base/models/mail.py:54
#, fuzzy
#| msgid "Waiting list entry"
msgid "awaiting retry" msgid "awaiting retry"
msgstr "รายการในรายการรอ" msgstr "รอการลองใหม่"
#: pretix/base/models/mail.py:55 #: pretix/base/models/mail.py:55
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:48 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:48
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:149 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:149
msgid "withheld" msgid "withheld"
msgstr "" msgstr "ถูกหักไว้"
#: pretix/base/models/mail.py:57 #: pretix/base/models/mail.py:57
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:50 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:50
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:151 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:151
msgid "aborted" msgid "aborted"
msgstr "" msgstr "ยกเลิกแล้ว"
#: pretix/base/models/mail.py:58 #: pretix/base/models/mail.py:58
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:52 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:52
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:153 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:153
#, fuzzy
#| msgctxt "checkin state"
#| msgid "Present"
msgid "sent" msgid "sent"
msgstr "มา" msgstr "ส่งแล้ว"
#: pretix/base/models/mail.py:59 #: pretix/base/models/mail.py:59
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:46 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mail.html:46
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:147 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:147
msgid "bounced" msgid "bounced"
msgstr "" msgstr "ตีกลับ"
#: pretix/base/models/memberships.py:44 #: pretix/base/models/memberships.py:44
#: pretix/presale/templates/pretixpresale/organizers/customer_memberships.html:28 #: pretix/presale/templates/pretixpresale/organizers/customer_memberships.html:28
@@ -6622,24 +6613,22 @@ msgstr ""
"ทั้งนี้ การตั้งค่าอาจใช้เวลาสักครู่เพื่อให้มีผลกับผู้ใช้ทุกคน" "ทั้งนี้ การตั้งค่าอาจใช้เวลาสักครู่เพื่อให้มีผลกับผู้ใช้ทุกคน"
#: pretix/base/models/organizer.py:384 #: pretix/base/models/organizer.py:384
#, fuzzy
#| msgid "Event admission"
msgid "All event permissions" msgid "All event permissions"
msgstr "เริ่มเปิดให้เข้างาน" msgstr "สิทธิทั้งหมดของกิจกรรม"
#: pretix/base/models/organizer.py:385 #: pretix/base/models/organizer.py:385
#: pretix/control/templates/pretixcontrol/organizers/team_edit.html:34 #: pretix/control/templates/pretixcontrol/organizers/team_edit.html:34
msgid "Event permissions" msgid "Event permissions"
msgstr "" msgstr "สิทธิของกิจกรรม"
#: pretix/base/models/organizer.py:386 #: pretix/base/models/organizer.py:386
msgid "All organizer permissions" msgid "All organizer permissions"
msgstr "" msgstr "สิทธิขององค์กรทั้งหมด"
#: pretix/base/models/organizer.py:387 #: pretix/base/models/organizer.py:387
#: pretix/control/templates/pretixcontrol/organizers/team_edit.html:25 #: pretix/control/templates/pretixcontrol/organizers/team_edit.html:25
msgid "Organizer permissions" msgid "Organizer permissions"
msgstr "" msgstr "สิทธิขององค์กร"
#: pretix/base/models/organizer.py:407 #: pretix/base/models/organizer.py:407
#, python-format #, python-format
@@ -7889,10 +7878,8 @@ msgstr ""
"2x สินค้าเพิ่มเติม 2" "2x สินค้าเพิ่มเติม 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 "รายการสินค้าเพิ่มเติม" msgstr "รายการส่วนเสริมที่เช็คอินแล้ว"
#: 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
@@ -8073,145 +8060,129 @@ msgstr "ไฟล์เลย์เอาต์ของคุณไม่ถู
#: pretix/base/permissions.py:314 pretix/base/permissions.py:331 #: pretix/base/permissions.py:314 pretix/base/permissions.py:331
msgctxt "permission_level" msgctxt "permission_level"
msgid "View" msgid "View"
msgstr "" msgstr "แสดงผล"
#: pretix/base/permissions.py:164 pretix/base/permissions.py:169 #: pretix/base/permissions.py:164 pretix/base/permissions.py:169
#: pretix/base/permissions.py:174 pretix/base/permissions.py:179 #: pretix/base/permissions.py:174 pretix/base/permissions.py:179
#: pretix/base/permissions.py:286 pretix/base/permissions.py:315 #: pretix/base/permissions.py:286 pretix/base/permissions.py:315
#, fuzzy
#| msgid "Voucher changed"
msgctxt "permission_level" msgctxt "permission_level"
msgid "View and change" msgid "View and change"
msgstr "แก้ไขรหัสส่วนลดแล้ว" msgstr "แสดงและแก้ไข"
#: pretix/base/permissions.py:168 #: pretix/base/permissions.py:168
msgid "API only" msgid "API only"
msgstr "" msgstr "API เท่านั้น"
#: pretix/base/permissions.py:173 #: pretix/base/permissions.py:173
msgid "" msgid ""
"Menu item will only show up if the user has permission for general settings." "Menu item will only show up if the user has permission for general settings."
msgstr "" msgstr ""
"รายการเมนูนี้จะแสดงเฉพาะผู้ใช้ที่มีสิทธิ์เข้าถึงการตั้งค่าทั่วไปเท่านั้น"
#: pretix/base/permissions.py:177 pretix/base/permissions.py:231 #: pretix/base/permissions.py:177 pretix/base/permissions.py:231
#: pretix/base/permissions.py:285 pretix/base/permissions.py:313 #: pretix/base/permissions.py:285 pretix/base/permissions.py:313
#: pretix/base/permissions.py:330 #: pretix/base/permissions.py:330
#, fuzzy
#| msgid "Read access"
msgctxt "permission_level" msgctxt "permission_level"
msgid "No access" msgid "No access"
msgstr "สิทธิในการอ่านข้อมูล" msgstr "ไม่มีสิทธิในการเข้าถึง"
#: pretix/base/permissions.py:188 #: pretix/base/permissions.py:188
#: pretix/control/templates/pretixcontrol/event/settings.html:7 #: pretix/control/templates/pretixcontrol/event/settings.html:7
#: pretix/control/templates/pretixcontrol/event/settings.html:13 #: pretix/control/templates/pretixcontrol/event/settings.html:13
#: pretix/control/templates/pretixcontrol/user/settings.html:29 #: pretix/control/templates/pretixcontrol/user/settings.html:29
msgid "General settings" msgid "General settings"
msgstr "" msgstr "การตั้งค่าทั่วไป"
#: pretix/base/permissions.py:192 #: pretix/base/permissions.py:192
msgid "" msgid ""
"This includes access to all settings not listed explicitly below, including " "This includes access to all settings not listed explicitly below, including "
"plugin settings." "plugin settings."
msgstr "" msgstr ""
"ซึ่งรวมถึงสิทธิ์เข้าถึงการตั้งค่าทั้งหมดที่ไม่ได้ระบุไว้ด้านล่างอย่างชัดเจน รวมถึงการตั้งค่าปลั๊กอินด้วย"
#: pretix/base/permissions.py:197 #: pretix/base/permissions.py:197
#: pretix/control/templates/pretixcontrol/event/payment.html:6 #: pretix/control/templates/pretixcontrol/event/payment.html:6
#: pretix/control/templates/pretixcontrol/event/payment_provider.html:5 #: pretix/control/templates/pretixcontrol/event/payment_provider.html:5
msgid "Payment settings" msgid "Payment settings"
msgstr "" msgstr "การตั้งค่าการชำระเงิน"
#: pretix/base/permissions.py:203 #: pretix/base/permissions.py:203
#: pretix/control/templates/pretixcontrol/event/tax.html:120 #: pretix/control/templates/pretixcontrol/event/tax.html:120
msgid "Tax settings" msgid "Tax settings"
msgstr "" msgstr "การตั้งค่าเกี่ยวกับภาษี"
#: pretix/base/permissions.py:209 #: pretix/base/permissions.py:209
#, fuzzy
#| msgid "Invoice lines"
msgid "Invoicing settings" msgid "Invoicing settings"
msgstr "รายการในใบแจ้งหนี้" msgstr "การตั้งค่าที่เกี่ยวกับใบแจ้งหนี้"
#: pretix/base/permissions.py:215 #: pretix/base/permissions.py:215
#, fuzzy
#| msgctxt "subevent"
#| msgid "Event series date added"
msgid "Event series dates" msgid "Event series dates"
msgstr "เพิ่มวันที่ในชุดกิจกรรมแล้ว" msgstr "วันที่จัดงานแต่ละรอบ"
#: pretix/base/permissions.py:221 #: pretix/base/permissions.py:221
#, fuzzy
#| msgid "Product name and variation"
msgid "Products, quotas and questions" msgid "Products, quotas and questions"
msgstr "ชื่อสินค้าและรูปแบบสินค้า" msgstr "สินค้า โควตา และคำถาม"
#: pretix/base/permissions.py:224 #: pretix/base/permissions.py:224
msgid "Also includes related objects like categories or discounts." msgid "Also includes related objects like categories or discounts."
msgstr "" msgstr "รวมถึงรายการที่เกี่ยวข้อง เช่น หมวดหมู่หรือส่วนลดด้วย"
#: pretix/base/permissions.py:232 #: pretix/base/permissions.py:232
#, fuzzy
#| msgid "Web Check-in"
msgctxt "permission_level" msgctxt "permission_level"
msgid "Only check-in" msgid "Only check-in"
msgstr "เว็บเช็อิน (Web Check-in)" msgstr "สิทธิ์สำหรับเช็อินเท่านั้น"
#: pretix/base/permissions.py:233 #: pretix/base/permissions.py:233
msgctxt "permission_level" msgctxt "permission_level"
msgid "View all" msgid "View all"
msgstr "" msgstr "ดูทั้งหมด"
#: pretix/base/permissions.py:234 #: pretix/base/permissions.py:234
#, fuzzy
#| msgid "Web-based check-in"
msgctxt "permission_level" msgctxt "permission_level"
msgid "View all and check-in" msgid "View all and check-in"
msgstr "การเช็คอินผ่านเว็บ" msgstr "ดูทั้งหมดและเช็คอิน"
#: pretix/base/permissions.py:235 #: pretix/base/permissions.py:235
msgctxt "permission_level" msgctxt "permission_level"
msgid "View all and change" msgid "View all and change"
msgstr "" msgstr "ดูทั้งหมดและแก้ไข"
#: pretix/base/permissions.py:236 #: pretix/base/permissions.py:236
msgid "Includes the ability to cancel and refund individual orders." msgid "Includes the ability to cancel and refund individual orders."
msgstr "" msgstr "รวมถึงสิทธิ์ในการยกเลิกและคืนเงินสำหรับคำสั่งซื้อแต่ละรายการ"
#: pretix/base/permissions.py:238 #: pretix/base/permissions.py:238
msgid "Also includes related objects like the waiting list." msgid "Also includes related objects like the waiting list."
msgstr "" msgstr "รวมถึงข้อมูลที่เกี่ยวข้อง เช่น รายชื่อผู้รอคิวด้วย"
#: pretix/base/permissions.py:248 #: pretix/base/permissions.py:248
#, fuzzy
#| msgctxt "subevent"
#| msgid "Event or date information"
msgid "Full event or date cancellation" msgid "Full event or date cancellation"
msgstr "ข้อมูลกิจกรรมหรือวันที่" msgstr "การยกเลิกทั้งงานหรือวันที่จัดงาน"
#: pretix/base/permissions.py:252 #: pretix/base/permissions.py:252
msgctxt "permission_level" msgctxt "permission_level"
msgid "Not allowed" msgid "Not allowed"
msgstr "" msgstr "ไม่ได้รับอนุญาต"
#: pretix/base/permissions.py:253 #: pretix/base/permissions.py:253
msgctxt "permission_level" msgctxt "permission_level"
msgid "Allowed" msgid "Allowed"
msgstr "" msgstr "อนุญาต"
#: pretix/base/permissions.py:268 #: pretix/base/permissions.py:268
msgctxt "permission_level" msgctxt "permission_level"
msgid "Access existing events" msgid "Access existing events"
msgstr "" msgstr "เข้าถึงกิจกรรมที่มีอยู่"
#: pretix/base/permissions.py:269 #: pretix/base/permissions.py:269
msgctxt "permission_level" msgctxt "permission_level"
msgid "Access existing and create new events" msgid "Access existing and create new events"
msgstr "" msgstr "เข้าถึงกิจกรรมที่มีอยู่และสร้างกิจกรรมใหม่"
#: pretix/base/permissions.py:271 #: pretix/base/permissions.py:271
msgid "" msgid ""
"The level of access to events is determined in detail by the settings below." "The level of access to events is determined in detail by the settings below."
msgstr "" msgstr "การตั้งค่าด้านล่างจะกำหนดระดับการเข้าถึงกิจกรรมอย่างละเอียด"
#: pretix/base/permissions.py:275 pretix/control/navigation.py:143 #: pretix/base/permissions.py:275 pretix/control/navigation.py:143
#: pretix/control/navigation.py:462 pretix/control/navigation.py:512 #: pretix/control/navigation.py:462 pretix/control/navigation.py:512
@@ -8223,47 +8194,48 @@ msgstr ""
#: pretix/plugins/returnurl/apps.py:40 #: pretix/plugins/returnurl/apps.py:40
#: pretix/plugins/ticketoutputpdf/apps.py:55 #: pretix/plugins/ticketoutputpdf/apps.py:55
msgid "Settings" msgid "Settings"
msgstr "" msgstr "การตั้งค่า"
#: pretix/base/permissions.py:278 #: pretix/base/permissions.py:278
msgid "" msgid ""
"This includes access to all organizer-level functionality not listed " "This includes access to all organizer-level functionality not listed "
"explicitly below, including plugin settings." "explicitly below, including plugin settings."
msgstr "" msgstr ""
"ซึ่งรวมถึงสิทธิ์เข้าถึงฟังก์ชันทั้งหมดในระดับผู้จัดงานที่ไม่ได้ระบุไว้ด้านล่างอย่างชัดเจน รวมถึงการตั้งค่าปลั๊"
"กอินด้วย"
#: pretix/base/permissions.py:287 #: pretix/base/permissions.py:287
msgid "" msgid ""
"Includes the ability to give someone (including oneself) additional " "Includes the ability to give someone (including oneself) additional "
"permissions." "permissions."
msgstr "" msgstr "รวมถึงสิทธิ์ในการมอบสิทธิ์เพิ่มเติมให้ผู้อื่น (รวมถึงตนเอง)"
#: pretix/base/permissions.py:298 pretix/control/navigation.py:608 #: pretix/base/permissions.py:298 pretix/control/navigation.py:608
#: pretix/control/templates/pretixcontrol/organizers/customers.html:6 #: pretix/control/templates/pretixcontrol/organizers/customers.html:6
#: pretix/control/templates/pretixcontrol/organizers/customers.html:9 #: pretix/control/templates/pretixcontrol/organizers/customers.html:9
msgid "Customers" msgid "Customers"
msgstr "" msgstr "ลูกค้า"
#: pretix/base/permissions.py:310 pretix/control/navigation.py:666 #: pretix/base/permissions.py:310 pretix/control/navigation.py:666
#: pretix/control/navigation.py:673 #: pretix/control/navigation.py:673
msgid "Devices" msgid "Devices"
msgstr "" msgstr "อุปกรณ์"
#: pretix/base/permissions.py:316 #: pretix/base/permissions.py:316
msgid "" msgid ""
"Includes the ability to give access to events and data oneself does not have " "Includes the ability to give access to events and data oneself does not have "
"access to." "access to."
msgstr "" msgstr ""
"รวมถึงสิทธิ์ในการมอบการเข้าถึงกิจกรรมและข้อมูลที่ตนเองไม่มีสิทธิ์เข้าถึง"
#: pretix/base/permissions.py:321 #: pretix/base/permissions.py:321
#, fuzzy
#| msgid "Seating plan"
msgid "Seating plans" msgid "Seating plans"
msgstr "ผังที่นั่ง" msgstr "ผังที่นั่ง"
#: pretix/base/permissions.py:327 pretix/control/navigation.py:712 #: pretix/base/permissions.py:327 pretix/control/navigation.py:712
#: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:8 #: pretix/control/templates/pretixcontrol/organizers/outgoing_mails.html:8
msgid "Outgoing emails" msgid "Outgoing emails"
msgstr "" msgstr "อีเมลขาออก"
#: pretix/base/plugins.py:138 #: pretix/base/plugins.py:138
#: pretix/control/templates/pretixcontrol/event/quick_setup.html:132 #: pretix/control/templates/pretixcontrol/event/quick_setup.html:132
@@ -8418,24 +8390,21 @@ msgstr ""
#, python-format #, python-format
msgid "You cannot select more than %s item per order." msgid "You cannot select more than %s item per order."
msgid_plural "You cannot select more than %s items per order." msgid_plural "You cannot select more than %s items per order."
msgstr[0] "" msgstr[0] "คุณไม่สามารถเลือกได้เกิน %s รายการต่อคำสั่งซื้อ"
msgstr[1] ""
#: pretix/base/services/cart.py:138 pretix/base/services/orders.py:1602 #: pretix/base/services/cart.py:138 pretix/base/services/orders.py:1602
#, python-format #, python-format
msgid "You cannot select more than %(max)s item of the product %(product)s." msgid "You cannot select more than %(max)s item of the product %(product)s."
msgid_plural "" msgid_plural ""
"You cannot select more than %(max)s items of the product %(product)s." "You cannot select more than %(max)s items of the product %(product)s."
msgstr[0] "" msgstr[0] "คุณไม่สามารถเลือกสินค้า %(product)s ได้เกิน %(max)s รายการ"
msgstr[1] ""
#: pretix/base/services/cart.py:143 pretix/base/services/orders.py:1607 #: pretix/base/services/cart.py:143 pretix/base/services/orders.py:1607
#, python-format #, python-format
msgid "You need to select at least %(min)s item of the product %(product)s." msgid "You need to select at least %(min)s item of the product %(product)s."
msgid_plural "" msgid_plural ""
"You need to select at least %(min)s items of the product %(product)s." "You need to select at least %(min)s items of the product %(product)s."
msgstr[0] "" msgstr[0] "คุณต้องเลือกสินค้า %(product)s อย่างน้อย %(min)s รายการ"
msgstr[1] ""
#: pretix/base/services/cart.py:148 #: pretix/base/services/cart.py:148
#, python-format #, python-format
@@ -8446,7 +8415,8 @@ msgid_plural ""
"We removed %(product)s from your cart as you can not buy less than %(min)s " "We removed %(product)s from your cart as you can not buy less than %(min)s "
"items of it." "items of it."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" "เราได้นำสินค้า %(product)s ออกจากตะกร้าของคุณ เนื่องจากไม่สามารถซื้อสินค้านี้ต่ำกว่า %(min)s "
"รายการได้"
#: pretix/base/services/cart.py:152 pretix/base/services/orders.py:164 #: pretix/base/services/cart.py:152 pretix/base/services/orders.py:164
#: pretix/presale/templates/pretixpresale/event/index.html:170 #: pretix/presale/templates/pretixpresale/event/index.html:170
@@ -8501,7 +8471,8 @@ msgid_plural ""
"The voucher code \"%(voucher)s\" can only be used if you select at least " "The voucher code \"%(voucher)s\" can only be used if you select at least "
"%(number)s matching products." "%(number)s matching products."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" "สามารถใช้โค้ดส่วนลด “%(voucher)s” ได้ก็ต่อเมื่อเลือกสินค้าที่ร่วมรายการอย่างน้อย %(number)s ร"
"ายการ"
#: pretix/base/services/cart.py:170 #: pretix/base/services/cart.py:170
#, python-format #, python-format
@@ -8514,7 +8485,8 @@ msgid_plural ""
"%(number)s matching products. We have therefore removed some positions from " "%(number)s matching products. We have therefore removed some positions from "
"your cart that can no longer be purchased like this." "your cart that can no longer be purchased like this."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" "โค้ดส่วนลด “%(voucher)s” ใช้ได้เมื่อเลือกสินค้าที่เข้าเงื่อนไขอย่างน้อย %(number)s รายการ เรา"
"จึงนำบางรายการออกจากตะกร้าของคุณ เนื่องจากไม่สามารถสั่งซื้อภายใต้เงื่อนไขนี้ได้อีก"
#: pretix/base/services/cart.py:176 #: pretix/base/services/cart.py:176
msgid "" msgid ""
@@ -8606,7 +8578,7 @@ msgid_plural ""
"You can select at most %(max)s add-ons from the category %(cat)s for the " "You can select at most %(max)s add-ons from the category %(cat)s for the "
"product %(base)s." "product %(base)s."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" "สามารถเลือกส่วนเสริมในหมวดหมู่ %(cat)s สำหรับสินค้า %(base)s ได้ไม่เกิน %(max)s รายการ"
#: pretix/base/services/cart.py:210 pretix/base/services/orders.py:199 #: pretix/base/services/cart.py:210 pretix/base/services/orders.py:199
#, python-format #, python-format
@@ -30685,7 +30657,7 @@ msgstr ""
#: pretix/plugins/stripe/payment.py:1572 #: pretix/plugins/stripe/payment.py:1572
msgid "iDEAL | Wero" msgid "iDEAL | Wero"
msgstr "" msgstr "iDEAL | Wero"
#: pretix/plugins/stripe/payment.py:1575 #: pretix/plugins/stripe/payment.py:1575
msgid "" msgid ""
+6 -8
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:04+0000\n" "POT-Creation-Date: 2026-04-28 09:04+0000\n"
"PO-Revision-Date: 2026-01-29 13:00+0000\n" "PO-Revision-Date: 2026-05-20 10:58+0000\n"
"Last-Translator: Nate Horst <nate@agcthailand.org>\n" "Last-Translator: Phumraphee Sae-tang <phumraphee@gmail.com>\n"
"Language-Team: Thai <https://translate.pretix.eu/projects/pretix/pretix-js/" "Language-Team: Thai <https://translate.pretix.eu/projects/pretix/pretix-js/"
"th/>\n" "th/>\n"
"Language: th\n" "Language: th\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=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 5.15.2\n" "X-Generator: Weblate 2026.5\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
@@ -60,7 +60,7 @@ msgstr "PayPal Pay Later"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:41 #: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:41
msgid "iDEAL | Wero" msgid "iDEAL | Wero"
msgstr "" msgstr "iDEAL | Wero"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:42 #: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:42
msgid "SEPA Direct Debit" msgid "SEPA Direct Debit"
@@ -712,8 +712,7 @@ msgstr "จำนวน"
#: pretix/static/pretixcontrol/js/ui/subevent.js:112 #: pretix/static/pretixcontrol/js/ui/subevent.js:112
msgid "(one more date)" msgid "(one more date)"
msgid_plural "({num} more dates)" msgid_plural "({num} more dates)"
msgstr[0] "" msgstr[0] "อีก {num} วัน"
msgstr[1] ""
#: pretix/static/pretixpresale/js/ui/cart.js:47 #: pretix/static/pretixpresale/js/ui/cart.js:47
msgid "" msgid ""
@@ -734,8 +733,7 @@ msgstr "ตะกร้าสินค้าของคุณกำลังจ
#: pretix/static/pretixpresale/js/ui/cart.js:62 #: pretix/static/pretixpresale/js/ui/cart.js:62
msgid "The items in your cart are reserved for you for one minute." msgid "The items in your cart are reserved for you for one minute."
msgid_plural "The items in your cart are reserved for you for {num} minutes." msgid_plural "The items in your cart are reserved for you for {num} minutes."
msgstr[0] "" msgstr[0] "สินค้าในตะกร้าของคุณจะถูกจองไว้ให้คุณเป็นเวลา {num} นาที"
msgstr[1] ""
#: pretix/static/pretixpresale/js/ui/cart.js:83 #: pretix/static/pretixpresale/js/ui/cart.js:83
msgid "Your cart has expired." msgid "Your cart has expired."
+1 -1
View File
@@ -405,7 +405,7 @@ def process_banktransfers(self, job: int, data: list) -> None:
# We need to sort prefixes by length with long ones first. In case we have an event with slug # We need to sort prefixes by length with long ones first. In case we have an event with slug
# "CONF" and one with slug "CONF2022", we want CONF2022 to match first, to avoid the parser # "CONF" and one with slug "CONF2022", we want CONF2022 to match first, to avoid the parser
# thinking "2022" is already the order code. # thinking "2022" is already the order code.
"|".join(sorted([re.escape(p).replace("\\-", r"[\- ]*") for p in prefixes], key=lambda p: len(p), reverse=True)), "|".join([re.escape(p).replace("\\-", r"[\- ]*") for p in sorted(prefixes, key=lambda p: len(p), reverse=True)]),
min(code_len_agg['min'] or 1, inr_len_agg['min'] or 1), min(code_len_agg['min'] or 1, inr_len_agg['min'] or 1),
max(code_len_agg['max'] or 5, inr_len_agg['max'] or 5) max(code_len_agg['max'] or 5, inr_len_agg['max'] or 5)
) )
+2 -3
View File
@@ -82,7 +82,8 @@ class CheckInListMixin(BaseExporter):
widget=forms.RadioSelect( widget=forms.RadioSelect(
attrs={'class': 'scrolling-choice'} attrs={'class': 'scrolling-choice'}
), ),
initial=self.event.checkin_lists.first() initial=self.event.checkin_lists.first(),
required=True
)), )),
('date_range', ('date_range',
DateFrameField( DateFrameField(
@@ -143,7 +144,6 @@ class CheckInListMixin(BaseExporter):
if not self.event.has_subevents: if not self.event.has_subevents:
del d['date_range'] del d['date_range']
d['list'].queryset = self.event.checkin_lists.all()
d['list'].widget = Select2( d['list'].widget = Select2(
attrs={ attrs={
'data-model-select2': 'generic', 'data-model-select2': 'generic',
@@ -155,7 +155,6 @@ class CheckInListMixin(BaseExporter):
} }
) )
d['list'].widget.choices = d['list'].choices d['list'].widget.choices = d['list'].choices
d['list'].required = True
return d return d
@@ -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),
]
@@ -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>
+1 -1
View File
@@ -126,7 +126,7 @@ footer_link = EventPluginSignal()
Arguments: ``request`` Arguments: ``request``
The signal ``pretix.presale.signals.footer_link`` allows you to add links to the footer of an event page. You The signal ``pretix.presale.signals.footer_link`` allows you to add links to the footer of an event page. You
are expected to return a dictionary containing the keys ``label`` and ``url``. are expected to return a dictionary containing the keys ``label``, ``url`` and optionally ``cssclass``.
As with all event plugin signals, the ``sender`` keyword argument will contain the event. As with all event plugin signals, the ``sender`` keyword argument will contain the event.
""" """
@@ -80,7 +80,7 @@
<li>{{ footer_text }}</li> <li>{{ footer_text }}</li>
{% endif %} {% endif %}
{% for f in footer %} {% for f in footer %}
<li><a href="{% safelink f.url %}" target="_blank" rel="noopener">{{ f.label }}</a></li> <li><a class="{{ f.cssclass }}" href="{% safelink f.url %}" target="_blank" rel="noopener">{{ f.label }}</a></li>
{% endfor %} {% endfor %}
{% include "pretixpresale/base_footer.html" %} {# removing or hiding this might be in violation of pretix' license #} {% include "pretixpresale/base_footer.html" %} {# removing or hiding this might be in violation of pretix' license #}
</ul> </ul>
+50 -22
View File
@@ -122,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
@@ -153,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')
@@ -181,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')
@@ -215,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):
@@ -231,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'] = '*'
+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.3",
"@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,
}),
],
};
+11 -1
View File
@@ -53,7 +53,17 @@ function async_task_on_success(data) {
// hide waitingDialog when using browser's history back // hide waitingDialog when using browser's history back
waitingDialog.hide(); waitingDialog.hide();
}); });
location.href = data.redirect; if (async_task_is_download && window.self !== window.top) {
// if in an iframe, force to download an async_task_is_download
// e.g. pretix-reseller embeds order-page in iframe, which would cause ticket-PDFs to be displayed inline
var a = document.createElement("a");
a.href = data.redirect;
a.download = "";
a.target = "_blank";
a.click();
} else {
location.href = data.redirect;
}
} }
$(this).trigger('pretix:async-task-success', data); $(this).trigger('pretix:async-task-success', data);
} }
@@ -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,198 @@
/* 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'
}
}
export const TIME_OPTIONS = {
...DATETIME_OPTIONS,
format: document.body.dataset.timeformat,
}
@@ -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('#gates-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 { TIME_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: { ...TIME_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 () {
@@ -201,13 +201,16 @@ footer nav li:not(:first-child):before {
width: 1.5em; width: 1.5em;
text-align: center; text-align: center;
} }
footer nav .btn,
footer nav .btn-link { footer nav .btn-link {
display: inline; display: inline;
padding: 0;
margin: 0; margin: 0;
font-size: 11px; font-size: 11px;
vertical-align: baseline; vertical-align: baseline;
} }
footer nav .btn-link {
padding: 0;
}
.js-only { .js-only {
display: none; display: none;
@@ -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