mirror of
https://github.com/pretix/pretix.git
synced 2026-05-18 17:24:03 +00:00
Compare commits
41 Commits
fix-transl
...
pajowu/wal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f4c1c56c | ||
|
|
9064069cf3 | ||
|
|
c48d30919f | ||
|
|
30b64546a7 | ||
|
|
477b1e42d4 | ||
|
|
66882eb115 | ||
|
|
2e7d54174d | ||
|
|
a521956aca | ||
|
|
cfcd0f4206 | ||
|
|
affb32c513 | ||
|
|
3df5b1d075 | ||
|
|
857791445f | ||
|
|
52b28997a2 | ||
|
|
f65a6aa11f | ||
|
|
9faca5ea24 | ||
|
|
867512eee5 | ||
|
|
1436b65347 | ||
|
|
cc06588991 | ||
|
|
32bd9fa265 | ||
|
|
bdc9b155f9 | ||
|
|
1af2941594 | ||
|
|
11dc1e6f70 | ||
|
|
e08243e3b2 | ||
|
|
3a4e30f2ec | ||
|
|
ea2fa741f5 | ||
|
|
20d1bb9d32 | ||
|
|
ad48d592e7 | ||
|
|
4861aca640 | ||
|
|
82450c8250 | ||
|
|
b21b69b2b8 | ||
|
|
80ed6e76cd | ||
|
|
bb211be436 | ||
|
|
3b70ef8c84 | ||
|
|
9d57380c9a | ||
|
|
8b468c31a5 | ||
|
|
9aec608601 | ||
|
|
e542bb606d | ||
|
|
fe1b4ec9d0 | ||
|
|
f04df7a6ee | ||
|
|
1640ddd497 | ||
|
|
27148324a6 |
@@ -1,5 +1,6 @@
|
||||
doc/
|
||||
env/
|
||||
node_modules/
|
||||
res/
|
||||
local/
|
||||
.git/
|
||||
|
||||
5
.editorconfig
Normal file
5
.editorconfig
Normal file
@@ -0,0 +1,5 @@
|
||||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
@@ -46,4 +46,7 @@ jobs:
|
||||
- name: Run build
|
||||
run: python -m build
|
||||
- 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
.github/workflows/style-js.yml
vendored
Normal file
43
.github/workflows/style-js.yml
vendored
Normal 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
|
||||
45
.github/workflows/tests.yml
vendored
45
.github/workflows/tests.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
run: make all compress
|
||||
- name: Run tests
|
||||
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
|
||||
working-directory: ./src
|
||||
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 }}
|
||||
fail_ci_if_error: false
|
||||
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
.gitignore
vendored
2
.gitignore
vendored
@@ -24,5 +24,7 @@ local/
|
||||
.project
|
||||
.pydevproject
|
||||
.DS_Store
|
||||
node_modules/
|
||||
.vite/
|
||||
|
||||
|
||||
|
||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
/*
|
||||
10
Dockerfile
10
Dockerfile
@@ -1,6 +1,7 @@
|
||||
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 \
|
||||
build-essential \
|
||||
gettext \
|
||||
@@ -21,8 +22,7 @@ RUN apt-get update && \
|
||||
libmaxminddb0 \
|
||||
libmaxminddb-dev \
|
||||
zlib1g-dev \
|
||||
nodejs \
|
||||
npm && \
|
||||
nodejs && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
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 _build /pretix/_build
|
||||
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 \
|
||||
pip \
|
||||
|
||||
@@ -48,3 +48,8 @@ recursive-include src Makefile
|
||||
recursive-exclude doc *
|
||||
recursive-exclude deployment *
|
||||
recursive-exclude res *
|
||||
|
||||
include package.json
|
||||
include package-lock.json
|
||||
include tsconfig.json
|
||||
include vite.config.ts
|
||||
|
||||
@@ -844,3 +844,187 @@ You can also fetch existing leads (if you are authorized to do so):
|
||||
:statuscode 200: No error
|
||||
:statuscode 401: Invalid authentication code
|
||||
: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
|
||||
@@ -70,6 +70,7 @@ The following values for ``action_types`` are valid with pretix core:
|
||||
* ``pretix.subevent.changed``
|
||||
* ``pretix.subevent.deleted``
|
||||
* ``pretix.event.item.*``
|
||||
* ``pretix.event.quota.*``
|
||||
* ``pretix.event.live.activated``
|
||||
* ``pretix.event.live.deactivated``
|
||||
* ``pretix.event.testmode.activated``
|
||||
|
||||
@@ -110,6 +110,56 @@ process::
|
||||
|
||||
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`:
|
||||
|
||||
Code checks and unit tests
|
||||
|
||||
108
eslint.config.mjs
Normal file
108
eslint.config.mjs
Normal 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
package-lock.json
generated
Normal file
4781
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal 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 src/pretix/plugins/wallet",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,13 @@ classifiers = [
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
||||
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab
|
||||
"babel",
|
||||
"BeautifulSoup4==4.14.*",
|
||||
"bleach==6.3.*",
|
||||
"celery==5.6.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=47.0.0",
|
||||
"cryptography>=48.0.0",
|
||||
"css-inline==0.20.*",
|
||||
"defusedcsv>=3.0.0",
|
||||
"dnspython==2.*",
|
||||
@@ -93,7 +93,7 @@ dependencies = [
|
||||
"redis==7.4.*",
|
||||
"reportlab==4.4.*",
|
||||
"requests==2.32.*",
|
||||
"sentry-sdk==2.58.*",
|
||||
"sentry-sdk==2.59.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
@@ -111,7 +111,7 @@ dev = [
|
||||
"aiohttp==3.13.*",
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"fakeredis==2.34.*",
|
||||
"fakeredis==2.35.*",
|
||||
"flake8==7.3.*",
|
||||
"freezegun",
|
||||
"isort==8.0.*",
|
||||
@@ -124,7 +124,9 @@ dev = [
|
||||
"pytest-mock==3.15.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.8.*",
|
||||
"pytest-playwright",
|
||||
"pytest==9.0.*",
|
||||
"playwright",
|
||||
"responses",
|
||||
]
|
||||
|
||||
|
||||
@@ -32,9 +32,15 @@ ignore =
|
||||
src/tests/plugins/stripe/*
|
||||
src/tests/plugins/sendmail/*
|
||||
src/tests/plugins/ticketoutputpdf/*
|
||||
src/tests/plugins/wallet/*
|
||||
.*
|
||||
CODE_OF_CONDUCT.md
|
||||
CONTRIBUTING.md
|
||||
Dockerfile
|
||||
SECURITY.md
|
||||
eslint.config.mjs
|
||||
package-lock.json
|
||||
package.json
|
||||
tsconfig.json
|
||||
vite.config.js
|
||||
|
||||
|
||||
12
src/Makefile
12
src/Makefile
@@ -9,10 +9,10 @@ localegen:
|
||||
./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)
|
||||
|
||||
staticfiles: jsi18n
|
||||
staticfiles: npminstall npmbuild jsi18n
|
||||
./manage.py collectstatic --noinput
|
||||
|
||||
compress: npminstall
|
||||
compress:
|
||||
./manage.py compress
|
||||
|
||||
jsi18n: localecompile
|
||||
@@ -25,8 +25,8 @@ coverage:
|
||||
coverage run -m py.test
|
||||
|
||||
npminstall:
|
||||
# keep this in sync with pretix/_build.py!
|
||||
mkdir -p pretix/static.dist/node_prefix/
|
||||
cp -r pretix/static/npm_dir/* pretix/static.dist/node_prefix/
|
||||
npm ci --prefix=pretix/static.dist/node_prefix
|
||||
npm ci
|
||||
|
||||
npmbuild:
|
||||
npm run build
|
||||
|
||||
|
||||
@@ -37,9 +37,11 @@ INSTALLED_APPS = [
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
# pretix needs to go before staticfiles
|
||||
# so we can override the runserver command
|
||||
'pretix.base',
|
||||
'django.contrib.staticfiles',
|
||||
'pretix.control',
|
||||
'pretix.presale',
|
||||
'pretix.multidomain',
|
||||
@@ -64,6 +66,7 @@ INSTALLED_APPS = [
|
||||
'pretix.plugins.returnurl',
|
||||
'pretix.plugins.autocheckin',
|
||||
'pretix.plugins.webcheckin',
|
||||
'pretix.plugins.wallet',
|
||||
'django_countries',
|
||||
'oauth2_provider',
|
||||
'phonenumber_field',
|
||||
@@ -243,7 +246,6 @@ STORAGES = {
|
||||
|
||||
COMPRESS_PRECOMPILERS = (
|
||||
('text/x-scss', 'django_libsass.SassCompiler'),
|
||||
('text/vue', 'pretix.helpers.compressor.VueCompiler'),
|
||||
)
|
||||
|
||||
COMPRESS_OFFLINE_CONTEXT = {
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
#
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from setuptools.command.build import build
|
||||
from setuptools.command.build_ext import build_ext
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
project_root = os.path.abspath(os.path.join(here, '..', '..'))
|
||||
npm_installed = False
|
||||
|
||||
|
||||
@@ -35,14 +35,14 @@ def npm_install():
|
||||
global npm_installed
|
||||
|
||||
if not npm_installed:
|
||||
# keep this in sync with Makefile!
|
||||
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)
|
||||
subprocess.check_call('npm ci', shell=True, cwd=project_root)
|
||||
npm_installed = True
|
||||
|
||||
|
||||
def npm_build():
|
||||
subprocess.check_call('npm run build', shell=True, cwd=project_root)
|
||||
|
||||
|
||||
class CustomBuild(build):
|
||||
def run(self):
|
||||
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
|
||||
@@ -62,6 +62,7 @@ class CustomBuild(build):
|
||||
settings.COMPRESS_OFFLINE = True
|
||||
|
||||
npm_install()
|
||||
npm_build()
|
||||
management.call_command('compilemessages', verbosity=1)
|
||||
management.call_command('compilejsi18n', verbosity=1)
|
||||
management.call_command('collectstatic', verbosity=1, interactive=False)
|
||||
|
||||
@@ -47,3 +47,5 @@ HAS_MEMCACHED = False
|
||||
HAS_CELERY = False
|
||||
HAS_GEOIP = False
|
||||
SENTRY_ENABLED = False
|
||||
VITE_DEV_MODE = False
|
||||
VITE_IGNORE = False
|
||||
|
||||
@@ -133,37 +133,43 @@ class JobRunSerializer(serializers.Serializer):
|
||||
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):
|
||||
schedule_next_run = serializers.DateTimeField(read_only=True)
|
||||
export_identifier = serializers.ChoiceField(choices=[])
|
||||
locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en')
|
||||
owner = serializers.SlugRelatedField(slug_field='email', read_only=True)
|
||||
error_counter = serializers.IntegerField(read_only=True)
|
||||
export_form_data = ExportFormDataField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
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):
|
||||
d = value.replace(' ', '')
|
||||
if len(d.split(',')) > 25:
|
||||
|
||||
@@ -45,6 +45,12 @@ class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
|
||||
return 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):
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -408,6 +408,12 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
_('This includes product added or deleted and changes to nested objects like '
|
||||
'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(
|
||||
'pretix.event.live.activated',
|
||||
_('Shop taken live'),
|
||||
|
||||
@@ -160,7 +160,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
def _get_all_payment_methods(self, qs):
|
||||
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(
|
||||
'provider', flat=True
|
||||
).distinct()
|
||||
@@ -330,6 +330,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
taxsum=Sum('tax_value'), grosssum=Sum('value')
|
||||
)
|
||||
}
|
||||
payment_methods = None
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_sum_cache = {
|
||||
(o['order__id'], o['provider']): o['grosssum'] for o in
|
||||
@@ -347,6 +348,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
grosssum=Sum('amount')
|
||||
)
|
||||
}
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
sum_cache = {
|
||||
(o['order__id'], o['tax_rate']): o for o in
|
||||
OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate(
|
||||
@@ -434,7 +436,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
)
|
||||
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
for id, vn in payment_methods:
|
||||
row.append(
|
||||
payment_sum_cache.get((order.id, id), Decimal('0.00')) -
|
||||
|
||||
59
src/pretix/base/management/commands/runserver.py
Normal file
59
src/pretix/base/management/commands/runserver.py
Normal file
@@ -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)
|
||||
@@ -281,7 +281,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
h = {
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}'],
|
||||
'script-src': ["{static}"],
|
||||
'object-src': ["'none'"],
|
||||
'frame-src': ['{static}'],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
@@ -295,6 +295,18 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
# this. However, we'll restrict it to HTTPS.
|
||||
'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
|
||||
if (
|
||||
url.url_name == "event.order.pay.change" or
|
||||
|
||||
@@ -442,7 +442,7 @@ class AttendeeState(ImportColumn):
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Attendee address') + ': ' + _('State')
|
||||
return _('Attendee address') + ': ' + pgettext('address', 'State')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
|
||||
@@ -125,7 +125,7 @@ class LoggingMixin:
|
||||
elif isinstance(self, Event):
|
||||
event = self
|
||||
organizer_id = self.organizer_id
|
||||
elif hasattr(self, 'event'):
|
||||
elif hasattr(self, 'event') and self.event:
|
||||
event = self.event
|
||||
organizer_id = self.event.organizer_id
|
||||
elif hasattr(self, 'organizer_id'):
|
||||
|
||||
File diff suppressed because one or more lines are too long
243
src/pretix/base/templatetags/vite.py
Normal file
243
src/pretix/base/templatetags/vite.py
Normal 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>'
|
||||
@@ -114,7 +114,7 @@ class BaseTicketOutput:
|
||||
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
|
||||
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
|
||||
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
|
||||
If you want, you can just iterate over ``order.positions_with_tickets`` which applies the
|
||||
If you want, you can just iterate over ``self.get_tickets_to_print`` which applies the
|
||||
appropriate filters for you.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
@@ -192,6 +192,17 @@ class BaseTicketOutput:
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_meta(self) -> bool:
|
||||
"""
|
||||
Returns whether or whether not this output is a "meta" output that only works as a settings holder
|
||||
and should never be used directly. This is a trick to implement outputs with multiple formats but
|
||||
unified settings.
|
||||
|
||||
.. note:: You should set is_enabled to False for meta outputs.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def download_button_text(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -945,7 +945,7 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
class ProviderForm(SettingsForm):
|
||||
"""
|
||||
This is a SettingsForm, but if fields are set to required=True, validation
|
||||
errors are only raised if the payment method is enabled.
|
||||
errors are only raised if the provider is enabled.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -104,6 +104,12 @@ class GlobalSettingsForm(SettingsForm):
|
||||
help_text=_("Will be served at {domain}/.well-known/apple-developer-merchantid-domain-association").format(
|
||||
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)
|
||||
|
||||
@@ -34,11 +34,11 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
import bleach
|
||||
import dateutil.parser
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import date_format
|
||||
@@ -248,7 +248,7 @@ class OrderValidFromChanged(OrderChangeLogEntryType):
|
||||
def display_prefixed(self, event: Event, logentry: LogEntry, data):
|
||||
return _('The validity start date for position #{posid} has been changed to {value}.').format(
|
||||
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 '–'
|
||||
)
|
||||
|
||||
@@ -260,7 +260,7 @@ class OrderValidUntilChanged(OrderChangeLogEntryType):
|
||||
def display_prefixed(self, event: Event, logentry: LogEntry, data):
|
||||
return _('The validity end date for position #{posid} has been changed to {value}.').format(
|
||||
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', '?')
|
||||
|
||||
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 event:
|
||||
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(
|
||||
posid=data.get('positionid'),
|
||||
datetime=date_format(
|
||||
dateutil.parser.parse(data["datetime"]).astimezone(logentry.event.timezone),
|
||||
datetime.fromisoformat(data["datetime"]).astimezone(logentry.event.timezone),
|
||||
"SHORT_DATETIME_FORMAT"
|
||||
) if logentry.event else data["datetime"],
|
||||
type=dict(PrintLog.PRINT_TYPES)[data["type"]],
|
||||
@@ -985,7 +985,7 @@ class LegacyCheckinLogEntryType(OrderLogEntryType):
|
||||
|
||||
def display(self, logentry, data):
|
||||
# deprecated
|
||||
dt = dateutil.parser.parse(data.get('datetime'))
|
||||
dt = datetime.fromisoformat(data.get('datetime'))
|
||||
tz = logentry.event.timezone
|
||||
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
|
||||
if 'list' in data:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load statici18n %}
|
||||
{% load vite %}
|
||||
{% load eventsignal %}
|
||||
{% load eventurl %}
|
||||
{% load dialog %}
|
||||
@@ -84,6 +85,7 @@
|
||||
<meta name="theme-color" content="#3b1c4a">
|
||||
<meta name="referrer" content="origin">
|
||||
|
||||
{% vite_importmap %}
|
||||
{% block custom_header %}{% endblock %}
|
||||
</head>
|
||||
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load bootstrap3 %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
{% load vite %}
|
||||
{% block title %}
|
||||
{% if checkinlist %}
|
||||
{% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %}
|
||||
@@ -74,45 +75,8 @@
|
||||
{% bootstrap_field form.ignore_in_statistics layout="control" %}
|
||||
|
||||
<h3>{% trans "Custom check-in rule" %}</h3>
|
||||
<div id="rules-editor" class="form-inline">
|
||||
<div>
|
||||
<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 id="rules-editor">
|
||||
<!-- Vue app mount point -->
|
||||
</div>
|
||||
<div class="disabled-withoutjs sr-only">
|
||||
{{ form.rules }}
|
||||
@@ -125,13 +89,10 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ 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>
|
||||
{% if items %}
|
||||
{{ items|json_script:"items" }}
|
||||
{% endif %}
|
||||
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "d3/d3.v6.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-zoom.v2.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script>
|
||||
<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 %}
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% load getitem %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
{% load vite %}
|
||||
{% block title %}{% trans "Check-in simulator" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>
|
||||
@@ -124,11 +125,9 @@
|
||||
{% endif %}
|
||||
{% if result.rule_graph %}
|
||||
<div id="rules-editor" class="form-inline">
|
||||
<div role="tabpanel" class="tab-pane" id="rules-viz">
|
||||
<checkin-rules-visualization></checkin-rules-visualization>
|
||||
</div>
|
||||
<textarea id="id_rules" class="sr-only">{{ result.rule_graph|attr_escapejson_dumps }}</textarea>
|
||||
<!-- Vue app mount point -->
|
||||
</div>
|
||||
<textarea id="id_rules" class="sr-only">{{ result.rule_graph|attr_escapejson_dumps }}</textarea>
|
||||
{% endif %}
|
||||
</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-zoom.v2.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></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 %}
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
<legend>{% trans "How should the refund be sent?" %}</legend>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Any payments that you selected for automatical refunds will be immediately communicate the refund
|
||||
request to the respective payment provider. Manual refunds will be created as pending refunds, you
|
||||
can then later mark them as done once you actually transferred the money back to the customer.
|
||||
Any payments you selected for automatic refunds will have the refund request sent immediately to the
|
||||
respective payment provider. Manual refunds will be created as pending refunds, which you can later
|
||||
mark as done once you have actually transferred the money back to the customer.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<form action="{% url "control:organizer.device.bulk_edit" organizer=request.organizer.slug %}" method="post">
|
||||
<form action="#will-be-overridden" method="post">
|
||||
{% csrf_token %}
|
||||
{% for field in filter_form %}
|
||||
{{ field.as_hidden }}
|
||||
|
||||
@@ -965,7 +965,7 @@ class TicketSettingsPreview(EventPermissionRequiredMixin, View):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
if provider.identifier == self.kwargs.get('output'):
|
||||
if provider.identifier == self.kwargs.get('output') and not provider.is_meta:
|
||||
return provider
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
@@ -1068,6 +1068,11 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
provider_settings_fields = provider.settings_form_fields
|
||||
provider_settings_content = provider.settings_content_render(self.request)
|
||||
if not provider_settings_fields and not provider_settings_content:
|
||||
continue
|
||||
|
||||
provider.form = ProviderForm(
|
||||
obj=self.request.event,
|
||||
settingspref='ticketoutput_%s_' % provider.identifier,
|
||||
@@ -1077,17 +1082,17 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
|
||||
provider.form.fields = OrderedDict(
|
||||
[
|
||||
('ticketoutput_%s_%s' % (provider.identifier, k), v)
|
||||
for k, v in provider.settings_form_fields.items()
|
||||
for k, v in provider_settings_fields.items()
|
||||
]
|
||||
)
|
||||
provider.settings_content = provider.settings_content_render(self.request)
|
||||
provider.settings_content = provider_settings_content
|
||||
provider.form.prepare_fields()
|
||||
|
||||
provider.evaluated_preview_allowed = True
|
||||
if not provider.preview_allowed:
|
||||
provider.evaluated_preview_allowed = False
|
||||
else:
|
||||
for k, v in provider.settings_form_fields.items():
|
||||
for k, v in provider_settings_fields.items():
|
||||
if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)):
|
||||
provider.evaluated_preview_allowed = False
|
||||
break
|
||||
|
||||
@@ -564,6 +564,8 @@ class OrderDetail(OrderView):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
if provider.is_meta:
|
||||
continue
|
||||
buttons.append({
|
||||
'text': provider.download_button_text or 'Ticket',
|
||||
'icon': provider.download_button_icon or 'fa-download',
|
||||
|
||||
@@ -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)
|
||||
@@ -4,8 +4,8 @@ msgstr ""
|
||||
"Project-Id-Version: 1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-28 09:03+0000\n"
|
||||
"PO-Revision-Date: 2026-05-04 14:19+0000\n"
|
||||
"Last-Translator: Mie Frydensbjerg <mif@aarhus.dk>\n"
|
||||
"PO-Revision-Date: 2026-05-12 12:55+0000\n"
|
||||
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
|
||||
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"da/>\n"
|
||||
"Language: da\n"
|
||||
@@ -13,7 +13,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 5.17\n"
|
||||
"X-Generator: Weblate 5.17.1\n"
|
||||
|
||||
#: pretix/_base_settings.py:87
|
||||
msgid "English"
|
||||
@@ -8160,10 +8160,8 @@ msgstr ""
|
||||
"2x Add-on 2"
|
||||
|
||||
#: pretix/base/pdf.py:383
|
||||
#, fuzzy
|
||||
#| msgid "List of Add-Ons"
|
||||
msgid "List of Checked-In Add-Ons"
|
||||
msgstr "Tilføjelser"
|
||||
msgstr "Liste over indtjekkede tilvalg"
|
||||
|
||||
#: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537
|
||||
#: pretix/control/forms/filter.py:1539
|
||||
@@ -9152,10 +9150,8 @@ msgid "Czech National Bank"
|
||||
msgstr "Den tjekkiske nationalbank"
|
||||
|
||||
#: pretix/base/services/currencies.py:41
|
||||
#, fuzzy
|
||||
#| msgid "Czech National Bank"
|
||||
msgid "National Bank of Poland"
|
||||
msgstr "Den tjekkiske nationalbank"
|
||||
msgstr "Polens Nationalbank"
|
||||
|
||||
#: pretix/base/services/export.py:95 pretix/base/services/export.py:155
|
||||
msgid ""
|
||||
@@ -10224,16 +10220,12 @@ msgstr ""
|
||||
"i CZK."
|
||||
|
||||
#: 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 ""
|
||||
"Based on National Bank of Poland daily rates, whenever the invoice amount is "
|
||||
"not in PLN."
|
||||
msgstr ""
|
||||
"Baseret på den tjekkiske nationalbanks dagskurs, når fakturabeløbet ikke er "
|
||||
"i CZK."
|
||||
"Baseret på Polens Nationalbanks dagskurser, når fakturabeløbet ikke er "
|
||||
"angivet i PLN."
|
||||
|
||||
#: pretix/base/settings.py:597
|
||||
msgid "Require invoice address"
|
||||
@@ -16259,9 +16251,8 @@ msgid "Allow to overbook quotas when performing this operation"
|
||||
msgstr "Tillad overbooking af kvoter, når denne handling udføres"
|
||||
|
||||
#: pretix/control/forms/orders.py:335
|
||||
#, fuzzy
|
||||
msgid "Number of products to add"
|
||||
msgstr "Antal dage"
|
||||
msgstr "Antal produkter, der skal tilføjes"
|
||||
|
||||
#: pretix/control/forms/orders.py:344
|
||||
msgid "Add-on to"
|
||||
@@ -16293,10 +16284,8 @@ msgstr ""
|
||||
"standardpris"
|
||||
|
||||
#: 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."
|
||||
msgstr "Du kan ikke vælge den samme plads flere gange."
|
||||
msgstr "Du kan ikke vælge en plads, når du tilføjer flere produkter på én gang."
|
||||
|
||||
#: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482
|
||||
#: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552
|
||||
@@ -16662,24 +16651,26 @@ msgid ""
|
||||
msgstr "Din enhed har ikke adgang til noget. Vælg venligst nogle begivenheder."
|
||||
|
||||
#: pretix/control/forms/organizer.py:677 pretix/plugins/stripe/payment.py:330
|
||||
#, fuzzy
|
||||
msgid "experimental"
|
||||
msgstr "Funktioner"
|
||||
msgstr "eksperimentel"
|
||||
|
||||
#: pretix/control/forms/organizer.py:683
|
||||
msgid ""
|
||||
"This feature is currently in an experimental stage. It only supports very "
|
||||
"limited use cases and might change at any point."
|
||||
msgstr ""
|
||||
"Denne funktion er i øjeblikket på forsøgsstadiet. Den understøtter kun meget "
|
||||
"få anvendelsessituationer og kan ændres når som helst."
|
||||
|
||||
#: pretix/control/forms/organizer.py:706
|
||||
msgid "Sensitive emails like password resets will not be sent in Bcc."
|
||||
msgstr ""
|
||||
"Følsomme e-mails, såsom dem om nulstilling af adgangskoder, vil ikke blive "
|
||||
"sendt som Bcc."
|
||||
|
||||
#: pretix/control/forms/organizer.py:716
|
||||
#, fuzzy
|
||||
msgid "This will be attached to every email."
|
||||
msgstr "Bliver tilføjet alle e-mails. Tilgængelige pladsholdere: {event}"
|
||||
msgstr "Dette vil blive vedhæftet til hver eneste e-mail."
|
||||
|
||||
#: pretix/control/forms/organizer.py:790 pretix/control/logdisplay.py:671
|
||||
#: pretix/control/views/user.py:850 pretix/presale/views/customer.py:289
|
||||
@@ -16688,63 +16679,58 @@ msgid "Your password has been changed."
|
||||
msgstr "Din adgangskode er blevet ændret."
|
||||
|
||||
#: pretix/control/forms/organizer.py:823
|
||||
#, fuzzy
|
||||
msgctxt "webhooks"
|
||||
msgid "Event types"
|
||||
msgstr "Arrangementsdato"
|
||||
msgstr "Begivenhedstyper"
|
||||
|
||||
#: pretix/control/forms/organizer.py:857
|
||||
#, fuzzy
|
||||
msgid "Gift card value"
|
||||
msgstr "Gavekort"
|
||||
msgstr "Gavekortets værdi"
|
||||
|
||||
#: pretix/control/forms/organizer.py:961
|
||||
#, fuzzy
|
||||
msgid "An medium with this type and identifier is already registered."
|
||||
msgstr "Denne bestilling er allerede blevet tilbagebetalt."
|
||||
msgstr ""
|
||||
"Der findes allerede et medie med denne type og dette identifikationsnummer."
|
||||
|
||||
#: pretix/control/forms/organizer.py:1059
|
||||
#, fuzzy
|
||||
msgid "An account with this customer ID is already registered."
|
||||
msgstr "Denne bestilling er allerede blevet tilbagebetalt."
|
||||
msgstr "Der findes allerede en konto med dette kunde-id."
|
||||
|
||||
#: pretix/control/forms/organizer.py:1076
|
||||
#: pretix/control/templates/pretixcontrol/organizers/customer.html:62
|
||||
#: pretix/presale/forms/customer.py:169 pretix/presale/forms/customer.py:507
|
||||
msgid "Phone"
|
||||
msgstr ""
|
||||
msgstr "Telefon"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1190
|
||||
msgctxt "sso_oidc"
|
||||
msgid "Base URL"
|
||||
msgstr ""
|
||||
msgstr "Grund-URL"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1194
|
||||
#, fuzzy
|
||||
msgctxt "sso_oidc"
|
||||
msgid "Client ID"
|
||||
msgstr "Klient-id"
|
||||
msgstr "Kunde-ID"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1198
|
||||
#, fuzzy
|
||||
msgctxt "sso_oidc"
|
||||
msgid "Client secret"
|
||||
msgstr "Arrangementsrække"
|
||||
msgstr "Kundens sikkerhedsnøgle"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1202
|
||||
msgctxt "sso_oidc"
|
||||
msgid "Scope"
|
||||
msgstr ""
|
||||
msgstr "Omfang"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1203
|
||||
msgctxt "sso_oidc"
|
||||
msgid "Multiple scopes separated with spaces."
|
||||
msgstr ""
|
||||
msgstr "Flere omfang adskilt med mellemrum."
|
||||
|
||||
#: pretix/control/forms/organizer.py:1207
|
||||
msgctxt "sso_oidc"
|
||||
msgid "User ID field"
|
||||
msgstr ""
|
||||
msgstr "Feltet Bruger-ID"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1208
|
||||
msgctxt "sso_oidc"
|
||||
@@ -16752,12 +16738,13 @@ msgid ""
|
||||
"We will assume that the contents of the user ID fields are unique and can "
|
||||
"never change for a user."
|
||||
msgstr ""
|
||||
"Vi antager, at indholdet i felterne til bruger-id er unikt og aldrig kan "
|
||||
"ændres for en bruger."
|
||||
|
||||
#: pretix/control/forms/organizer.py:1214
|
||||
#, fuzzy
|
||||
msgctxt "sso_oidc"
|
||||
msgid "Email field"
|
||||
msgstr "Alle fakturaer"
|
||||
msgstr "E-mail-felt"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1215
|
||||
msgctxt "sso_oidc"
|
||||
@@ -16766,17 +16753,19 @@ msgid ""
|
||||
"verified to really belong the the user. If this can't be guaranteed, "
|
||||
"security issues might arise."
|
||||
msgstr ""
|
||||
"Vi går ud fra, at alle e-mailadresser, vi modtager fra SSO-udbyderen, er "
|
||||
"verificeret, så vi kan være sikre på, at de tilhører brugeren. Hvis dette "
|
||||
"ikke kan garanteres, kan der opstå sikkerhedsproblemer."
|
||||
|
||||
#: pretix/control/forms/organizer.py:1222
|
||||
#, fuzzy
|
||||
msgctxt "sso_oidc"
|
||||
msgid "Phone field"
|
||||
msgstr "Telefonnummer"
|
||||
msgstr "Feltet \"Telefon\""
|
||||
|
||||
#: pretix/control/forms/organizer.py:1226
|
||||
msgctxt "sso_oidc"
|
||||
msgid "Query parameters"
|
||||
msgstr ""
|
||||
msgstr "Forespørgselsparametre"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1227
|
||||
#, python-brace-format
|
||||
@@ -16785,20 +16774,20 @@ msgid ""
|
||||
"Optional query parameters, that will be added to calls to the authorization "
|
||||
"endpoint. Enter as: {example}"
|
||||
msgstr ""
|
||||
"Valgfrie forespørgselsparametre, der tilføjes til opkald til "
|
||||
"godkendelsesendepunktet. Indtast som: {example}"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1288
|
||||
msgid "Invalidate old client secret and generate a new one"
|
||||
msgstr ""
|
||||
msgstr "Ugyldiggør den gamle klient-sikkerhedsnøgle og generer en ny"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1321
|
||||
#, fuzzy
|
||||
msgid "Organizer short name"
|
||||
msgstr "Navn"
|
||||
msgstr "Arrangørens korte navn"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1325
|
||||
#, fuzzy
|
||||
msgid "Allow access to reusable media"
|
||||
msgstr "Deaktiveret"
|
||||
msgstr "Tillad adgang til genbrugsmedier"
|
||||
|
||||
#: pretix/control/forms/organizer.py:1326
|
||||
msgid ""
|
||||
@@ -16808,26 +16797,27 @@ msgid ""
|
||||
"will grant the other organizer access to cryptographic key material required "
|
||||
"to interact with the media type."
|
||||
msgstr ""
|
||||
"Dette er nødvendigt, hvis du ønsker, at den anden arrangør skal deltage i et "
|
||||
"fælles system med f.eks. NFC-betalingschips. Du bør kun benytte denne "
|
||||
"mulighed for arrangører, du stoler på, da dette (afhængigt af de aktiverede "
|
||||
"medietyper) giver den anden arrangør adgang til det kryptografiske "
|
||||
"nøglemateriale, der er nødvendigt for at kunne interagere med medietypen."
|
||||
|
||||
#: pretix/control/forms/organizer.py:1342
|
||||
#, fuzzy
|
||||
msgid "The selected organizer does not exist or cannot be invited."
|
||||
msgstr "Delarrangementet tilhører ikke dette arrangement."
|
||||
msgstr "Den valgte arrangør findes ikke eller kan ikke inviteres."
|
||||
|
||||
#: pretix/control/forms/organizer.py:1344
|
||||
#, fuzzy
|
||||
msgid "The selected organizer has already been invited."
|
||||
msgstr "Den valgt arrangør findes ikke."
|
||||
msgstr "Den valgte arrangør er allerede blevet inviteret."
|
||||
|
||||
#: pretix/control/forms/organizer.py:1379
|
||||
#, fuzzy
|
||||
#| msgid "A voucher with this code already exists."
|
||||
msgid "A sales channel with the same identifier already exists."
|
||||
msgstr "En rabatkode med denne kode findes allerede."
|
||||
msgstr "Der findes allerede en salgskanal med samme identifikator."
|
||||
|
||||
#: pretix/control/forms/organizer.py:1391
|
||||
msgid "Events with active plugin"
|
||||
msgstr ""
|
||||
msgstr "Begivenheder med aktivt plugin"
|
||||
|
||||
#: pretix/control/forms/renderers.py:56
|
||||
#: pretix/control/templates/pretixcontrol/items/question_edit.html:139
|
||||
@@ -16840,22 +16830,21 @@ msgstr "Valgfrit"
|
||||
#: pretix/control/templates/pretixcontrol/subevents/bulk_edit.html:49
|
||||
#: pretix/control/templates/pretixcontrol/subevents/bulk_edit.html:192
|
||||
#: pretix/control/templates/pretixcontrol/subevents/bulk_edit.html:286
|
||||
#, fuzzy
|
||||
msgctxt "form_bulk"
|
||||
msgid "change"
|
||||
msgstr "Gem ændringer"
|
||||
msgstr "ændring"
|
||||
|
||||
#: pretix/control/forms/rrule.py:35
|
||||
msgid "year(s)"
|
||||
msgstr ""
|
||||
msgstr "år"
|
||||
|
||||
#: pretix/control/forms/rrule.py:36
|
||||
msgid "month(s)"
|
||||
msgstr ""
|
||||
msgstr "måned(er)"
|
||||
|
||||
#: pretix/control/forms/rrule.py:37
|
||||
msgid "week(s)"
|
||||
msgstr ""
|
||||
msgstr "uge(r)"
|
||||
|
||||
#: pretix/control/forms/rrule.py:38
|
||||
msgid "day(s)"
|
||||
@@ -16863,7 +16852,7 @@ msgstr "dag(e)"
|
||||
|
||||
#: pretix/control/forms/rrule.py:43
|
||||
msgid "Interval"
|
||||
msgstr ""
|
||||
msgstr "Interval"
|
||||
|
||||
#: pretix/control/forms/rrule.py:69
|
||||
msgid "Number of repetitions"
|
||||
@@ -16876,22 +16865,22 @@ msgstr "Seneste dato"
|
||||
#: pretix/control/forms/rrule.py:87 pretix/control/forms/rrule.py:134
|
||||
msgctxt "rrule"
|
||||
msgid "first"
|
||||
msgstr ""
|
||||
msgstr "første"
|
||||
|
||||
#: pretix/control/forms/rrule.py:88 pretix/control/forms/rrule.py:135
|
||||
msgctxt "rrule"
|
||||
msgid "second"
|
||||
msgstr ""
|
||||
msgstr "anden"
|
||||
|
||||
#: pretix/control/forms/rrule.py:89 pretix/control/forms/rrule.py:136
|
||||
msgctxt "rrule"
|
||||
msgid "third"
|
||||
msgstr ""
|
||||
msgstr "tredje"
|
||||
|
||||
#: pretix/control/forms/rrule.py:90 pretix/control/forms/rrule.py:137
|
||||
msgctxt "rrule"
|
||||
msgid "last"
|
||||
msgstr ""
|
||||
msgstr "sidste"
|
||||
|
||||
#: pretix/control/forms/rrule.py:111 pretix/control/forms/rrule.py:150
|
||||
#: pretix/presale/templates/pretixpresale/fragment_calendar_nav.html:20
|
||||
@@ -16905,7 +16894,7 @@ msgstr "Weekend dag"
|
||||
#: pretix/control/forms/subevents.py:106
|
||||
msgctxt "subevent"
|
||||
msgid "Skip dates that overlap with any existing date"
|
||||
msgstr ""
|
||||
msgstr "Spring datoer over, der overlapper med eksisterende datoer"
|
||||
|
||||
#: pretix/control/forms/subevents.py:109
|
||||
msgctxt "subevent"
|
||||
@@ -16915,20 +16904,24 @@ msgid ""
|
||||
"This respects even inactive dates and works best if all dates have both a "
|
||||
"start and end time."
|
||||
msgstr ""
|
||||
"Dette kan være nyttigt, hvis alle dine datoer finder sted på samme sted, og "
|
||||
"der ikke må oprettes gentagne datoer, der er i konflikt med eksisterende "
|
||||
"særlige begivenheder. Dette gælder også for inaktive datoer og fungerer "
|
||||
"bedst, hvis alle datoer har både en start- og en sluttid."
|
||||
|
||||
#: pretix/control/forms/subevents.py:128
|
||||
#, fuzzy
|
||||
msgid "Keep the current values"
|
||||
msgstr "Aktuelle problemer"
|
||||
msgstr "Bevar de nuværende værdier"
|
||||
|
||||
#: pretix/control/forms/subevents.py:145 pretix/control/forms/subevents.py:151
|
||||
msgid "Selection contains various values"
|
||||
msgstr ""
|
||||
msgstr "Udvalget indeholder forskellige værdier"
|
||||
|
||||
#: pretix/control/forms/subevents.py:298 pretix/control/forms/subevents.py:327
|
||||
#, fuzzy
|
||||
msgid "The end of availability should be after the start of availability."
|
||||
msgstr "Arrangementets sluttidspunkt skal være efter starttidspunktet."
|
||||
msgstr ""
|
||||
"Slutdatoen for tilgængeligheden bør ligge efter startdatoen for "
|
||||
"tilgængeligheden."
|
||||
|
||||
#: pretix/control/forms/subevents.py:360
|
||||
#, fuzzy
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-28 09:03+0000\n"
|
||||
"PO-Revision-Date: 2026-03-27 09:03+0000\n"
|
||||
"Last-Translator: Ivano Voghera <ivano.voghera@gmail.com>\n"
|
||||
"PO-Revision-Date: 2026-05-12 04:00+0000\n"
|
||||
"Last-Translator: Stefano Campus <stefano.campus@regione.piemonte.it>\n"
|
||||
"Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"it/>\n"
|
||||
"Language: it\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\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
|
||||
msgid "English"
|
||||
@@ -8598,6 +8598,8 @@ msgid ""
|
||||
"Includes the ability to give someone (including oneself) additional "
|
||||
"permissions."
|
||||
msgstr ""
|
||||
"Consente di assegnare a qualcuno (compreso se stessi) autorizzazioni "
|
||||
"aggiuntive."
|
||||
|
||||
#: pretix/base/permissions.py:298 pretix/control/navigation.py:608
|
||||
#: 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/control/navigation.py:673
|
||||
msgid "Devices"
|
||||
msgstr ""
|
||||
msgstr "Dispositivi"
|
||||
|
||||
#: pretix/base/permissions.py:316
|
||||
msgid ""
|
||||
"Includes the ability to give access to events and data oneself does not have "
|
||||
"access to."
|
||||
msgstr ""
|
||||
"Consente di concedere l'accesso a eventi e dati a cui non si ha accesso."
|
||||
|
||||
#: pretix/base/permissions.py:321
|
||||
#, fuzzy
|
||||
@@ -8747,6 +8750,8 @@ msgid ""
|
||||
"Some products can no longer be purchased and have been removed from your "
|
||||
"cart for the following reason: %s"
|
||||
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
|
||||
msgid ""
|
||||
@@ -10060,6 +10065,8 @@ msgid ""
|
||||
"For business customers, compute taxes based on net total. For individuals, "
|
||||
"use line-based rounding"
|
||||
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
|
||||
msgid "Compute taxes based on net total with stable gross prices"
|
||||
@@ -10096,6 +10103,8 @@ msgstr ""
|
||||
#: pretix/base/settings.py:190
|
||||
msgid "Require login to access order confirmation pages"
|
||||
msgstr ""
|
||||
"È necessario effettuare l'accesso per visualizzare le pagine di conferma "
|
||||
"dell'ordine"
|
||||
|
||||
#: pretix/base/settings.py:191
|
||||
msgid ""
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \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"
|
||||
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"ja/>\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\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
|
||||
msgid "English"
|
||||
@@ -4441,7 +4441,7 @@ msgstr "全ての製品(新規に作成されたものを含む)"
|
||||
#: pretix/base/models/checkin.py:56 pretix/plugins/badges/exporters.py:436
|
||||
#: pretix/plugins/checkinlists/exporters.py:854
|
||||
msgid "Limit to products"
|
||||
msgstr "商品の上限"
|
||||
msgstr "対象製品を限定"
|
||||
|
||||
#: pretix/base/models/checkin.py:60
|
||||
msgid ""
|
||||
@@ -6896,8 +6896,8 @@ msgstr "免税輸出品目、VAT非課税"
|
||||
msgctxt "tax_code"
|
||||
msgid "VAT exempt for EEA intra-community supply of goods and services"
|
||||
msgstr ""
|
||||
"EEA(欧州経済領域)域内事業者間取引における商品・サービス供給のVAT(付加価値"
|
||||
"税)免税"
|
||||
"EEA(欧州経済領域)域内事業者間取引における物品・サービス供給のVAT(付加価値税)"
|
||||
"免税"
|
||||
|
||||
#: pretix/base/models/tax.py:186
|
||||
msgid "Special cases"
|
||||
@@ -7144,10 +7144,10 @@ msgid ""
|
||||
"usages in some cases can be lower than this limit, e.g. in case of "
|
||||
"cancellations."
|
||||
msgstr ""
|
||||
"複数(1を超える値)に設定した場合、バウチャーは初回使用時にこの数の製品と引き"
|
||||
"換える必要があります。その後の使用では、より少ない数の製品にも使用できます。"
|
||||
"ただし、キャンセルなどの場合には、合計使用回数がこの制限を下回ることがありま"
|
||||
"す。"
|
||||
"1より大きい値を設定すると、バウチャーを最初に使用する際に、この数の製品に対し"
|
||||
"て引き換える必要があります。2回目以降の使用では、これより少ない数の製品に対し"
|
||||
"ても使用できます。この場合、キャンセルなどにより、合計の使用回数がこの上限を"
|
||||
"下回ることがある点にご注意ください。"
|
||||
|
||||
#: pretix/base/models/vouchers.py:217
|
||||
msgid ""
|
||||
@@ -8059,10 +8059,8 @@ msgstr ""
|
||||
"2x アドオン2"
|
||||
|
||||
#: pretix/base/pdf.py:383
|
||||
#, fuzzy
|
||||
#| msgid "List of Add-Ons"
|
||||
msgid "List of Checked-In Add-Ons"
|
||||
msgstr "アドオンのリスト"
|
||||
msgstr "チェックイン済みアドオン一覧"
|
||||
|
||||
#: pretix/base/pdf.py:390 pretix/control/forms/filter.py:1537
|
||||
#: pretix/control/forms/filter.py:1539
|
||||
@@ -9019,10 +9017,8 @@ msgid "Czech National Bank"
|
||||
msgstr "チェコ国立銀行"
|
||||
|
||||
#: pretix/base/services/currencies.py:41
|
||||
#, fuzzy
|
||||
#| msgid "Czech National Bank"
|
||||
msgid "National Bank of Poland"
|
||||
msgstr "チェコ国立銀行"
|
||||
msgstr "ポーランド国立銀行"
|
||||
|
||||
#: pretix/base/services/export.py:95 pretix/base/services/export.py:155
|
||||
msgid ""
|
||||
@@ -10068,14 +10064,10 @@ msgid ""
|
||||
msgstr "チェコ国立銀行の日次レートに基づいて、請求書の金額がCZK以外の場合。"
|
||||
|
||||
#: 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 ""
|
||||
"Based on National Bank of Poland daily rates, whenever the invoice amount is "
|
||||
"not in PLN."
|
||||
msgstr "チェコ国立銀行の日次レートに基づいて、請求書の金額がCZK以外の場合。"
|
||||
msgstr "ポーランド国立銀行の日次レートに基づいて、請求書の金額がPLN以外の場合。"
|
||||
|
||||
#: pretix/base/settings.py:597
|
||||
msgid "Require invoice address"
|
||||
@@ -15956,10 +15948,8 @@ msgid "Allow to overbook quotas when performing this operation"
|
||||
msgstr "この操作を実行する際にクォータの超過予約を許可する"
|
||||
|
||||
#: pretix/control/forms/orders.py:335
|
||||
#, fuzzy
|
||||
#| msgid "Number of orders"
|
||||
msgid "Number of products to add"
|
||||
msgstr "注文数"
|
||||
msgstr "追加する製品の数"
|
||||
|
||||
#: pretix/control/forms/orders.py:344
|
||||
msgid "Add-on to"
|
||||
@@ -15991,10 +15981,8 @@ msgstr ""
|
||||
"さい"
|
||||
|
||||
#: 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."
|
||||
msgstr "同じ席を複数回選択することはできません。"
|
||||
msgstr "複数の製品を同時に追加する場合、座席を選択することはできません。"
|
||||
|
||||
#: pretix/control/forms/orders.py:478 pretix/control/forms/orders.py:482
|
||||
#: pretix/control/forms/orders.py:510 pretix/control/forms/orders.py:552
|
||||
@@ -16596,7 +16584,7 @@ msgstr "週末の日"
|
||||
#: pretix/control/forms/subevents.py:106
|
||||
msgctxt "subevent"
|
||||
msgid "Skip dates that overlap with any existing date"
|
||||
msgstr ""
|
||||
msgstr "既存の日付と重複する日付をスキップする"
|
||||
|
||||
#: pretix/control/forms/subevents.py:109
|
||||
msgctxt "subevent"
|
||||
@@ -16606,6 +16594,9 @@ msgid ""
|
||||
"This respects even inactive dates and works best if all dates have both a "
|
||||
"start and end time."
|
||||
msgstr ""
|
||||
"これは、すべての日付が同じ場所で行われ、既存の特別イベントと競合して重複した"
|
||||
"日付が作成されない場合に有用です。これは、非アクティブな日付さえも尊重し、す"
|
||||
"べての日付に開始時刻と終了時刻の両方がある場合に最も効果的です。"
|
||||
|
||||
#: pretix/control/forms/subevents.py:128
|
||||
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 "
|
||||
"same time."
|
||||
msgstr ""
|
||||
"製品を実際に利用可能にするには、クォータも必要です。クォータは、pretixが製品"
|
||||
"のインスタンスをいくつ販売するかを定義します。これにより、イベントが無制限の"
|
||||
"参加者を受け入れることができるか、参加者数が制限されるかを設定できます。より"
|
||||
"複雑な要件を満たすために、製品を複数のクォータに割り当てることができます。例"
|
||||
"えば、販売されるチケットの総数と特定のチケット種別の数を同時に制限したい場合"
|
||||
"などです。"
|
||||
"製品を実際に販売可能にするには、クォータも必要です。クォータは、製品をどれだ"
|
||||
"けpretixが販売するかを定義します。これにより、イベントの参加者数を無制限にす"
|
||||
"るか、人数を制限するかを設定できます。1つの製品を複数のクォータに割り当てるこ"
|
||||
"とで、より複雑な要件にも対応できます。たとえば、販売するチケットの総数と特定"
|
||||
"のチケット種別の数を同時に制限したい場合などです。"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/items/quotas.html:25
|
||||
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 order."
|
||||
msgstr ""
|
||||
"自動割引によりこの商品の価格が引き下げられたか、同じ注文内の別の商品に対する"
|
||||
"自動割引によりこの製品の価格が引き下げられたか、同じ注文内の別の製品に対する"
|
||||
"割引計算の対象になっています。"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/index.html:496
|
||||
@@ -29425,7 +29415,7 @@ msgstr "一度に10万以上の日付を作成しないでください。"
|
||||
|
||||
#: pretix/control/views/subevents.py:966
|
||||
msgid "All dates would be skipped because they conflict with existing dates."
|
||||
msgstr ""
|
||||
msgstr "すべての日付は、既存の日付と衝突するため、スキップされます。"
|
||||
|
||||
#: pretix/control/views/subevents.py:1102
|
||||
#, python-brace-format
|
||||
@@ -34613,7 +34603,7 @@ msgid ""
|
||||
"changed because they are not on sale:"
|
||||
msgstr ""
|
||||
"このアドオンカテゴリで選択された製品の中には、現在セール対象外のため変更でき"
|
||||
"ない商品があります:"
|
||||
"ない製品があります:"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:392
|
||||
msgid "There are no add-ons available for this product."
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-28 09:04+0000\n"
|
||||
"PO-Revision-Date: 2026-03-23 21:00+0000\n"
|
||||
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
|
||||
"PO-Revision-Date: 2026-05-12 06:34+0000\n"
|
||||
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
|
||||
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
"js/ja/>\n"
|
||||
"Language: ja\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\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:62
|
||||
@@ -572,7 +572,7 @@ msgstr "未入場"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/checkinrules.js:289
|
||||
msgid "Error: Product not found!"
|
||||
msgstr "エラー:商品が見つかりません!"
|
||||
msgstr "エラー:製品が見つかりません!"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/checkinrules.js:296
|
||||
msgid "Error: Variation not found!"
|
||||
@@ -743,7 +743,7 @@ msgstr "カートの有効期限が近づいています。"
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:62
|
||||
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."
|
||||
msgstr[0] "カート内の商品はあと {num} 分間確保されています。"
|
||||
msgstr[0] "カート内の製品はあと {num} 分間確保されています。"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:83
|
||||
msgid "Your cart has expired."
|
||||
@@ -754,7 +754,7 @@ msgid ""
|
||||
"The items in your cart are no longer reserved for you. You can still "
|
||||
"complete your order as long as they're available."
|
||||
msgstr ""
|
||||
"カート内の商品の確保期限が切れました。在庫があれば、このまま注文を完了するこ"
|
||||
"カート内の製品の確保期限が切れました。在庫があれば、このまま注文を完了するこ"
|
||||
"とができます。"
|
||||
|
||||
#: 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 "
|
||||
"products, they will be added to your existing cart."
|
||||
msgstr ""
|
||||
"このイベントのカートに商品が入っています。商品を追加すると、既存のカートに追"
|
||||
"このイベントのカートに製品が入っています。製品を追加すると、既存のカートに追"
|
||||
"加されます。"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:57
|
||||
|
||||
@@ -82,7 +82,8 @@ class CheckInListMixin(BaseExporter):
|
||||
widget=forms.RadioSelect(
|
||||
attrs={'class': 'scrolling-choice'}
|
||||
),
|
||||
initial=self.event.checkin_lists.first()
|
||||
initial=self.event.checkin_lists.first(),
|
||||
required=True
|
||||
)),
|
||||
('date_range',
|
||||
DateFrameField(
|
||||
@@ -143,7 +144,6 @@ class CheckInListMixin(BaseExporter):
|
||||
if not self.event.has_subevents:
|
||||
del d['date_range']
|
||||
|
||||
d['list'].queryset = self.event.checkin_lists.all()
|
||||
d['list'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
@@ -155,7 +155,6 @@ class CheckInListMixin(BaseExporter):
|
||||
}
|
||||
)
|
||||
d['list'].widget.choices = d['list'].choices
|
||||
d['list'].required = True
|
||||
|
||||
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),
|
||||
]
|
||||
21
src/pretix/plugins/wallet/__init__.py
Normal file
21
src/pretix/plugins/wallet/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
84
src/pretix/plugins/wallet/api.py
Normal file
84
src/pretix/plugins/wallet/api.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from rest_framework import viewsets
|
||||
from django.db import transaction
|
||||
from .styles import PassLayout, AVAILABLE_STYLES_DICT
|
||||
from .models import WalletLayout
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
import django_filters.rest_framework
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from .views import get_layout_variables
|
||||
|
||||
|
||||
class WalletLayoutSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = WalletLayout
|
||||
fields = ("id", "platform", "name", "style", "layout")
|
||||
read_only_fields = ("id",)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance:
|
||||
self.fields['platform'].read_only = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs, event=self.context["event"])
|
||||
|
||||
def validate_platform(self, value):
|
||||
if self.instance and value != self.instance.platform:
|
||||
raise ValidationError(_("Platform cannot be changed"))
|
||||
|
||||
if value not in AVAILABLE_STYLES_DICT:
|
||||
raise ValidationError(_("Invalid platform"))
|
||||
return value
|
||||
|
||||
def validate_layout(self, value):
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError(_("Layout must be a dict"))
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
if self.instance:
|
||||
platform = self.instance.platform
|
||||
else:
|
||||
platform = data.get('platform', None)
|
||||
if "style" in data and "layout" in data and platform:
|
||||
platform_styles = AVAILABLE_STYLES_DICT[platform]
|
||||
|
||||
if data["style"] not in platform_styles:
|
||||
raise ValidationError(_("Invalid style"))
|
||||
style = platform_styles[data["style"]]
|
||||
|
||||
layout = PassLayout(style=style, layout=data["layout"])
|
||||
context = {"placeholders": get_layout_variables(self.context['event'])}
|
||||
layout.validate(context=context)
|
||||
return data
|
||||
|
||||
|
||||
class WalletLayoutViewSet(viewsets.ModelViewSet):
|
||||
model = WalletLayout
|
||||
queryset = WalletLayout.objects.none()
|
||||
serializer_class = WalletLayoutSerializer
|
||||
filter_backends = (django_filters.rest_framework.DjangoFilterBackend,)
|
||||
filterset_fields = ["platform"]
|
||||
permission = "event.settings.general:write"
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.wallet_layouts.all()
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
return super().get_serializer(*args, **kwargs)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx["event"] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
super().perform_update(serializer)
|
||||
serializer.instance.log_action(
|
||||
action="pretix.plugins.wallet.layout.changed",
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
)
|
||||
41
src/pretix/plugins/wallet/apps.py
Normal file
41
src/pretix/plugins/wallet/apps.py
Normal file
@@ -0,0 +1,41 @@
|
||||
#
|
||||
# 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.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix import __version__ as version
|
||||
|
||||
|
||||
class WalletApp(AppConfig):
|
||||
name = 'pretix.plugins.wallet'
|
||||
verbose_name = _("wallet")
|
||||
|
||||
class PretixPluginMeta:
|
||||
name = _("wallet")
|
||||
author = _("the pretix team")
|
||||
version = version
|
||||
category = 'FORMAT'
|
||||
description = _("Issue wallet passes for tickets (e.g. apple wallet, google wallet)")
|
||||
|
||||
def ready(self):
|
||||
from . import signals # NOQA
|
||||
|
||||
44
src/pretix/plugins/wallet/migrations/0001_initial.py
Normal file
44
src/pretix/plugins/wallet/migrations/0001_initial.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 4.2.28 on 2026-03-17 16:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0297_outgoingmail"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WalletLayout",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=190)),
|
||||
("platform", models.CharField(max_length=10)),
|
||||
("style", models.CharField(max_length=255)),
|
||||
("layout", models.TextField()),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="wallet_layouts",
|
||||
to="pretixbase.event",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("name",),
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
]
|
||||
0
src/pretix/plugins/wallet/migrations/__init__.py
Normal file
0
src/pretix/plugins/wallet/migrations/__init__.py
Normal file
63
src/pretix/plugins/wallet/models.py
Normal file
63
src/pretix/plugins/wallet/models.py
Normal file
@@ -0,0 +1,63 @@
|
||||
#
|
||||
# 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.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
|
||||
class WalletLayout(LoggedModel):
|
||||
event = models.ForeignKey(
|
||||
'pretixbase.Event',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='wallet_layouts'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=190,
|
||||
verbose_name=_('Name')
|
||||
)
|
||||
platform = models.CharField(max_length=10)
|
||||
style = models.CharField(max_length=255)
|
||||
layout = models.JSONField(default=dict)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
ordering = ("name",)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class WalletLayoutItem(models.Model):
|
||||
item = models.ForeignKey('pretixbase.Item', null=True, blank=True, related_name='walletlayout_assignments',
|
||||
on_delete=models.CASCADE)
|
||||
layout = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name='item_assignments')
|
||||
sales_channel = models.ForeignKey(
|
||||
"pretixbase.SalesChannel",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('item', 'layout', 'sales_channel'),)
|
||||
ordering = ("id",)
|
||||
0
src/pretix/plugins/wallet/serializer.py
Normal file
0
src/pretix/plugins/wallet/serializer.py
Normal file
35
src/pretix/plugins/wallet/signals.py
Normal file
35
src/pretix/plugins/wallet/signals.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#
|
||||
# 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 pretix.base.signals import register_ticket_outputs
|
||||
from .ticketoutput import OUTPUTS
|
||||
|
||||
def connect_signals():
|
||||
for output in OUTPUTS:
|
||||
# DIY functools.partial to make get_defining_app happy
|
||||
def get_register_func(o):
|
||||
def register(sender, **kwargs):
|
||||
return o
|
||||
return register
|
||||
register_ticket_outputs.connect(get_register_func(output), dispatch_uid=f"output_{output.identifier}")
|
||||
|
||||
connect_signals()
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from "vue";
|
||||
import StyleSettings from "./style-settings.vue";
|
||||
import Select from "./input/select.vue";
|
||||
import Input from "./input/input.vue";
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const isLoading = ref<boolean>(true);
|
||||
const wallet_layout = ref<Layout | null>(null);
|
||||
|
||||
const STYLES: Styles = JSON.parse(
|
||||
document.querySelector("#styles")?.textContent ?? "{}",
|
||||
);
|
||||
const VARIABLES: VariableConfig = JSON.parse(
|
||||
document.querySelector("#variables")?.textContent ?? "{}",
|
||||
);
|
||||
const LOCALES: Record<string, string> = JSON.parse(
|
||||
document.querySelector("#locales")?.textContent ?? "{}",
|
||||
);
|
||||
const CSRF_TOKEN =
|
||||
document.querySelector<HTMLInputElement>("input[name=csrfmiddlewaretoken]")
|
||||
?.value ?? "";
|
||||
|
||||
const props = defineProps<{
|
||||
layoutId: string;
|
||||
}>();
|
||||
|
||||
watchEffect(() => {
|
||||
// TODO: error handling / proper api client
|
||||
isLoading.value = true;
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${props.layoutId}/`,
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
wallet_layout.value = x;
|
||||
isLoading.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
function saveLayout(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
isLoading.value = true;
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${props.layoutId}/`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-CSRFToken": CSRF_TOKEN,
|
||||
},
|
||||
body: JSON.stringify(wallet_layout.value),
|
||||
},
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
wallet_layout.value = x;
|
||||
isLoading.value = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
// TODO: add :key for all `v-for`s
|
||||
// TODO: i18n textfields
|
||||
// TODO: proper spinner
|
||||
template(v-if="isLoading") {{ gettext("Loading...") }}
|
||||
form(v-else @submit="saveLayout")
|
||||
.row
|
||||
.col-md-8
|
||||
.form-group()
|
||||
Input(label="Name" v-model="wallet_layout.name")
|
||||
|
||||
.form-group()
|
||||
Select(label="Style" v-model="wallet_layout.style" :choices="Object.values(STYLES).map(x => [x.identifier, x.name])")
|
||||
|
||||
StyleSettings(v-if="wallet_layout.style" v-model="wallet_layout.layout" :style="STYLES[wallet_layout.style]" :variables="VARIABLES" :locales="LOCALES")
|
||||
.col-md-4
|
||||
.panel.panel-default
|
||||
.panel-heading Preview
|
||||
.panel-body
|
||||
// TODO: Preview
|
||||
pre
|
||||
code {{ wallet_layout }}
|
||||
pre(v-if="wallet_layout.style")
|
||||
code {{ STYLES[wallet_layout.style] }}
|
||||
.form-group.submit-group
|
||||
button.btn.btn-primary.btn-save(type="submit") Submit
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
errors?: string[],
|
||||
locales: Record<string, string>
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel<Record<string, string> | string>();
|
||||
watchEffect(() => {
|
||||
if (typeof modelValue.value === "string") {
|
||||
const oldVal = modelValue.value;
|
||||
modelValue.value = Object.fromEntries(Object.keys(props.locales).map((x): [string, string] => [x, oldVal]))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
input.form-control(v-for="(human_readable, locale) in locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="human_readable")
|
||||
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string,
|
||||
errors?: string[],
|
||||
}>()
|
||||
const modelValue = defineModel<string|null>();
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
label.control-label(:for="id", v-if="props.label") {{ props.label }}
|
||||
input.form-control(:id="id" v-model="modelValue" v-bind="$attrs")
|
||||
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { useId, watchEffect } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
choices: Array<[string, string]>
|
||||
errors?: string[]
|
||||
}>()
|
||||
const modelValue = defineModel<string|null>();
|
||||
const id = useId()
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.choices.length === 1) {
|
||||
modelValue.value = props.choices[0][0]
|
||||
} else if (props.choices.length < 1) {
|
||||
modelValue.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
template(v-if="choices.length >= 1")
|
||||
label.control-label(v-if="props.label" :for="id") {{ props.label }}
|
||||
select.form-control(:id="id" v-model="modelValue" v-bind="$attrs" required)
|
||||
option(v-for="choice in props.choices" :key="choice[0]" :value="choice[0]") {{ choice[1] }}
|
||||
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watchEffect } from "vue";
|
||||
import Select from "./input/select.vue";
|
||||
import Input from "./input/input.vue";
|
||||
import I18nInput from "./input/i18ninput.vue";
|
||||
import TextContent from "./text-content.vue";
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const props = defineProps<{
|
||||
fieldgroup: PlaceholderFieldGroupDefinition;
|
||||
overflows: FieldGroupDefinition[];
|
||||
variables: Variables;
|
||||
locales: Record<string, string>;
|
||||
}>();
|
||||
const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({ required: true });
|
||||
|
||||
const overflowOptions = computed((): Array<[string | null, string]> => {
|
||||
if (props.overflows.length) {
|
||||
return [
|
||||
...props.overflows.map((x): [string, string] => [x.identifier, x.name]),
|
||||
[null, "Do not overflow"],
|
||||
];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
function addVariable() {
|
||||
fieldConfig.value.entries.push({ type: "placeholder", label: "" });
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!fieldConfig.value) {
|
||||
fieldConfig.value = {overflow: null, entries: JSON.parse(JSON.stringify(props.fieldgroup.default_entries))};
|
||||
}
|
||||
if (fieldConfig.value && !fieldConfig.value.entries) {
|
||||
fieldConfig.value.entries = JSON.parse(JSON.stringify(props.fieldgroup.default_entries))
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.panel.panel-default
|
||||
.panel-heading
|
||||
h3.panel-title {{ fieldgroup.name }}
|
||||
.panel-body(v-if="fieldConfig")
|
||||
.form-group()
|
||||
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
|
||||
h4 {{ gettext("Content") }}
|
||||
table.table.table-hover
|
||||
thead
|
||||
tr
|
||||
th.col-md-5(v-if="fieldgroup.labels") {{ gettext('Label') }}
|
||||
th(:class="'col-md-' + (fieldgroup.labels ? '6' : '11')") {{ gettext('Content') }}
|
||||
th.col-xs-1
|
||||
tbody
|
||||
tr(v-for="n,i in fieldConfig.entries.length" :key="i")
|
||||
td(v-if="fieldgroup.labels")
|
||||
.i18n-form-group
|
||||
I18nInput(v-model="fieldConfig.entries[n-1].label" :locales="locales")
|
||||
td
|
||||
TextContent(v-if='fieldgroup.content_type == "text"'
|
||||
v-model="fieldConfig.entries[n-1]"
|
||||
:variables="props.variables"
|
||||
:locales="locales")
|
||||
Select(v-else-if='fieldgroup.content_type == "image"'
|
||||
v-model="fieldConfig.entries[n-1].content"
|
||||
:choices="Object.entries(props.variables).map(([k,v]) => [k, v.label])"
|
||||
)
|
||||
td.text-right
|
||||
button.btn.btn-danger.form-control-static(type="button" @click="fieldConfig.entries.splice(n-1, 1)")
|
||||
i.fa.fa-trash
|
||||
span.sr-only {{ gettext('Delete')}}
|
||||
|
||||
button.btn.btn-default(type="button" @click="addVariable")
|
||||
i.fa.fa-plus
|
||||
| {{ gettext("Add field") }}
|
||||
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const props = defineProps<{
|
||||
fieldgroup: FieldGroupDefinition;
|
||||
}>();
|
||||
const fieldConfig = defineModel<PredefinedFieldGroupConfig>({ required: true });
|
||||
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.panel.panel-default
|
||||
.panel-heading
|
||||
h3.panel-title {{ fieldgroup.name }}
|
||||
.panel-body
|
||||
.form-group
|
||||
span.text-muted These fields appear somewhere and are visible too.
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watchEffect } from "vue";
|
||||
import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
|
||||
import PredefinedFieldSettings from "./predefined-field-settings.vue";
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const props = defineProps<{
|
||||
variables: VariableConfig
|
||||
style?: Style;
|
||||
locales: Record<string, string>;
|
||||
}>();
|
||||
|
||||
const layout = defineModel<LayoutData>();
|
||||
|
||||
watchEffect(() => {
|
||||
if (layout.value === undefined) {
|
||||
return
|
||||
}
|
||||
if (layout.value.fieldgroups === undefined) {
|
||||
layout.value.fieldgroups = {};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
h2.h3 {{ gettext("Field Groups") }}
|
||||
template(v-if="props.style && layout.fieldgroups"
|
||||
v-for="(fieldgroup, fieldgroupId) in props.style.fieldgroups")
|
||||
PlaceholderFieldSettings(
|
||||
v-if="fieldgroup.type == 'placeholder'"
|
||||
v-model="layout.fieldgroups[fieldgroup.identifier]"
|
||||
:fieldgroup="fieldgroup"
|
||||
:overflows="props.style.fieldgroups.slice(fieldgroupId + 1).filter(x => x.type == 'placeholder' && x.content_type === fieldgroup.content_type)"
|
||||
:variables="variables[fieldgroup.content_type]"
|
||||
:locales="locales"
|
||||
)
|
||||
PredefinedFieldSettings(v-else-if="fieldgroup.type == 'predefined'"
|
||||
v-model="layout.fieldgroups[fieldgroup.identifier]"
|
||||
:fieldgroup="fieldgroup")
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from 'vue'
|
||||
import Select from './input/select.vue'
|
||||
import I18nInput from './input/i18ninput.vue'
|
||||
|
||||
const gettext = (window as any).gettext
|
||||
|
||||
const props = defineProps<{
|
||||
variables: Variables
|
||||
locales: Record<string, string>;
|
||||
}>()
|
||||
const entry = defineModel<FieldEntry>({ required: true })
|
||||
|
||||
const selectChoices = computed(() =>{
|
||||
const choices = Object.entries(props.variables).map(([k,v]): [string, string] => [k, v.label])
|
||||
choices.push(["other", gettext("Other…")])
|
||||
return choices
|
||||
});
|
||||
|
||||
const selection = computed({
|
||||
get() {
|
||||
if (entry.value.type === 'placeholder') {
|
||||
return entry.value.content
|
||||
} else if (entry.value.type === 'text') {
|
||||
return "other"
|
||||
} else {
|
||||
throw new Error(`Unknown entry type "${entry.value.type}"`);
|
||||
}
|
||||
},
|
||||
set(newValue) {
|
||||
if (newValue == "other") {
|
||||
entry.value.type = "text"
|
||||
entry.value.content = {};
|
||||
} else {
|
||||
entry.value.type = "placeholder"
|
||||
entry.value.content = newValue
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const textContent = computed({
|
||||
get() {
|
||||
if (entry.value.type === 'placeholder') {
|
||||
return ""
|
||||
} else if (entry.value.type === 'text') {
|
||||
return entry.value.content
|
||||
} else {
|
||||
throw new Error(`Unknown entry type "${entry.value.type}"`);
|
||||
}
|
||||
},
|
||||
set(newValue) {
|
||||
entry.value.content = newValue
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.i18n-form-group
|
||||
Select(
|
||||
v-model="selection"
|
||||
:choices="selectChoices"
|
||||
)
|
||||
I18nInput(v-model="textContent" v-if="selection === 'other'" :locales="locales")
|
||||
</template>
|
||||
75
src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts
vendored
Normal file
75
src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
type BaseFieldGroupDefinition = {
|
||||
type: string;
|
||||
identifier: string;
|
||||
name: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
type FieldGroupDefinition = PlaceholderFieldGroupDefinition | PredefinedFieldGroupDefinition;
|
||||
|
||||
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
|
||||
type: 'placeholder';
|
||||
content_type: FieldContentType;
|
||||
default_entries: FieldEntry[];
|
||||
labels: boolean;
|
||||
min_entries: number|null;
|
||||
max_entries: number|null;
|
||||
}
|
||||
|
||||
type PredefinedFieldGroupDefinition = BaseFieldGroupDefinition & {
|
||||
type: 'predefined';
|
||||
}
|
||||
|
||||
type I18nString = string | Record<string, string>
|
||||
|
||||
type FieldContentType = 'text' | 'image';
|
||||
|
||||
type PlaceholderFieldEntry = {
|
||||
type: 'placeholder';
|
||||
label?: I18nString;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
type ContentFieldEntry = {
|
||||
type: FieldContentType;
|
||||
label?: I18nString;
|
||||
content?: I18nString;
|
||||
}
|
||||
|
||||
type FieldEntry = PlaceholderFieldEntry | ContentFieldEntry;
|
||||
|
||||
type Style = {
|
||||
identifier: string;
|
||||
name: string;
|
||||
fieldgroups: FieldGroupDefinition[];
|
||||
};
|
||||
|
||||
type Variable = {
|
||||
label: string
|
||||
};
|
||||
|
||||
type Styles = Record<string, Style>;
|
||||
type Variables = Record<string, Variable>;
|
||||
type VariableConfig = Record<string, Variables>;
|
||||
|
||||
|
||||
|
||||
type PlaceholderFieldGroupConfig = {
|
||||
entries: Array<FieldEntry>;
|
||||
overflow: string | null;
|
||||
};
|
||||
|
||||
type PredefinedFieldGroupConfig = {};
|
||||
|
||||
type FieldGroupConfig = PlaceholderFieldGroupConfig | PredefinedFieldGroupConfig;
|
||||
|
||||
type LayoutData = {
|
||||
fieldgroups: Record<string, FieldGroupConfig>;
|
||||
};
|
||||
|
||||
type Layout = {
|
||||
name?: string;
|
||||
style?: string;
|
||||
layout?: LayoutData;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './components/app.vue'
|
||||
|
||||
const mountEl = document.querySelector<HTMLElement>('#editor')!
|
||||
const app = createApp(App, mountEl.dataset)
|
||||
app.mount(mountEl)
|
||||
|
||||
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)
|
||||
}
|
||||
17
src/pretix/plugins/wallet/styles/__init__.py
Normal file
17
src/pretix/plugins/wallet/styles/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from .apple import ApplePlatform, AppleWalletEventTicket
|
||||
from .google import GooglePlatform, GoogleWalletEventTicket
|
||||
from .base import PassLayout
|
||||
|
||||
AVAILABLE_PLATFORMS = {"apple": ApplePlatform, "google": GooglePlatform}
|
||||
AVAILABLE_STYLES = {
|
||||
"apple": [AppleWalletEventTicket()],
|
||||
"google": [
|
||||
GoogleWalletEventTicket()
|
||||
],
|
||||
}
|
||||
|
||||
AVAILABLE_STYLES_DICT = {
|
||||
plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items()
|
||||
}
|
||||
|
||||
__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassLayout"]
|
||||
256
src/pretix/plugins/wallet/styles/apple.py
Normal file
256
src/pretix/plugins/wallet/styles/apple.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from .base import (
|
||||
FieldEntryType,
|
||||
ImageFieldGroup,
|
||||
PlaceholderFieldGroup,
|
||||
PredefinedFieldGroup,
|
||||
TextFieldGroup,
|
||||
WalletPlatform,
|
||||
PassStyle,
|
||||
PlaceholderFieldEntry,
|
||||
)
|
||||
from django.utils.translation import gettext as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
import io
|
||||
import hashlib
|
||||
import zipfile
|
||||
import cryptography
|
||||
import cryptography.hazmat.primitives.serialization.pkcs7
|
||||
import json
|
||||
from django.contrib.staticfiles import finders
|
||||
|
||||
|
||||
|
||||
class ApplePlatform(WalletPlatform):
|
||||
identifier = "apple"
|
||||
name = _("Apple")
|
||||
|
||||
|
||||
class StringResource:
|
||||
# mapping string in default event locale -> LazyI18nString
|
||||
entries: dict[str, LazyI18nString]
|
||||
locales: set[str]
|
||||
|
||||
def __init__(self, locales):
|
||||
self.entries = {}
|
||||
self.locales = set(locales)
|
||||
|
||||
def add_entry(self, key: str, value: LazyI18nString):
|
||||
if key in self.entries:
|
||||
raise ValueError(f"{key} already exists in this StringResource")
|
||||
self.entries[key] = value
|
||||
|
||||
def escape(self, string):
|
||||
return string.translate(
|
||||
str.maketrans({'"': '\\"', "\r": "\\r", "\n": "\\n", "\\": "\\\\"})
|
||||
)
|
||||
|
||||
def generate_resource(self, language):
|
||||
output = ""
|
||||
for key, entry in self.entries.items():
|
||||
output += (
|
||||
f'"{self.escape(key)}" = "{self.escape(entry.localize(language))}";\n'
|
||||
)
|
||||
return output.strip()
|
||||
|
||||
def generate(self):
|
||||
return {language: self.generate_resource(language) for language in self.locales}
|
||||
|
||||
|
||||
class SignedZipFile:
|
||||
"""Generates a zip-file with manifest and signature as apple expects a pkpass file to be"""
|
||||
|
||||
def __init__(self, ca_certificate, certificate, key, password):
|
||||
self.ca_certificate = cryptography.x509.load_pem_x509_certificate(
|
||||
ca_certificate
|
||||
)
|
||||
self.certificate = cryptography.x509.load_pem_x509_certificate(certificate)
|
||||
self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
|
||||
key, password
|
||||
)
|
||||
self.password = password
|
||||
|
||||
self.file = io.BytesIO()
|
||||
self.zip_file = zipfile.ZipFile(self.file, "w")
|
||||
self.manifest = {}
|
||||
|
||||
def sign(self, data: bytes):
|
||||
return (
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7SignatureBuilder()
|
||||
.set_data(data)
|
||||
.add_signer(
|
||||
self.certificate,
|
||||
self.key,
|
||||
cryptography.hazmat.primitives.hashes.SHA256(),
|
||||
)
|
||||
.add_certificate(self.ca_certificate)
|
||||
.sign(
|
||||
cryptography.hazmat.primitives.serialization.Encoding.DER,
|
||||
[
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.Binary,
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.DetachedSignature,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def finish(self):
|
||||
manifest = json.dumps(self.manifest).encode()
|
||||
signature = self.sign(manifest)
|
||||
self.add_file("manifest.json", manifest)
|
||||
self.add_file("signature", signature)
|
||||
self.zip_file.close()
|
||||
return self.file.getvalue()
|
||||
|
||||
def add_file(self, filename: str, content: str | bytes):
|
||||
if isinstance(content, str):
|
||||
content = content.encode()
|
||||
|
||||
with self.zip_file.open(filename, "w") as f:
|
||||
f.write(content)
|
||||
self.manifest[filename] = hashlib.sha1(content).hexdigest()
|
||||
|
||||
|
||||
class AppleWalletStyle(PassStyle):
|
||||
platform = ApplePlatform
|
||||
|
||||
def pass_content(self, layout, context, strings):
|
||||
raise NotImplementedError()
|
||||
|
||||
def generate_pass_json(self, layout, context, strings):
|
||||
def add_from_context(key):
|
||||
value = context.get(key)
|
||||
if not value:
|
||||
raise ValueError(f"{key} must be set to a truthy value")
|
||||
return value
|
||||
|
||||
pass_json = {
|
||||
"formatVersion": 1,
|
||||
"description": add_from_context("description"),
|
||||
"organizationName": add_from_context("organizationName"),
|
||||
"passTypeIdentifier": add_from_context("passTypeIdentifier"),
|
||||
"teamIdentifier": add_from_context("teamIdentifier"),
|
||||
"serialNumber": add_from_context("serialNumber"),
|
||||
**self.pass_content(layout, context, strings),
|
||||
}
|
||||
return pass_json
|
||||
|
||||
def generate(self, layout, context):
|
||||
for key in ["ca_certificate", "certificate", "key", "password", "locales"]:
|
||||
if key not in context:
|
||||
raise ValueError(f"{key} missing from context")
|
||||
pkpass = SignedZipFile(
|
||||
context["ca_certificate"],
|
||||
context["certificate"],
|
||||
context["key"],
|
||||
context["password"],
|
||||
)
|
||||
strings = StringResource(locales=context['locales'])
|
||||
|
||||
pass_json = self.generate_pass_json(layout, context, strings)
|
||||
print(pass_json)
|
||||
pkpass.add_file(
|
||||
"icon.png", open(finders.find("pretix_passbook/icon.png"), "rb").read()
|
||||
)
|
||||
|
||||
pkpass.add_file("pass.json", json.dumps(pass_json))
|
||||
return pkpass.finish()
|
||||
|
||||
|
||||
class AppleWalletEventTicket(AppleWalletStyle):
|
||||
identifier = "event_1"
|
||||
name = _("Event Ticket Layout 1")
|
||||
fieldgroups = [
|
||||
ImageFieldGroup(
|
||||
identifier="logo",
|
||||
name=_("Logo"),
|
||||
min_entries=0,
|
||||
max_entries=1,
|
||||
labels=False,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
content="poweredby",
|
||||
)
|
||||
],
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="primary",
|
||||
name=_("Primary"),
|
||||
min_entries=1,
|
||||
max_entries=1,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
|
||||
content="item",
|
||||
)
|
||||
], # TODO: support Lazyi18nproxy here
|
||||
description=_("These fields appear prominently featured on the pass."),
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="secondary", name=_("Secondary"), max_entries=4
|
||||
), # TODO: validation of max field count if combined "Coupons, store cards, and generic passes with a square barcode can have a total of up to four secondary and auxiliary fields, combined."
|
||||
TextFieldGroup(
|
||||
identifier="headers", name=_("Header"), max_entries=3
|
||||
), # TODO: header image
|
||||
TextFieldGroup(identifier="auxillary", name=_("Auxillary"), max_entries=4),
|
||||
TextFieldGroup(identifier="back", name=_("Back")),
|
||||
]
|
||||
# preview_image = "apple/event_ticket.svg"
|
||||
|
||||
def get_pass_fields(self, layout, context):
|
||||
fields = {}
|
||||
for group in self.fieldgroups:
|
||||
if isinstance(group, PredefinedFieldGroup):
|
||||
pass
|
||||
elif isinstance(group, PlaceholderFieldGroup):
|
||||
group_fields = []
|
||||
if group.identifier in layout["fieldgroups"]:
|
||||
for field in layout["fieldgroups"][group.identifier]["entries"]:
|
||||
field_entry = {}
|
||||
if group.labels:
|
||||
field_entry["label"] = LazyI18nString(field["label"])
|
||||
if field["type"] == FieldEntryType.PLACEHOLDER.value:
|
||||
placeholder = (
|
||||
context.get("placeholders")
|
||||
.get(group.content_type.value, {})
|
||||
.get(field["content"])
|
||||
)
|
||||
if placeholder:
|
||||
placeholder_value = placeholder["evaluate"](
|
||||
*context.get("evaluation_context", [])
|
||||
)
|
||||
if placeholder_value:
|
||||
field_entry["value"] = placeholder_value
|
||||
elif field["type"] == FieldEntryType.TEXT.value:
|
||||
placeholder_value = LazyI18nString(field["content"])
|
||||
elif field["type"] == FieldEntryType.IMAGE.value:
|
||||
raise NotImplementedError(
|
||||
"Image placeholders not implemented"
|
||||
)
|
||||
if "value" in field_entry and field_entry["value"]:
|
||||
group_fields.append(field_entry)
|
||||
if group.min_entries and len(group_fields) < group.min_entries:
|
||||
raise ValueError(
|
||||
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(group_fields)} were provided"
|
||||
)
|
||||
fields[group.identifier] = group_fields[: group.max_entries]
|
||||
else:
|
||||
raise ValueError("Unknown field group")
|
||||
return fields
|
||||
|
||||
def convert_fields(self, strings, fields):
|
||||
converted = []
|
||||
for i,f in enumerate(fields):
|
||||
converted_field = {**f, "key": f"primary-{i}"}
|
||||
if "label" in converted_field and isinstance(converted_field['label'], LazyI18nString):
|
||||
strings.add_entry(f"primary-{i}-label", converted_field['label'])
|
||||
converted_field['label'] = f"primary-{i}-label"
|
||||
|
||||
converted.append(converted_field)
|
||||
return converted
|
||||
|
||||
def pass_content(self, layout, context, strings):
|
||||
fields = self.get_pass_fields(layout, context)
|
||||
return {
|
||||
"eventTicket": {
|
||||
"primaryFields": self.convert_fields(strings, fields['primary'])
|
||||
}
|
||||
}
|
||||
298
src/pretix/plugins/wallet/styles/base.py
Normal file
298
src/pretix/plugins/wallet/styles/base.py
Normal file
@@ -0,0 +1,298 @@
|
||||
import enum
|
||||
from i18nfield.strings import LazyI18nString
|
||||
import jsonschema
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
class WalletPlatform:
|
||||
identifier: str
|
||||
name: str
|
||||
|
||||
|
||||
class FieldGroupType(enum.Enum):
|
||||
PLACEHOLDER = "placeholder"
|
||||
PREDEFINED = "predefined"
|
||||
|
||||
|
||||
class FieldGroup:
|
||||
type: FieldGroupType
|
||||
identifier: str
|
||||
name: str
|
||||
description: str
|
||||
required: bool = False
|
||||
|
||||
def __init__(self, identifier: str, name: str, description=None, required=False):
|
||||
self.identifier = identifier
|
||||
self.name = name
|
||||
self.required = required
|
||||
self.description = description or ""
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: dict,
|
||||
) -> dict:
|
||||
raise NotImplemented()
|
||||
|
||||
def asdict(self):
|
||||
return {
|
||||
"type": self.type.value,
|
||||
"identifier": self.identifier,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"required": self.required,
|
||||
}
|
||||
|
||||
|
||||
class FieldContentType(enum.Enum):
|
||||
IMAGE = "image"
|
||||
TEXT = "text"
|
||||
|
||||
|
||||
class FieldEntryType(enum.Enum):
|
||||
IMAGE = "image"
|
||||
TEXT = "text"
|
||||
PLACEHOLDER = "placeholder"
|
||||
|
||||
|
||||
class FieldEntry[T]:
|
||||
type: FieldEntryType
|
||||
label: LazyI18nString | None
|
||||
content: T
|
||||
|
||||
def __init__(
|
||||
self, type: FieldEntryType, content: T, label: LazyI18nString | None = None
|
||||
):
|
||||
self.type = type
|
||||
self.label = label
|
||||
self.content = content
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {"type": self.type.value, "content": self.content, "label": self.label.data if self.label else None}
|
||||
|
||||
class PlaceholderFieldEntry(FieldEntry[str]):
|
||||
type = FieldEntryType.PLACEHOLDER
|
||||
label: LazyI18nString | None
|
||||
content: str
|
||||
|
||||
def __init__(
|
||||
self, content: str, label: LazyI18nString | None = None
|
||||
):
|
||||
self.label = label
|
||||
self.content = content
|
||||
|
||||
|
||||
class CustomFieldEntry(FieldEntry[LazyI18nString]):
|
||||
type: FieldEntryType
|
||||
label: LazyI18nString | None
|
||||
content: LazyI18nString
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {"type": self.type.value, "content": self.content.data, "label": self.label.data if self.label else None}
|
||||
|
||||
|
||||
|
||||
class PredefinedFieldGroup(FieldGroup):
|
||||
type = FieldGroupType.PREDEFINED
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: dict,
|
||||
):
|
||||
return {
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
class PlaceholderFieldGroup(FieldGroup):
|
||||
type = FieldGroupType.PLACEHOLDER
|
||||
content_type: FieldContentType
|
||||
default_entries: list[FieldEntry]
|
||||
labels: bool
|
||||
min_entries: int | None
|
||||
max_entries: int | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
name: str,
|
||||
content_type: FieldContentType,
|
||||
description: str=None,
|
||||
required=False,
|
||||
default_entries=None,
|
||||
min_entries=None,
|
||||
max_entries=None,
|
||||
labels=True,
|
||||
):
|
||||
super().__init__(identifier, name, description, required)
|
||||
self.content_type = content_type
|
||||
self.default_entries = default_entries or []
|
||||
self.min_entries = min_entries
|
||||
self.max_entries = max_entries
|
||||
self.labels = labels
|
||||
|
||||
if self.required and (self.min_entries is None or self.min_entries < 1):
|
||||
self.min_entries = 1
|
||||
|
||||
def asdict(self):
|
||||
return {
|
||||
**super().asdict(),
|
||||
"content_type": self.content_type.value,
|
||||
"default_entries": [x.asdict() for x in self.default_entries],
|
||||
"labels": self.labels,
|
||||
"min_entries": self.min_entries,
|
||||
"max_entries": self.max_entries,
|
||||
}
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: dict,
|
||||
):
|
||||
placeholders = list(context.get("placeholders", {}).get(self.content_type.value, {}).keys())
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": self.entries_schema(placeholders=placeholders),
|
||||
"overflow": {
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
f.identifier
|
||||
for f in remaining_fields
|
||||
if isinstance(f, PlaceholderFieldGroup)
|
||||
and f.content_type == self.content_type
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
"required": ["entries"],
|
||||
}
|
||||
|
||||
def entries_schema(self, placeholders: list[str]):
|
||||
baseprops = {}
|
||||
if self.labels:
|
||||
baseprops["label"] = {"$ref": "#/$defs/I18nString"}
|
||||
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"anyOf": [
|
||||
{
|
||||
"properties": {
|
||||
**baseprops,
|
||||
"type": {"const": "placeholder"},
|
||||
"content": {"enum": placeholders},
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
**baseprops,
|
||||
"type": {"const": self.content_type.value},
|
||||
"content": {"$ref": "#/$defs/I18nString"},
|
||||
}
|
||||
},
|
||||
],
|
||||
"required": ["type", "content"],
|
||||
},
|
||||
}
|
||||
if self.labels:
|
||||
schema["items"]["required"].append("label")
|
||||
if self.min_entries is not None:
|
||||
schema["minItems"] = self.min_entries
|
||||
# max_entries is not enforced here, as the layout can have more fields than that (null-fields are removed, rest is overspilled)
|
||||
return schema
|
||||
|
||||
|
||||
|
||||
class TextFieldGroup(PlaceholderFieldGroup):
|
||||
content_type = FieldContentType.TEXT
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(content_type=self.content_type, **kwargs)
|
||||
|
||||
|
||||
class ImageFieldGroup(PlaceholderFieldGroup):
|
||||
content_type = FieldContentType.IMAGE
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(content_type=self.content_type, **kwargs)
|
||||
|
||||
|
||||
class PassStyle:
|
||||
platform: type[WalletPlatform]
|
||||
identifier: str # unique within platform
|
||||
name: str
|
||||
# order here limits in what order users can configure field "overspilling" (if too many fields are defined, where should the rest go) -> can only go down in the list
|
||||
# we evaluate the fields in this order, so they overspill in this order as well (fields from primary are appended to the overspilling field before fields from secondary are etc)
|
||||
|
||||
fieldgroups: list[FieldGroup]
|
||||
|
||||
def asdict(self):
|
||||
return {
|
||||
"platform": self.platform.identifier,
|
||||
"identifier": self.identifier,
|
||||
"name": self.name,
|
||||
"fieldgroups": [x.asdict() for x in self.fieldgroups],
|
||||
}
|
||||
|
||||
def layout_schema(self, context):
|
||||
schema = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
# TODO: $id
|
||||
"title": self.name,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fieldgroups": {
|
||||
"description": "Layout Field Groups",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
group.identifier: group.layout_schema(
|
||||
context=context, remaining_fields=self.fieldgroups[i:]
|
||||
)
|
||||
for (i, group) in enumerate(self.fieldgroups)
|
||||
},
|
||||
"required": [
|
||||
group.identifier for group in self.fieldgroups if group.required
|
||||
],
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"I18nString": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "object", "additionalProperties": {"type": "string"}},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
if any(group.required for group in self.fieldgroups):
|
||||
schema["required"] = ["fieldgroups"]
|
||||
|
||||
return schema
|
||||
|
||||
def generate(self, layout, context):
|
||||
raise NotImplementedError()
|
||||
|
||||
class PassLayout:
|
||||
style: PassStyle
|
||||
layout: dict
|
||||
|
||||
def __init__(self, style, layout):
|
||||
self.style = style
|
||||
self.layout = layout
|
||||
|
||||
def validate(self, context):
|
||||
schema = self.style.layout_schema(context)
|
||||
try:
|
||||
jsonschema.validate(self.layout, schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
raise ValidationError("Invalid layout: {}".format(str(e)))
|
||||
|
||||
def generate(self, context):
|
||||
# TODO: how to handle nonexisting placeholders here?
|
||||
self.validate(context)
|
||||
return self.style.generate(self.layout, context)
|
||||
20
src/pretix/plugins/wallet/styles/google.py
Normal file
20
src/pretix/plugins/wallet/styles/google.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from .base import PassStyle, PredefinedFieldGroup, TextFieldGroup, WalletPlatform
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class GooglePlatform(WalletPlatform):
|
||||
identifier = "google"
|
||||
name = _("Google")
|
||||
|
||||
|
||||
class GoogleWalletStyle(PassStyle):
|
||||
platform = GooglePlatform
|
||||
|
||||
|
||||
class GoogleWalletEventTicket(PassStyle):
|
||||
identifier = "event"
|
||||
name = "Event Ticket"
|
||||
platform = GooglePlatform
|
||||
fieldgroups = [
|
||||
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
|
||||
TextFieldGroup(identifier="qrcode", name=_("QR-Code"), labels=False),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load bootstrap3 %}
|
||||
{% load vite %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
|
||||
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "New layout" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form layout="control" %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{% trans "Ticket design" %}
|
||||
</label>
|
||||
<div class="col-md-9 form-control-static">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can modify the design after you saved this page.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load bootstrap3 %}
|
||||
{% load vite %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
|
||||
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "Edit layout" %} {{ object.name }} </h1>
|
||||
{{ styles|json_script:"styles" }}
|
||||
{{ variables|json_script:"variables" }}
|
||||
{{ locales|json_script:"locales" }}
|
||||
<div id="editor" data-layout-id="{{ object.pk }}"></div>
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %}
|
||||
{% csrf_token %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,83 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load wallet %}
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Wallet layouts" %}</h1>
|
||||
<div class="tabbed-form">
|
||||
{% for platform in platforms.values %}
|
||||
<fieldset>
|
||||
<legend>{{platform.name}}</legend>
|
||||
{% with platform_layouts=platform|platform_layouts:request.event %}
|
||||
{% if platform_layouts|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You haven't created any layouts yet.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
{% if "event.settings.general:write" in request.eventpermset %}
|
||||
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug platform=platform.identifier %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new layout" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Default" %}</th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in platform_layouts %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if "can_change_event_settings" in request.eventpermset %}
|
||||
<strong><a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
|
||||
{{ l.name }}
|
||||
</a></strong>
|
||||
{% else %}
|
||||
<strong>{{ l.name }}</strong>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if l.default %}
|
||||
<span class="text-success">
|
||||
<span class="fa fa-check"></span>
|
||||
{% trans "Default" %}
|
||||
</span>
|
||||
{% elif "can_change_event_settings" in request.eventpermset %}
|
||||
<form class="form-inline" method="post"
|
||||
action="{% url "plugins:wallet:default" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-sm">
|
||||
{% trans "Make default" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
{% if "can_change_event_settings" in request.eventpermset %}
|
||||
<a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug platform=platform.identifier %}?copy_from={{ l.id }}"
|
||||
class="btn btn-default btn-sm" title="{% trans "Clone" %}" data-toggle="tooltip"><i class="fa fa-copy"></i></a>
|
||||
<a href="{% url "plugins:wallet:delete" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% load i18n %}
|
||||
<p>
|
||||
<a class="btn btn-primary btn-lg" target="_blank"
|
||||
href="{% url "plugins:wallet:index" organizer=request.organizer.slug event=request.event.slug %}">
|
||||
<span class="fa fa-paint-brush"></span>
|
||||
{% trans "Edit layouts" %}
|
||||
</a>
|
||||
</p>
|
||||
10
src/pretix/plugins/wallet/templatetags/wallet.py
Normal file
10
src/pretix/plugins/wallet/templatetags/wallet.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django import template
|
||||
|
||||
from ..models import WalletLayout
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def platform_layouts(platform, event):
|
||||
return WalletLayout.objects.filter(event=event, platform=platform.identifier)
|
||||
131
src/pretix/plugins/wallet/ticketoutput.py
Normal file
131
src/pretix/plugins/wallet/ticketoutput.py
Normal file
@@ -0,0 +1,131 @@
|
||||
#
|
||||
# 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 logging
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from pretix.base.ticketoutput import BaseTicketOutput
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from .styles import AVAILABLE_STYLES_DICT
|
||||
|
||||
from .models import WalletLayout
|
||||
from .views import get_layout_variables
|
||||
|
||||
|
||||
logger = logging.getLogger("pretix.plugins.wallet")
|
||||
|
||||
|
||||
class WalletSettingsHolder(BaseTicketOutput):
|
||||
identifier = "wallet"
|
||||
verbose_name = _("Wallet Output")
|
||||
|
||||
is_meta = True
|
||||
is_enabled = False
|
||||
preview_allowed = (
|
||||
False # TODO: implement own preview view or hide button for meta-outputs
|
||||
)
|
||||
|
||||
def settings_content_render(self, request) -> str:
|
||||
return render_to_string(
|
||||
"pretixplugins/wallet/settings_content.html", {"request": request}
|
||||
)
|
||||
|
||||
|
||||
class WalletOutput(BaseTicketOutput):
|
||||
settings_form_fields = []
|
||||
|
||||
def __init__(self, event: Event):
|
||||
super().__init__(event)
|
||||
self.settings = SettingsSandbox(
|
||||
"ticketoutput", WalletSettingsHolder.identifier, event
|
||||
)
|
||||
|
||||
|
||||
class GoogleWalletTicketOutput(WalletOutput):
|
||||
identifier = "wallet_google"
|
||||
verbose_name = _("Google")
|
||||
download_button_text = "Add to Google Wallet"
|
||||
|
||||
|
||||
class AppleWalletTicketOutput(WalletOutput):
|
||||
identifier = "wallet_apple"
|
||||
verbose_name = _("Apple")
|
||||
download_button_text = "Add to Apple Wallet"
|
||||
|
||||
def generate(self, op):
|
||||
order = op.order
|
||||
event = order.event
|
||||
filename = "{}-{}.pkpass".format(order.event.slug, order.code)
|
||||
|
||||
# layout = self.override_layout_signal.send_chained(
|
||||
# order.event, 'layout', orderposition=op, layout=self.layout_map.get(
|
||||
# (op.item_id, self.override_channel or order.sales_channel.identifier),
|
||||
# self.layout_map.get(
|
||||
# (op.item_id, 'web'),
|
||||
# self.default_layout
|
||||
# )
|
||||
# )
|
||||
# )
|
||||
layout = WalletLayout.objects.get(pk=1)
|
||||
|
||||
ticket = str(op.item.name)
|
||||
if op.variation:
|
||||
ticket += " - " + str(op.variation)
|
||||
|
||||
serialNumber = "%s-%s-%s-%d" % (
|
||||
order.event.organizer.slug,
|
||||
order.event.slug,
|
||||
order.code,
|
||||
op.pk,
|
||||
)
|
||||
|
||||
context = {
|
||||
"placeholders": get_layout_variables(op.order.event),
|
||||
"evaluation_context": [op, order, order.event],
|
||||
"ca_certificate": open(
|
||||
"/Users/engelhardt/code/tmp/wallet/apple/ca_cert.pem", "rb"
|
||||
).read(),
|
||||
"certificate": open(
|
||||
"/Users/engelhardt/code/tmp/wallet/apple/cert.pem", "rb"
|
||||
).read(),
|
||||
"key": open(
|
||||
"/Users/engelhardt/code/tmp/wallet/apple/secret_key.pem", "rb"
|
||||
).read(),
|
||||
"password": None,
|
||||
"description": _("Ticket for {event} ({product})").format( # TODO: i18n
|
||||
event=event.name, product=ticket
|
||||
),
|
||||
"organizationName": event.organizer.name,
|
||||
"passTypeIdentifier": "pass.test.test",
|
||||
"teamIdentifier": "TEST123456",
|
||||
"serialNumber": serialNumber,
|
||||
"locales": event.settings.locales
|
||||
}
|
||||
assert layout.platform == "apple"
|
||||
data = AVAILABLE_STYLES_DICT[layout.platform][layout.style].generate(
|
||||
layout.layout, context
|
||||
)
|
||||
return filename, "application/vnd.apple.pkpass", data
|
||||
|
||||
|
||||
OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput]
|
||||
45
src/pretix/plugins/wallet/urls.py
Normal file
45
src/pretix/plugins/wallet/urls.py
Normal file
@@ -0,0 +1,45 @@
|
||||
#
|
||||
# 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.urls import re_path
|
||||
from pretix.api.urls import event_router
|
||||
|
||||
from .views import (
|
||||
LayoutEditorView,
|
||||
LayoutCreateView,
|
||||
LayoutListView
|
||||
)
|
||||
from .api import WalletLayoutViewSet
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/$',
|
||||
LayoutListView.as_view(), name='index'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/add/(?P<platform>[^/]+)/$',
|
||||
LayoutCreateView.as_view(), name='add'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<layout>[^/]+)/$',
|
||||
LayoutEditorView.as_view(), name='edit'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/default/(?P<layout>[^/]+)/$', # TODO
|
||||
LayoutEditorView.as_view(), name='default'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/delete/(?P<layout>[^/]+)/$', # TODO
|
||||
LayoutEditorView.as_view(), name='delete'),
|
||||
]
|
||||
|
||||
event_router.register('walletlayouts', WalletLayoutViewSet)
|
||||
119
src/pretix/plugins/wallet/views.py
Normal file
119
src/pretix/plugins/wallet/views.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from django import forms
|
||||
from django.http import Http404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, DetailView, ListView
|
||||
from pretix.base.pdf import get_images, get_variables
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from django.conf import settings
|
||||
from .models import WalletLayout
|
||||
from .styles import AVAILABLE_STYLES, AVAILABLE_PLATFORMS
|
||||
|
||||
|
||||
def get_layout_variables(event):
|
||||
return {
|
||||
"text": get_variables(event),
|
||||
"image": get_images(event)
|
||||
| {"poweredby": {"label": _("pretix-Logo")}}, # TODO: image upload
|
||||
}
|
||||
|
||||
|
||||
def get_editor_variables(event):
|
||||
return {
|
||||
t: {
|
||||
vid: {"label": v.get("label"), "editor_sample": v.get("editor_sample")}
|
||||
for vid, v in vs.items()
|
||||
}
|
||||
for t, vs in get_layout_variables(event).items()
|
||||
}
|
||||
|
||||
|
||||
# TODO: should this even be a list view?
|
||||
class LayoutListView(EventPermissionRequiredMixin, ListView):
|
||||
model = WalletLayout
|
||||
permission = "can_change_event_settings"
|
||||
template_name = "pretixplugins/wallet/layout_list.html"
|
||||
context_object_name = "layouts"
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.wallet_layouts
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["platforms"] = AVAILABLE_PLATFORMS
|
||||
return ctx
|
||||
|
||||
|
||||
class LayoutEditorView(DetailView):
|
||||
template_name = "pretixplugins/wallet/edit.html"
|
||||
model = WalletLayout
|
||||
permission = "event.settings.general:write"
|
||||
pk_url_kwarg = "layout"
|
||||
|
||||
def get_platform_styles(self):
|
||||
if self.object.platform not in AVAILABLE_STYLES:
|
||||
raise Http404(
|
||||
_("Unknown platform '{platform}'").format(platform=self.object.platform)
|
||||
)
|
||||
return AVAILABLE_STYLES[self.object.platform]
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["styles"] = {
|
||||
style.identifier: style.asdict() for style in self.get_platform_styles()
|
||||
}
|
||||
context["variables"] = get_editor_variables(self.request.event)
|
||||
context["locales"] = {
|
||||
l: dict(settings.LANGUAGES).get(l, l)
|
||||
for l in self.request.event.settings.get("locales")
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class WalletLayoutCreateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = WalletLayout
|
||||
fields = ("name",)
|
||||
|
||||
def __init__(self, *args, platform, event, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.platform = platform
|
||||
self.event = event
|
||||
|
||||
def save(self, *args, **kwargs) -> Any:
|
||||
self.instance.platform = self.platform
|
||||
self.instance.event = self.event
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class LayoutCreateView(CreateView):
|
||||
template_name = "pretixplugins/wallet/create.html"
|
||||
form_class = WalletLayoutCreateForm
|
||||
permission = "event.settings.general:write"
|
||||
|
||||
@property
|
||||
def platform(self):
|
||||
platform = self.kwargs["platform"]
|
||||
if platform not in AVAILABLE_PLATFORMS:
|
||||
raise Http404(_("Unknown platform '{platform}'").format(platform=platform))
|
||||
return platform
|
||||
|
||||
def get_form_kwargs(self) -> dict[str, Any]:
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["platform"] = self.platform
|
||||
kwargs["event"] = self.request.event
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(
|
||||
"plugins:wallet:edit",
|
||||
kwargs={
|
||||
"organizer": self.request.event.organizer.slug,
|
||||
"event": self.request.event.slug,
|
||||
"layout": self.object.pk,
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
<a class="list-group-item" href="#" @click.prevent="$emit('selected', list)">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ list.name }}
|
||||
</div>
|
||||
<div class="col-md-6 text-muted">
|
||||
{{ subevent }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
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 setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { CheckinList } from '../api'
|
||||
import { formatSubevent } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
list: CheckinList
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
selected: [list: CheckinList]
|
||||
}>()
|
||||
|
||||
const subevent = computed(() => formatSubevent(props.list.subevent))
|
||||
</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>
|
||||
<div class="panel panel-primary checkinlist-select">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{{ $root.strings['checkinlist.select'] }}
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
<checkinlist-item v-if="lists" v-for="l in lists" :list="l" :key="l.id" @selected="$emit('selected', l)"></checkinlist-item>
|
||||
<li v-if="loading" class="list-group-item text-center">
|
||||
<span class="fa fa-4x fa-cog fa-spin loading-icon"></span>
|
||||
</li>
|
||||
<li v-else-if="error" class="list-group-item text-center">
|
||||
{{ error }}
|
||||
</li>
|
||||
<a v-else-if="next_url" class="list-group-item text-center" href="#" @click.prevent="loadNext">
|
||||
{{ $root.strings['pagination.next'] }}
|
||||
</a>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
components: {
|
||||
CheckinlistItem: CheckinlistItem.default,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
error: null,
|
||||
lists: null,
|
||||
next_url: null,
|
||||
}
|
||||
},
|
||||
// TODO: pagination
|
||||
mounted() {
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading = true
|
||||
const cutoff = moment().subtract(8, 'hours').toISOString()
|
||||
if (location.hash) {
|
||||
fetch(this.$root.api.lists + location.hash.substr(1) + '/' + '?expand=subevent')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
this.loading = false
|
||||
if (data.id) {
|
||||
this.$emit('selected', data)
|
||||
} else {
|
||||
location.hash = ''
|
||||
this.load()
|
||||
}
|
||||
})
|
||||
.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
|
||||
})
|
||||
},
|
||||
},
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import type { CheckinList } from '../api'
|
||||
import { STRINGS } from '../i18n'
|
||||
import CheckinlistItem from './checkinlist-item.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
selected: [list: CheckinList]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<unknown>(null)
|
||||
const lists = ref<CheckinList[] | null>(null)
|
||||
const nextUrl = ref<string | null>(null)
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
if (location.hash) {
|
||||
const listId = location.hash.substring(1)
|
||||
try {
|
||||
const data = await api.fetchCheckinList(listId)
|
||||
loading.value = false
|
||||
if (data.id) {
|
||||
emit('selected', data)
|
||||
} else {
|
||||
location.hash = ''
|
||||
load()
|
||||
}
|
||||
} catch {
|
||||
location.hash = ''
|
||||
load()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const data = await api.fetchCheckinLists()
|
||||
loading.value = false
|
||||
|
||||
if (data.results) {
|
||||
lists.value = 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
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
<input class="form-control" :required="required">
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ["required", "value"],
|
||||
mounted: function () {
|
||||
var vm = this;
|
||||
var multiple = this.multiple;
|
||||
$(this.$el)
|
||||
.datetimepicker(this.opts())
|
||||
.trigger("change")
|
||||
.on("dp.change", function (e) {
|
||||
vm.$emit("input", $(this).data('DateTimePicker').date().format("YYYY-MM-DD"));
|
||||
});
|
||||
if (!vm.value) {
|
||||
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
|
||||
} else {
|
||||
$(this.$el).data("DateTimePicker").date(moment(vm.value));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
opts: function () {
|
||||
return {
|
||||
format: $("body").attr("data-dateformat"),
|
||||
locale: $("body").attr("data-datetimelocale"),
|
||||
useCurrent: false,
|
||||
showClear: this.required,
|
||||
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'
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: function (val) {
|
||||
$(this.$el).data('DateTimePicker').date(moment(val));
|
||||
},
|
||||
},
|
||||
destroyed: function () {
|
||||
$(this.$el)
|
||||
.off()
|
||||
.datetimepicker("destroy");
|
||||
}
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { dateFormat, datetimeLocale } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
required?: boolean
|
||||
modelValue?: string
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
|
||||
const opts = {
|
||||
format: dateFormat,
|
||||
locale: datetimeLocale,
|
||||
useCurrent: false,
|
||||
showClear: props.required,
|
||||
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',
|
||||
},
|
||||
}
|
||||
</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>
|
||||
<input class="form-control" :required="required">
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ["required", "value"],
|
||||
mounted: function () {
|
||||
var vm = this;
|
||||
var multiple = this.multiple;
|
||||
$(this.$el)
|
||||
.datetimepicker(this.opts())
|
||||
.trigger("change")
|
||||
.on("dp.change", function (e) {
|
||||
vm.$emit("input", $(this).data('DateTimePicker').date().toISOString());
|
||||
});
|
||||
if (!vm.value) {
|
||||
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
|
||||
} else {
|
||||
$(this.$el).data("DateTimePicker").date(moment(vm.value));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
opts: function () {
|
||||
return {
|
||||
format: $("body").attr("data-datetimeformat"),
|
||||
locale: $("body").attr("data-datetimelocale"),
|
||||
timeZone: $("body").attr("data-timezone"),
|
||||
useCurrent: false,
|
||||
showClear: this.required,
|
||||
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'
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: function (val) {
|
||||
$(this.$el).data('DateTimePicker').date(moment(val));
|
||||
},
|
||||
},
|
||||
destroyed: function () {
|
||||
$(this.$el)
|
||||
.off()
|
||||
.datetimepicker("destroy");
|
||||
}
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { datetimeFormat, datetimeLocale, timezone } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
required?: boolean
|
||||
modelValue?: string
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
|
||||
const opts = {
|
||||
format: datetimeFormat,
|
||||
locale: datetimeLocale,
|
||||
timeZone: timezone,
|
||||
useCurrent: false,
|
||||
showClear: props.required,
|
||||
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',
|
||||
},
|
||||
}
|
||||
</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>
|
||||
<a class="list-group-item searchresult" href="#" @click.prevent="$emit('selected', position)" ref="a">
|
||||
<div class="details">
|
||||
<h4>{{ position.order }}-{{ position.positionid }} {{ position.attendee_name }}</h4>
|
||||
<span>{{ itemvar }}<br></span>
|
||||
<span v-if="subevent">{{ subevent }}<br></span>
|
||||
<div class="secret">{{ position.secret }}</div>
|
||||
</div>
|
||||
<div :class="`status status-${status}`">
|
||||
<span v-if="position.require_attention"><span class="fa fa-warning"></span><br></span>
|
||||
{{ $root.strings[`status.${status}`] }}
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
position: Object
|
||||
},
|
||||
computed: {
|
||||
status() {
|
||||
if (this.position.checkins.length) return 'redeemed';
|
||||
if (this.position.order__status === 'n' && this.position.order__valid_if_pending) return 'pending_valid';
|
||||
if (this.position.order__status === 'n' && this.position.order__require_approval) return 'require_approval';
|
||||
return this.position.order__status
|
||||
},
|
||||
itemvar() {
|
||||
if (this.position.variation) {
|
||||
return `${i18nstring_localize(this.position.item.name)} – ${i18nstring_localize(this.position.variation.value)}`
|
||||
}
|
||||
return i18nstring_localize(this.position.item.name)
|
||||
},
|
||||
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 setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Position } from '../api'
|
||||
import { STRINGS, i18nstringLocalize, formatSubevent } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
position: Position
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
selected: [position: Position]
|
||||
}>()
|
||||
|
||||
const rootEl = ref<HTMLAnchorElement>()
|
||||
|
||||
const status = computed(() => {
|
||||
if (props.position.checkins.length) return 'redeemed'
|
||||
if (props.position.order__status === 'n' && props.position.order__valid_if_pending) return 'pending_valid'
|
||||
if (props.position.order__status === 'n' && props.position.order__require_approval) return 'require_approval'
|
||||
return props.position.order__status
|
||||
})
|
||||
|
||||
const itemvar = computed(() => {
|
||||
if (props.position.variation) {
|
||||
return `${i18nstringLocalize(props.position.item.name)} – ${i18nstringLocalize(props.position.variation.value)}`
|
||||
}
|
||||
return i18nstringLocalize(props.position.item.name)
|
||||
})
|
||||
|
||||
const subevent = computed(() => formatSubevent(props.position.subevent))
|
||||
|
||||
defineExpose({ el: rootEl })
|
||||
</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>
|
||||
<input class="form-control" :required="required">
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ["required", "value"],
|
||||
mounted: function () {
|
||||
var vm = this;
|
||||
var multiple = this.multiple;
|
||||
$(this.$el)
|
||||
.datetimepicker(this.opts())
|
||||
.trigger("change")
|
||||
.on("dp.change", function (e) {
|
||||
vm.$emit("input", $(this).data('DateTimePicker').date().format("HH:mm:ss"));
|
||||
});
|
||||
if (!vm.value) {
|
||||
$(this.$el).data("DateTimePicker").viewDate(moment().hour(0).minute(0).second(0).millisecond(0));
|
||||
} else {
|
||||
$(this.$el).data("DateTimePicker").date(moment(vm.value));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
opts: function () {
|
||||
return {
|
||||
format: $("body").attr("data-timeformat"),
|
||||
locale: $("body").attr("data-datetimelocale"),
|
||||
useCurrent: false,
|
||||
showClear: this.required,
|
||||
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'
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: function (val) {
|
||||
$(this.$el).data('DateTimePicker').date(moment(val));
|
||||
},
|
||||
},
|
||||
destroyed: function () {
|
||||
$(this.$el)
|
||||
.off()
|
||||
.datetimepicker("destroy");
|
||||
}
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { timeFormat, datetimeLocale } from '../i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
required?: boolean
|
||||
modelValue?: string
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const input = ref<HTMLInputElement>()
|
||||
|
||||
const opts = {
|
||||
format: timeFormat,
|
||||
locale: datetimeLocale,
|
||||
useCurrent: false,
|
||||
showClear: props.required,
|
||||
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',
|
||||
},
|
||||
}
|
||||
</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 eventurl %}
|
||||
{% load escapejson %}
|
||||
{% load vite %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -23,11 +24,7 @@
|
||||
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"
|
||||
data-dateformat="{{ js_date_format }}" data-datetimelocale="{{ js_locale }}"
|
||||
data-pretixlocale="{{ request.LANGUAGE_CODE }}" data-timezone="{{ request.event.settings.timezone }}">
|
||||
<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>
|
||||
<div id="app" data-event-name="{{ request.event.name }}"></div>
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/i18nstring.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 "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
|
||||
{% 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="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 %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -126,7 +126,7 @@ footer_link = EventPluginSignal()
|
||||
Arguments: ``request``
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<li>{{ footer_text }}</li>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
{% include "pretixpresale/base_footer.html" %} {# removing or hiding this might be in violation of pretix' license #}
|
||||
</ul>
|
||||
|
||||
@@ -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()))}'
|
||||
|
||||
|
||||
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):
|
||||
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
|
||||
@@ -153,13 +166,16 @@ def widget_css(request, version, **kwargs):
|
||||
return resp
|
||||
|
||||
|
||||
def generate_widget_js(version, lang):
|
||||
def generate_widget_js(version, lang, use_vite=False):
|
||||
code = []
|
||||
with language(lang):
|
||||
# Provide isolation
|
||||
code.append('(function (siteglobals) {\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.translation = DjangoTranslation(lang, domain='djangojs')
|
||||
@@ -181,20 +197,25 @@ def generate_widget_js(version, lang):
|
||||
'plural': plural,
|
||||
})
|
||||
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)
|
||||
|
||||
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:
|
||||
if use_vite:
|
||||
vite_js = finders.find('vite/widget/widget.js')
|
||||
if not vite_js:
|
||||
raise FileNotFoundError('Vite widget build not found. Run: npm run build:widget')
|
||||
with open(vite_js, 'r', encoding='utf-8') as fp:
|
||||
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:
|
||||
code.append('})(this);\n')
|
||||
@@ -215,15 +236,22 @@ def widget_js(request, version, lang, **kwargs):
|
||||
if 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:
|
||||
resp = HttpResponse(cached_js, content_type='text/javascript')
|
||||
resp._csp_ignore = True
|
||||
resp['Access-Control-Allow-Origin'] = '*'
|
||||
return resp
|
||||
|
||||
settings_key = 'widget_file_v{}_{}_{}'.format(version, lang, variant)
|
||||
checksum_key = 'widget_checksum_v{}_{}_{}'.format(version, lang, variant)
|
||||
|
||||
gs = GlobalSettingsObject()
|
||||
fname = gs.settings.get('widget_file_v{}_{}'.format(version, lang))
|
||||
fname = gs.settings.get(settings_key)
|
||||
resp = None
|
||||
if fname and not settings.DEBUG:
|
||||
if isinstance(fname, File):
|
||||
@@ -231,21 +259,21 @@ def widget_js(request, version, lang, **kwargs):
|
||||
try:
|
||||
data = default_storage.open(fname).read()
|
||||
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:
|
||||
logger.exception('Failed to open widget.js')
|
||||
|
||||
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()
|
||||
if not settings.DEBUG:
|
||||
newname = default_storage.save(
|
||||
'widget/widget.{}.{}.{}.js'.format(version, lang, checksum),
|
||||
'widget/widget.{}.{}.{}.{}.js'.format(version, lang, variant, checksum),
|
||||
ContentFile(data)
|
||||
)
|
||||
gs.settings.set('widget_file_v{}_{}'.format(version, lang), 'file://' + newname)
|
||||
gs.settings.set('widget_checksum_v{}_{}'.format(version, lang), checksum)
|
||||
cache.set('widget_js_data_v{}_{}'.format(version, lang), data, 3600 * 4)
|
||||
gs.settings.set(settings_key, 'file://' + newname)
|
||||
gs.settings.set(checksum_key, checksum)
|
||||
cache.set(cache_prefix, data, 3600 * 4)
|
||||
resp = HttpResponse(data, content_type='text/javascript')
|
||||
resp._csp_ignore = True
|
||||
resp['Access-Control-Allow-Origin'] = '*'
|
||||
|
||||
@@ -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)
|
||||
|
||||
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')
|
||||
|
||||
6430
src/pretix/static/npm_dir/package-lock.json
generated
6430
src/pretix/static/npm_dir/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user