Compare commits

..

19 Commits

Author SHA1 Message Date
Raphael Michel 916951aa68 Bump to 2026.3.5 2026-07-01 14:13:39 +02:00
Raphael Michel 3adb2eaacb Update GitLab CI instructions 2026-07-01 14:13:39 +02:00
Raphael Michel 436a8b0c24 [SECURITY] Hardening for user impersonation feature (CVE-2026-13602)
---------

Co-authored-by: Mira Weller <weller@pretix.eu>
2026-07-01 14:02:48 +02:00
Raphael Michel deee2c9f66 [SECURITY] Centralize framebreaking logic from payment plugins to core (CVE-2026-13602)
- Add central framebreaker page via safelink helper
- Update paypal, paypal2 and stripe plugins to use central framebreaker
- Add CSP header to cookies.html

---------

Co-authored-by: Mira Weller <weller@pretix.eu>
2026-07-01 14:02:48 +02:00
Mira Weller cf87fa1039 [SECURITY] Allowlisting and changed salts for safelink and safelink_callback (CVE-2026-13602)
---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-07-01 14:02:48 +02:00
Raphael Michel 7ed69a8ef7 Bump version to 2026.3.4 2026-06-25 16:36:43 +02:00
Mira Weller ba37f2eb47 [SECURITY] Fix XSS in ticket layout JSON (CVE-2026-57532) 2026-06-25 16:22:02 +02:00
Raphael Michel 8ec81f80e8 [SECURITY] Properly escape HTML tags in PDF generation (CVE-2026-57535) 2026-06-25 16:21:44 +02:00
Raphael Michel 257bb2e4b7 [SECURITY] Prevent reading of any local files in reportlab (CVE-2026-57535) 2026-06-25 16:19:38 +02:00
Raphael Michel 3953d57c2e [SECURITY] Disable outbound and file access for reportlab (CVE-2026-57535) 2026-06-25 16:17:49 +02:00
Mira Weller 2f1025c3a6 [SECURITY] Fix reflected XSS in redirection page (CVE-2026-57533) 2026-06-25 16:17:49 +02:00
Mira Weller 19f4dce267 [SECURITY] Fix stored XSS in ticket confirmation page (CVE-2026-13225) 2026-06-25 16:17:49 +02:00
Mira Weller e44f91530a [SECURITY] Hardening: Don't use |safe on confirm_messages 2026-06-25 16:17:49 +02:00
Raphael Michel d985fd61a1 Bump to 2026.3.3 2026-06-09 13:23:50 +02:00
Richard Schreiber 803964da0e [SECURITY] Reusable media export: Hide giftcard secret (CVE-2026-11764 backport) (#6262) 2026-06-09 13:20:27 +02:00
Raphael Michel 7e6df3d427 Bump to 2026.3.2 2026-05-27 16:29:35 +02:00
Raphael Michel 7b93cc57db [SECURITY] Add missing session check for cached files (CVE-2026-9712) 2026-05-27 16:29:26 +02:00
Raphael Michel 21d62c5078 Bump version to 2026.3.1 2026-04-08 13:58:36 +02:00
Raphael Michel 988dc112ac [SECURITY] API: Add missing event filter for check-ins 2026-04-08 13:58:23 +02:00
395 changed files with 150655 additions and 158847 deletions
-1
View File
@@ -1,6 +1,5 @@
doc/
env/
node_modules/
res/
local/
.git/
-5
View File
@@ -1,5 +0,0 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = tab
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
+1 -4
View File
@@ -46,7 +46,4 @@ jobs:
- name: Run build
run: python -m build
- name: Check files
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
run: unzip -l dist/pretix*whl | grep node_modules || exit 1
-43
View File
@@ -1,43 +0,0 @@
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
+1 -44
View File
@@ -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 --ignore=tests/e2e --maxfail=100
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --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,46 +84,3 @@ 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
View File
@@ -24,7 +24,5 @@ local/
.project
.pydevproject
.DS_Store
node_modules/
.vite/
+1 -1
View File
@@ -1 +1 @@
24
17
-1
View File
@@ -1 +0,0 @@
/*
+5 -10
View File
@@ -1,16 +1,11 @@
Contributing to pretix
======================
Welcome to pretix, we are happy that you would like to contribute.
Before you do so, please make sure to read the following documents:
Hey there and welcome to pretix!
- [Contribution workflow](https://docs.pretix.eu/dev/development/contribution/general.html)
- [AI-assisted contribution policy](https://docs.pretix.eu/dev/development/contribution/ai.html)
- [Coding style and quality](https://docs.pretix.eu/dev/development/contribution/style.html)
- [Development setup](https://docs.pretix.eu/dev/development/setup.html)
- [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html)
* We've got a contributors guide in [our documentation](https://docs.pretix.eu/dev/development/contribution/) together with notes on the [development setup](https://docs.pretix.eu/dev/development/setup.html).
Before we can accept your first PR we'll need you to sign [our **Contributor License Agreement** (CLA)](https://pretix.eu/about/en/cla).
You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
* Please note that we have a [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html) in place that applies to all project contributions, including issues, pull requests, etc.
* Before we can accept a PR from you we'll need you to sign [our CLA](https://pretix.eu/about/en/cla). You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
**Before contributing new functionality, always open a discussion first.**
+4 -9
View File
@@ -1,7 +1,6 @@
FROM python:3.13-trixie
FROM python:3.11-bookworm
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get update && \
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
gettext \
@@ -22,7 +21,8 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
libmaxminddb0 \
libmaxminddb-dev \
zlib1g-dev \
nodejs && \
nodejs \
npm && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
dpkg-reconfigure locales && \
@@ -31,7 +31,6 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
mkdir /etc/pretix && \
mkdir /data && \
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
chmod 0755 /pretix && \
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
mkdir /static && \
mkdir /etc/supervisord
@@ -50,10 +49,6 @@ 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 \
-5
View File
@@ -48,8 +48,3 @@ 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
+1 -1
View File
@@ -192,7 +192,7 @@ Cart position endpoints
* ``attendee_email`` (optional)
* ``subevent`` (optional)
* ``expires`` (optional)
* ``includes_tax`` (optional, **DEPRECATED**, do not use, will be removed)
* ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
* ``sales_channel`` (optional)
* ``voucher`` (optional, expect a voucher code)
* ``addons`` (optional, expect a list of nested objects of cart positions)
-184
View File
@@ -844,187 +844,3 @@ 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
+6 -13
View File
@@ -16,7 +16,6 @@ Field Type Description
id integer Internal ID of the program time
start datetime The start date time for this program time slot.
end datetime The end date time for this program time slot.
location multi-lingual string The program time slot's location (or ``null``)
===================================== ========================== =======================================================
.. versionchanged:: TODO
@@ -55,20 +54,17 @@ Endpoints
{
"id": 2,
"start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z",
"location": null
"end": "2025-08-15T00:00:00Z"
},
{
"id": 3,
"start": "2025-08-12T22:00:00Z",
"end": "2025-08-13T22:00:00Z",
"location": null
"end": "2025-08-13T22:00:00Z"
},
{
"id": 14,
"start": "2025-08-15T22:00:00Z",
"end": "2025-08-17T22:00:00Z",
"location": null
"end": "2025-08-17T22:00:00Z"
}
]
}
@@ -103,8 +99,7 @@ Endpoints
{
"id": 1,
"start": "2025-08-15T22:00:00Z",
"end": "2025-10-27T23:00:00Z",
"location": null
"end": "2025-10-27T23:00:00Z"
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -130,8 +125,7 @@ Endpoints
{
"start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z",
"location": null
"end": "2025-08-15T22:00:00Z"
}
**Example response**:
@@ -145,8 +139,7 @@ Endpoints
{
"id": 17,
"start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z",
"location": null
"end": "2025-08-15T22:00:00Z"
}
:param organizer: The ``slug`` field of the organizer of the event/item to create a program time for
-1
View File
@@ -1070,7 +1070,6 @@ Creating orders
* ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected)
* ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID)
* ``add_to_reusable_medium`` (optional, causes the new ticket to be added to the given reusable medium, identified by its ID)
* ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run)
* ``answers``
+9 -30
View File
@@ -21,16 +21,12 @@ id integer Internal ID of
type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``.
organizer string Organizer slug of the organizer who "owns" this medium.
identifier string Unique identifier of the medium. The format depends on the ``type``.
claim_token string Secret token to claim ownership of the medium (or ``null``)
label string Label to identify the medium, usually something human readable (or ``null``)
active boolean Whether this medium may be used.
created datetime Date of creation
updated datetime Date of last modification
expires datetime Expiry date (or ``null``)
customer string Identifier of a customer account this medium belongs to.
linked_orderpositions list of integers Internal IDs of tickets this medium is linked to.
linked_orderposition integer **DEPRECATED.** ID of the ticket the medium is linked to, if it is linked to
only one ticket. ``null``, if the medium is linked to none or multiple tickets.
linked_orderposition integer Internal ID of a ticket this medium is linked to.
linked_giftcard integer Internal ID of a gift card this medium is linked to.
info object Additional data, content depends on the ``type``. Consider
this internal to the system and don't use it for your own data.
@@ -43,14 +39,6 @@ Existing media types are:
- ``nfc_uid``
- ``nfc_mf0aes``
.. versionchanged:: 2026.5
The ``claim_token``, ``label``, ``linked_orderpositions`` attributes have been added, the ``linked_orderposition`` attribute has been
deprecated. Note: To maintain backwards compatibility ``linked_orderposition`` contains the internal ID of the linked order position
if the medium has exactly one order position in ``linked_orderpositions``.
Endpoints
---------
@@ -89,7 +77,6 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
"linked_orderpositions": [],
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
@@ -105,13 +92,10 @@ Endpoints
:query string customer: Only show media linked to the given customer.
:query string created_since: Only show media created since a given date.
:query string updated_since: Only show media updated since a given date.
:query integer linked_orderpositions: Only show media linked to the given tickets. Note: you can pass multiple ticket IDs by passing
``linked_orderpositions`` multiple times. Any medium matching any linked orderposition will be returned.
:query integer linked_orderposition: Only show media linked to the given ticket.
:query integer linked_giftcard: Only show media linked to the given gift card.
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderpositions"``,
``"linked_orderposition"`` (**DEPRECATED**), or ``"customer"``, the respective field will be shown
as a nested value instead of just an ID.
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``,
or ``"customer"``, the respective field will be shown as a nested value instead of just an ID.
The nested objects are identical to the respective resources, except that order positions
will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
matching easier. The parameter can be given multiple times.
@@ -150,7 +134,6 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
"linked_orderpositions": [],
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
@@ -208,7 +191,6 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
"linked_orderpositions": [],
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
@@ -216,9 +198,9 @@ Endpoints
}
:param organizer: The ``slug`` field of the organizer to look up a medium for
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderpositions`` each will have an attribute of the
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
:statuscode 201: no error
@@ -245,7 +227,6 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
"linked_orderpositions": [],
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
@@ -270,7 +251,6 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
"linked_orderpositions": [],
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
@@ -278,7 +258,7 @@ Endpoints
}
:param organizer: The ``slug`` field of the organizer to create a medium for
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
@@ -307,7 +287,7 @@ Endpoints
Content-Length: 94
{
"linked_orderpositions": [13, 29]
"linked_orderposition": 13
}
**Example response**:
@@ -328,8 +308,7 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
"linked_orderpositions": [13, 29],
"linked_orderposition": None,
"linked_orderposition": 13,
"linked_giftcard": None,
"notes": None,
"info": {}
@@ -337,7 +316,7 @@ Endpoints
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the medium to modify
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
-1
View File
@@ -70,7 +70,6 @@ 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``
+2 -2
View File
@@ -64,8 +64,8 @@ Backend
.. automodule:: pretix.control.signals
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
order_info, order_approve_info, event_settings_widget, oauth_application_registered,
order_position_buttons, subevent_forms, item_formsets, order_search_filter_q, order_search_forms, subevent_detail_html
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms,
item_formsets, order_search_filter_q, order_search_forms
.. automodule:: pretix.base.signals
:no-index:
-24
View File
@@ -1,24 +0,0 @@
.. _`aipolicy`:
AI-assisted contribution policy
===============================
pretix is maintained by humans.
Every discussion, issue, and pull request is read and reviewed by humans (and sometimes machines, too).
We ask you to respect the time and effort put in by these humans by not sending low-effort, unqualified work, since it puts the burden of validation on the maintainer.
Therefore, the pretix project has strict rules for AI usage:
- **All AI usage in any form must be disclosed.** You must state the tool you used (e.g. Claude Code, Cursor, Amp) along with the extent that the work was AI-assisted.
- **The human-in-the-loop must fully understand all code.** If you can't explain what your changes do and how they interact with the greater system without the aid of AI tools, do not contribute to this project.
- **Issues and discussions can use AI assistance but must have a full human-in-the-loop.** This means that any content generated with AI must have been reviewed and edited by a human before submission. AI is very good at being overly verbose and including noise that distracts from the main point. Humans must do their research and trim this down.
- **No AI-generated media is allowed (art, images, videos, audio, etc.).** Text and code are the only acceptable AI-generated content, per the other rules in this policy.
- **Bad AI drivers will be excluded from the project.** People who produce bad contributions that are clearly AI (slop) will be blocked from our organization without warning.
This policy was inspired by the `ghostty project`_.
.. _ghostty project: https://github.com/ghostty-org/ghostty/blob/main/AI_POLICY.md
+11 -30
View File
@@ -1,39 +1,23 @@
Contribution workflow
=====================
General remarks
===============
You are interested in contributing to pretix? That is awesome!
If youre new to contributing to open source software, dont be afraid. Well happily review your code and give you
constructive and friendly feedback on your changes. Every contribution should go through the following steps.
constructive and friendly feedback on your changes.
Discussion & Design
-------------------
pretix is a large and mature project with more of a decade of history and hopefully many more decades to come.
Keeping pretix in good shape over long timeframes is first and foremost a fight against complexity.
With every additional feature, complexity grows, and both features and complexity are hard to remove.
Even if you are doing the initial work of the contribution, accepting the contribution is not free for us.
Not only will we need to maintain the feature, but every feature adds cost to the maintenance of every other feature it interacts with, and every feature adds effort for users to understand how pretix works.
Therefore, we must carefully select what features we add, based on how well they fit the system in general and of how much use they will be to our larger user base.
We strongly ask you to **create a discussion on GitHub for every new feature idea** outlining the use case and the proposed implementation design.
Pull requests without prior discussion will likely just be closed.
For bug fixes and very minor changes, you can skip this step and open a PR right away.
Development
-----------
To develop your contribution, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
First of all, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
If you run into any problems on your way, please do not hesitate to ask us anytime!
While developing, please have a look at our :ref:`aipolicy` and our guidelines on :ref:`codestyle`.
Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
assured that we will not tolerate any form of harassment.
Sending a patch
---------------
Once you have a first draft of your changes, please `create a pull request`_ on our `GitHub repository`_.
If you improved pretix in any way, we'd be very happy if you contribute it
back to the main code base! The easiest way to do so is to `create a pull request`_
on our `GitHub repository`_.
We recommend that you create a feature branch for every issue you work on so the changes can
be reviewed individually.
@@ -41,17 +25,14 @@ Please use the test suite to check whether your changes break any existing featu
the code style checks to confirm you are consistent with pretix's coding style. You'll
find instructions on this in the :ref:`checksandtests` section of the development setup guide.
We automatically run the tests and the code style check on every pull request through GitHub Actions and we wont
We automatically run the tests and the code style check on every pull request on Travis CI and we wont
accept any pull requests without all tests passing. However, if you don't find out *why* they are not passing,
just send the pull request and tell us we'll be glad to help.
If you add a new feature, please include appropriate documentation into your patch. If you fix a bug,
please include a regression test, i.e. a test that fails without your changes and passes after applying your changes.
Again: If you get stuck, do not hesitate to contact us through GitHub discussions.
Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
assured that we will not tolerate any form of harassment.
Again: If you get stuck, do not hesitate to contact any of us, or Raphael personally at mail@raphaelmichel.de.
.. _create a pull request: https://help.github.com/articles/creating-a-pull-request/
.. _GitHub repository: https://github.com/pretix/pretix
-1
View File
@@ -6,5 +6,4 @@ Contributing to pretix
general
style
ai
codeofconduct
+4 -4
View File
@@ -1,7 +1,5 @@
.. spelling:word-list:: Rebase rebasing
.. _`codestyle`:
Coding style and quality
========================
@@ -30,6 +28,8 @@ Code
Commits and Pull Requests
-------------------------
Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since
the pull request title will become the commit message on merge. We prefer merging with GitHub's "Squash and merge"
feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the
@@ -86,7 +86,7 @@ individual commits, we use "Rebase and merge" instead. Merge commits should be a
.. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/
.. _flake8: https://pypi.python.org/pypi/flake8
.. _Django Coding Style: https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/
.. _translation: https://docs.djangoproject.com/en/6.0/topics/i18n/translation/
.. _class-based views: https://docs.djangoproject.com/en/6.0/topics/class-based-views/
.. _translation: https://docs.djangoproject.com/en/1.11/topics/i18n/translation/
.. _class-based views: https://docs.djangoproject.com/en/1.11/topics/class-based-views/
.. _pytest-style: https://docs.pytest.org/en/latest/assert.html
.. _fixtures: https://docs.pytest.org/en/latest/fixture.html
-50
View File
@@ -110,56 +110,6 @@ 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
View File
@@ -1,108 +0,0 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import ts from 'typescript-eslint'
import stylistic from '@stylistic/eslint-plugin'
import vue from 'eslint-plugin-vue'
import vuePug from 'eslint-plugin-vue-pug'
const ignores = globalIgnores([
'**/node_modules',
'**/dist'
])
export default defineConfig([
ignores,
...ts.config(
js.configs.recommended,
ts.configs.recommended
),
stylistic.configs.customize({
indent: 'tab',
braceStyle: '1tbs',
quoteProps: 'as-needed'
}),
...vue.configs['flat/recommended'],
...vuePug.configs['flat/recommended'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
localStorage: false,
$: 'readonly',
$$: 'readonly',
$ref: 'readonly',
$computed: 'readonly',
},
parserOptions: {
parser: '@typescript-eslint/parser'
}
},
rules: {
'no-debugger': 'off',
curly: 0,
'no-return-assign': 0,
'no-console': 'off',
'vue/require-default-prop': 0,
'vue/require-v-for-key': 0,
'vue/valid-v-for': 'warn',
'vue/no-reserved-keys': 0,
'vue/no-setup-props-destructure': 0,
'vue/multi-word-component-names': 0,
'vue/max-attributes-per-line': 0,
'vue/attribute-hyphenation': ['warn', 'never'],
'vue/v-on-event-hyphenation': ['warn', 'never'],
'import/first': 0,
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/no-explicit-any': 0,
'no-use-before-define': 'off',
'no-var': 'error',
'@typescript-eslint/no-use-before-define': ['error', {
typedefs: false,
functions: false,
}],
'@typescript-eslint/no-unused-vars': ['error', {
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true
}],
'@stylistic/comma-dangle': 0,
'@stylistic/space-before-function-paren': ['error', 'always'],
'@stylistic/max-statements-per-line': ['error', { max: 1, ignoredNodes: ['BreakStatement'] }],
'@stylistic/member-delimiter-style': 0,
'@stylistic/arrow-parens': 0,
'@stylistic/generator-star-spacing': 0,
'@stylistic/yield-star-spacing': ['error', 'after'],
},
},
{
files: [
'src/pretix/static/pretixcontrol/js/ui/checkinrules/**/*.vue',
'src/pretix/plugins/webcheckin/**/*.vue',
],
languageOptions: {
globals: {
moment: 'readonly',
},
},
},
{
files: [
'src/pretix/static/pretixpresale/widget/**/*.{ts,vue}',
],
languageOptions: {
globals: {
LANG: 'readonly',
},
},
},
])
-4781
View File
File diff suppressed because it is too large Load Diff
-52
View File
@@ -1,52 +0,0 @@
{
"name": "pretix",
"version": "1.0.0",
"description": "",
"homepage": "https://github.com/pretix/pretix#readme",
"bugs": {
"url": "https://github.com/pretix/pretix/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/pretix/pretix.git"
},
"license": "SEE LICENSE IN LICENSE",
"author": "",
"type": "module",
"main": "index.js",
"directories": {
"doc": "doc"
},
"scripts": {
"dev:control": "vite",
"dev:widget": "vite src/pretix/static/pretixpresale/widget",
"build": "npm run build:control -s && npm run build:widget -s",
"build:control": "vite build",
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"vue": "^3.5.30"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/jquery": "^3.5.33",
"@types/moment": "^2.11.29",
"@types/node": "^25.5.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/language-plugin-pug": "^3.2.5",
"eslint": "^10.0.3",
"eslint-plugin-vue": "^10.8.0",
"eslint-plugin-vue-pug": "^1.0.0-alpha.5",
"globals": "^17.4.0",
"pug": "^3.0.3",
"sass-embedded": "^1.98.0",
"smol-toml": "^1.6.1",
"stylus": "^0.64.0",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.0"
}
}
+16 -18
View File
@@ -27,15 +27,15 @@ classifiers = [
]
dependencies = [
"arabic-reshaper==3.0.1", # Support for Arabic in reportlab
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
"babel",
"BeautifulSoup4==4.14.*",
"bleach==6.4.*",
"bleach==6.3.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=48.0.0",
"cryptography>=44.0.0",
"css-inline==0.20.*",
"defusedcsv>=3.0.0",
"defusedcsv>=1.1.0",
"dnspython==2.*",
"Django[argon2]==5.2.*",
"django-bootstrap3==26.1",
@@ -43,7 +43,7 @@ dependencies = [
"django-countries==8.2.*",
"django-filter==25.1",
"django-formset-js-improved==0.5.0.5",
"django-formtools==2.6.1",
"django-formtools==2.5.1",
"django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*",
"django-i18nfield==1.11.*",
@@ -56,7 +56,7 @@ dependencies = [
"django-redis==6.0.*",
"django-scopes==2.0.*",
"django-statici18n==2.7.*",
"djangorestframework==3.17.*",
"djangorestframework==3.16.*",
"dnspython==2.8.*",
"drf_ujson2==1.7.*",
"geoip2==5.*",
@@ -74,11 +74,11 @@ dependencies = [
"packaging",
"paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.13.*",
"PyJWT==2.12.*",
"phonenumberslite==9.0.*",
"Pillow==12.2.*",
"Pillow==12.1.*",
"pretix-plugin-build",
"protobuf==7.35.*",
"protobuf==7.34.*",
"psycopg2-binary",
"pycountry",
"pycparser==3.0",
@@ -90,14 +90,14 @@ dependencies = [
"pytz-deprecation-shim==0.1.*",
"pyuca",
"qrcode==8.2",
"redis==7.4.*",
"reportlab==4.5.*",
"redis==7.1.*",
"reportlab==4.4.*",
"requests==2.32.*",
"sentry-sdk==2.61.*",
"sentry-sdk==2.56.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
"tlds>=2026041800",
"tlds>=2020041600",
"tqdm==4.*",
"ua-parser==1.0.*",
"vobject==0.9.*",
@@ -108,25 +108,23 @@ dependencies = [
[project.optional-dependencies]
memcached = ["pylibmc"]
dev = [
"aiohttp==3.14.*",
"aiohttp==3.13.*",
"coverage",
"coveralls",
"fakeredis==2.36.*",
"fakeredis==2.34.*",
"flake8==7.3.*",
"freezegun",
"isort==8.0.*",
"pep8-naming==0.15.*",
"potypo",
"pytest-asyncio>=1.4.0",
"pytest-asyncio>=0.24",
"pytest-cache",
"pytest-cov",
"pytest-django==4.*",
"pytest-mock==3.15.*",
"pytest-sugar",
"pytest-xdist==3.8.*",
"pytest-playwright",
"pytest==9.0.*",
"playwright",
"responses",
]
-5
View File
@@ -37,9 +37,4 @@ ignore =
CONTRIBUTING.md
Dockerfile
SECURITY.md
eslint.config.mjs
package-lock.json
package.json
tsconfig.json
vite.config.js
+6 -6
View File
@@ -9,10 +9,10 @@ localegen:
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
./manage.py makemessages --keep-pot -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: npminstall npmbuild jsi18n
staticfiles: jsi18n
./manage.py collectstatic --noinput
compress:
compress: npminstall
./manage.py compress
jsi18n: localecompile
@@ -25,8 +25,8 @@ coverage:
coverage run -m py.test
npminstall:
npm ci
npmbuild:
npm run build
# 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
+1 -1
View File
@@ -19,4 +19,4 @@
# 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/>.
#
__version__ = "2026.6.0.dev0"
__version__ = "2026.3.5"
+3 -4
View File
@@ -37,11 +37,9 @@ INSTALLED_APPS = [
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.humanize',
# pretix needs to go before staticfiles
# so we can override the runserver command
'pretix.base',
'django.contrib.staticfiles',
'django.contrib.humanize',
'pretix.base',
'pretix.control',
'pretix.presale',
'pretix.multidomain',
@@ -245,6 +243,7 @@ STORAGES = {
COMPRESS_PRECOMPILERS = (
('text/x-scss', 'django_libsass.SassCompiler'),
('text/vue', 'pretix.helpers.compressor.VueCompiler'),
)
COMPRESS_OFFLINE_CONTEXT = {
+6 -7
View File
@@ -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:
subprocess.check_call('npm ci', shell=True, cwd=project_root)
# 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)
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,7 +62,6 @@ 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)
-2
View File
@@ -47,5 +47,3 @@ HAS_MEMCACHED = False
HAS_CELERY = False
HAS_GEOIP = False
SENTRY_ENABLED = False
VITE_DEV_MODE = False
VITE_IGNORE = False
+4 -13
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import logging
from collections import OrderedDict
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
@@ -51,18 +52,10 @@ class BaseSecurityProfile:
"""
raise NotImplementedError()
@property
def priority(self) -> int:
"""
Priority for ordering, higher will come first.
"""
return 100
class FullAccessSecurityProfile(BaseSecurityProfile):
identifier = 'full'
verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)')
priority = 1000
def is_allowed(self, request):
return True
@@ -197,15 +190,13 @@ def get_all_security_profiles():
if _ALL_PROFILES:
return _ALL_PROFILES
types = []
types = OrderedDict()
for recv, ret in register_device_security_profile.send(None):
if isinstance(ret, (list, tuple)):
for r in ret:
types.append(r)
types[r.identifier] = r
else:
types.append(ret)
types.sort(key=lambda el: el.priority, reverse=True)
types = {r.identifier: r for r in types}
types[ret.identifier] = ret
_ALL_PROFILES = types
return types
+20 -26
View File
@@ -133,43 +133,37 @@ 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:
+2 -2
View File
@@ -115,10 +115,10 @@ class PluginsField(serializers.Field):
def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins
active_plugins = set(obj.get_plugins())
return sorted([
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in active_plugins
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
])
def to_internal_value(self, data):
-6
View File
@@ -45,12 +45,6 @@ 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):
+2 -2
View File
@@ -191,7 +191,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('start', 'end', 'location')
fields = ('start', 'end')
class ItemBundleSerializer(serializers.ModelSerializer):
@@ -222,7 +222,7 @@ class ItemBundleSerializer(serializers.ModelSerializer):
class ItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta:
model = ItemProgramTime
fields = ('id', 'start', 'end', 'location')
fields = ('id', 'start', 'end')
def validate(self, data):
data = super().validate(data)
+10 -74
View File
@@ -31,9 +31,7 @@ from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.serializers.organizer import (
CustomerSerializer, GiftCardSerializer,
)
from pretix.base.models import (
Device, Order, OrderPosition, ReusableMedium, TeamAPIToken,
)
from pretix.base.models import Order, OrderPosition, ReusableMedium
logger = logging.getLogger(__name__)
@@ -66,14 +64,13 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
expand_nested = self.context['request'].query_params.getlist('expand')
if 'linked_giftcard' in expand_nested:
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
if not self.context["can_read_giftcards"]:
raise PermissionDenied("No permission to access gift card details.")
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context)
if 'linked_giftcard.owner_ticket' in expand_nested:
if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
else:
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
@@ -82,27 +79,18 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
queryset=self.context['organizer'].issued_gift_cards.all()
)
# keep linked_orderposition (singular) for backwards compatibility, will be overwritten in self.validate
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
required=False,
allow_null=True,
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
)
if 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested:
self.fields['linked_orderpositions'] = NestedOrderPositionSerializer(
many=True,
read_only=True
)
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
# No additional permission check performed, documented limitation of the permission system
# Would get to complex/unusable otherwise since the permission depends on the event
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
else:
self.fields['linked_orderpositions'] = serializers.PrimaryKeyRelatedField(
many=True,
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
required=False,
allow_null=True,
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
)
if 'customer' in expand_nested:
if 'customer' in self.context['request'].query_params.getlist('expand'):
if not self.context["can_read_customers"]:
raise PermissionDenied("No permission to access customer details.")
@@ -117,21 +105,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def validate(self, data):
data = super().validate(data)
if 'linked_orderposition' in data:
linked_orderposition = data['linked_orderposition']
# backwards-compatibility
if 'linked_orderpositions' in data:
raise ValidationError({
'linked_orderposition': 'You cannot use linked_orderposition and linked_orderpositions at the same time.'
})
if self.instance and self.instance.linked_orderpositions.count() > 1:
raise ValidationError({
'linked_orderposition': 'There are more than one linked_orderposition. You need to use linked_orderpositions.'
})
data['linked_orderpositions'] = [linked_orderposition] if linked_orderposition else []
del data['linked_orderposition']
if 'type' in data and 'identifier' in data:
qs = self.context['organizer'].reusable_media.filter(
identifier=data['identifier'], type=data['type']
@@ -144,41 +117,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
)
return data
def to_representation(self, instance):
r = super().to_representation(instance)
request = self.context.get('request')
ops = r.get('linked_orderpositions', [])
# late permission evaluations for checks that depend on the actual linked events
expand_nested = self.context['request'].query_params.getlist('expand')
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if ops and 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested:
ops_noperm = []
for lop in instance.linked_orderpositions.all():
event = lop.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
ops_noperm.append(lop.id)
if ops_noperm:
ops = [
{'id': op['id']} if op['id'] in ops_noperm
else op
for op in ops
]
r['linked_orderpositions'] = ops
# add linked_orderposition (singular) for backwards compatibility
if len(ops) < 2:
r['linked_orderposition'] = ops[0] if ops else None
if 'linked_giftcard.owner_ticket' in expand_nested:
gc = instance.linked_giftcard
if gc is not None and gc.owner_ticket is not None:
event = gc.owner_ticket.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['linked_giftcard']['owner_ticket'] = {'id': instance.linked_giftcard.owner_ticket.id}
return r
class Meta:
model = ReusableMedium
fields = (
@@ -188,12 +126,10 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
'updated',
'type',
'identifier',
'claim_token',
'label',
'active',
'expires',
'customer',
'linked_orderpositions',
'linked_orderposition',
'linked_giftcard',
'info',
'notes',
+11 -48
View File
@@ -769,11 +769,7 @@ class PaymentDetailsField(serializers.Field):
pp = value.payment_provider
if not pp:
return {}
try:
return pp.api_payment_details(value)
except Exception:
logger.exception("Failed to retrieve payment_details")
return {}
return pp.api_payment_details(value)
class OrderPaymentSerializer(I18nAwareModelSerializer):
@@ -1043,15 +1039,13 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
requested_valid_from = serializers.DateTimeField(required=False, allow_null=True)
use_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(),
required=False, allow_null=True)
add_to_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(),
required=False, allow_null=True)
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until',
'requested_valid_from', 'use_reusable_medium', 'add_to_reusable_medium', 'discount')
'requested_valid_from', 'use_reusable_medium', 'discount')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1063,8 +1057,6 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
with scopes_disabled():
if 'use_reusable_medium' in self.fields:
self.fields['use_reusable_medium'].queryset = ReusableMedium.objects.all()
if 'add_to_reusable_medium' in self.fields:
self.fields['add_to_reusable_medium'].queryset = ReusableMedium.objects.all()
def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -1080,9 +1072,6 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
)
return m
def validate_add_to_reusable_medium(self, m):
return self.validate_use_reusable_medium(m)
def validate_item(self, item):
if item.event != self.context['event']:
raise ValidationError(
@@ -1156,13 +1145,6 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
{'discount': ['You can only specify a discount if you do the price computation, but price is not set.']}
)
if 'use_reusable_medium' in data and 'add_to_reusable_medium' in data:
raise ValidationError({
'use_reusable_medium': ['You can only specify either use_reusable_medium or add_to_reusable_medium.'],
'add_to_reusable_medium': ['You can only specify either use_reusable_medium or add_to_reusable_medium.'],
})
return data
@@ -1430,7 +1412,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
qa = QuotaAvailability()
qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0])
qa.compute()
v_avail = {}
# These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental
# use further down
@@ -1460,13 +1441,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
voucher_usage[v] += 1
if voucher_usage[v] > 0:
if v not in v_avail:
v.refresh_from_db(fields=['redeemed'])
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=v) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
).exclude(pk__in=[cp.pk for cp in delete_cps])
v_avail[v] = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail[v] < voucher_usage[v]:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
).exclude(pk__in=[cp.pk for cp in delete_cps])
v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail < voucher_usage[v]:
errs[i]['voucher'] = [
'The voucher has already been used the maximum number of times.'
]
@@ -1602,7 +1581,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k not in ('answers', '_quotas', 'use_reusable_medium', 'add_to_reusable_medium')})
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
if simulate:
pos.order = order._wrapped
else:
@@ -1676,7 +1655,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
for pos_data in positions_data:
answers_data = pos_data.pop('answers', [])
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
add_to_reusable_medium = pos_data.pop('add_to_reusable_medium', None)
pos = pos_data['__instance']
pos._calculate_tax(invoice_address=ia)
@@ -1718,25 +1696,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answ.options.add(*options)
if use_reusable_medium:
for op_pk in use_reusable_medium.linked_orderpositions.values_list('pk', flat=True):
use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.removed',
data={
'linked_orderposition': op_pk,
}
)
use_reusable_medium.linked_orderpositions.set([pos])
use_reusable_medium.linked_orderposition = pos
use_reusable_medium.save(update_fields=['linked_orderposition'])
use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.added',
data={
'by_order': order.code,
'linked_orderposition': pos.pk,
}
)
elif add_to_reusable_medium:
add_to_reusable_medium.linked_orderpositions.add(pos)
add_to_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.added',
'pretix.reusable_medium.linked_orderposition.changed',
data={
'by_order': order.code,
'linked_orderposition': pos.pk,
-13
View File
@@ -286,19 +286,6 @@ class GiftCardSerializer(I18nAwareModelSerializer):
)
return data
def to_representation(self, instance):
r = super().to_representation(instance)
request = self.context.get('request')
# late permission evaluations for checks that depend on the actual linked events
if 'owner_ticket' in self.context['request'].query_params.getlist('expand'):
owner_ticket = instance.owner_ticket
if owner_ticket:
event = owner_ticket.order.event
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['owner_ticket'] = {'id': instance.owner_ticket.id}
return r
class Meta:
model = GiftCard
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket',
+22 -92
View File
@@ -491,7 +491,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
)
raw_barcode_for_checkin = None
from_revoked_secret = False
reusable_medium_used = None
if simulate:
common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True
@@ -522,12 +521,11 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
# with respecting the force option), or it's a reusable medium (-> proceed with that)
if not op_candidates:
try:
media = ReusableMedium.objects.active().filter(
Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk')))
).get(
media = ReusableMedium.objects.select_related('linked_orderposition').active().get(
organizer_id=checkinlists[0].event.organizer_id,
type=source_type,
identifier=raw_barcode,
linked_orderposition__isnull=False,
)
raw_barcode_for_checkin = raw_barcode
except ReusableMedium.DoesNotExist:
@@ -630,9 +628,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400)
else:
linked_ops = media.linked_orderpositions.all().select_related("order").prefetch_related("addons")
linked_event_ids = {op.order.event_id for op in linked_ops}
if not any(event_id in list_by_event for event_id in linked_event_ids):
if media.linked_orderposition.order.event_id not in list_by_event:
# Medium exists but connected ticket is for the wrong event
if not simulate:
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
@@ -658,91 +654,28 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'checkin_texts': [],
'list': MiniCheckinListSerializer(checkinlists[0]).data,
}, status=404)
op_candidates = []
for op in linked_ops:
if op.order.event_id in list_by_event:
reusable_medium_used = media
op_candidates.append(op)
if list_by_event[op.order.event_id].addon_match:
op_candidates += list(op.addons.all())
op_candidates = [media.linked_orderposition]
if list_by_event[media.linked_orderposition.order.event_id].addon_match:
op_candidates += list(media.linked_orderposition.addons.all())
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
# key on the same list, we're probably dealing with multiple linked_orderpositions or the ``addon_match`` case
# here and need to figure out which op has the right product. This basically is a valid-for-checkin-test on every op.
# key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
# which add-on has the right product.
if len(op_candidates) > 1:
op_candidates_matching_product = [
op for op in op_candidates
if (
(list_by_event[op.order.event_id].addon_match or op.secret == raw_barcode or legacy_url_support) and
(list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()})
)
]
if not reusable_medium_used:
# 3a. First, we clean up that we made an imprecise query above. If a scan is made for multiple check-in lists,
# we have queried ``addon_to__secret=raw_barcode``, even if some of the lists in question do not allow addon
# matching. So we accept all candidates that match one of these cases:
# - Exactly the ticket secret we scanned (because that's always a possible result)
# - Exactly the ticket pk we scanned (on legacy endpoints)
# - An add-on on a list that allows add-on matching
# This is not necessary when a reusable media was used, since in that case we already obeyed list.addon_match
# correctly above.
op_candidates_filtered = [
op for op in op_candidates
if (
op.secret == raw_barcode or
list_by_event[op.order.event_id].addon_match or
(str(op.pk) == raw_barcode and legacy_url_support and not untrusted_input)
)
]
else:
op_candidates_filtered = op_candidates
if len(op_candidates_filtered) > 1:
# 3b. If we still have multiple candidates, we filter by product based on the check-in list configuration.
# This is relevant for the addon_match scenario where the scanned ticket has multiple add-ons, but only
# one is contained in the check-in list used to scan. It makes sense to filter this first, since it is a
# "static" check, i.e. scanning the same QR code on the same check-in list will always do the same, no matter
# when I scan it, and it is "intentional" filtering in the sense that the admin configured this behaviour
# into the check-in list.
op_candidates_filtered = [
op for op in op_candidates_filtered
if list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()}
]
if len(op_candidates_filtered) > 1:
# 3c. If we still have multiple candidates, we filter by validity date. This was introduced for the case where
# a reusable media refers to two tickets, one currently valid and one expired or in the future. Howeer,
# it could in theory also happen with two add-ons being on the same check-in list but without overlapping
# validity. It makes sense to filter this "after" the previous checks since it is not "intentional" filtering
# configured by the admin but "accidental" filtering that depends on the time of execution.
op_candidates_filtered = [
op for op in op_candidates_filtered
if (
(not op.valid_from or op.valid_from <= datetime) and
(not op.valid_until or op.valid_until > datetime)
)
]
if len(op_candidates_filtered) == 0:
# None of the ops is valid today or has the correct product, too bad! We could just error out here, but
if len(op_candidates_matching_product) == 0:
# None of the found add-ons has the correct product, too bad! We could just error out here, but
# instead we just continue with *any* product and have it rejected by the check in perform_checkin.
# To improve the error message, we select the op that will "work next" or - if none matches - "worked last".
op_candidate = None
for op in op_candidates:
if (
op.valid_from and op.valid_from > datetime and
(not op_candidate or op.valid_from < op_candidate.valid_from)
):
op_candidate = op
if not op_candidate:
# no candidate in the future, get closest in the past
for op in op_candidates:
if (
op.valid_until and op.valid_until < datetime and
(not op_candidate or op.valid_until > op_candidate.valid_until)
):
op_candidate = op
if not op_candidate:
op_candidate = op_candidates[0]
op_candidates = [op_candidate]
elif len(op_candidates_filtered) > 1:
# This has the advantage of a better error message.
op_candidates = [op_candidates[0]]
elif len(op_candidates_matching_product) > 1:
# It's still ambiguous, we'll error out.
# We choose the first match (regardless of product) for the logging since it's most likely to be the
# base product according to our order_by above.
@@ -776,7 +709,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400)
else:
op_candidates = op_candidates_filtered
op_candidates = op_candidates_matching_product
op = op_candidates[0]
common_checkin_args['list'] = list_by_event[op.order.event_id]
@@ -788,10 +721,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
if str(q.pk) in answers_data:
try:
if q.type == Question.TYPE_FILE:
if answers_data[str(q.pk)]:
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
else:
given_answers[q] = None
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
else:
given_answers[q] = q.clean_answer(answers_data[str(q.pk)])
except (ValidationError, BaseValidationError):
+10 -35
View File
@@ -53,12 +53,10 @@ with scopes_disabled():
customer = django_filters.CharFilter(field_name='customer__identifier')
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
# backwards-compatible
linked_orderposition = django_filters.NumberFilter(field_name='linked_orderpositions__id')
class Meta:
model = ReusableMedium
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderpositions', 'linked_giftcard']
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard']
class ReusableMediaViewSet(viewsets.ModelViewSet):
@@ -77,7 +75,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
).order_by().values('card').annotate(s=Sum('value')).values('s')
return self.request.organizer.reusable_media.prefetch_related(
Prefetch(
'linked_orderpositions',
'linked_orderposition',
queryset=OrderPosition.objects.select_related(
'order', 'order__event', 'order__event__organizer', 'seat',
).prefetch_related(
@@ -119,38 +117,14 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
@transaction.atomic()
def perform_update(self, serializer):
rm = ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
prev_linked_ops_pks = list(rm.linked_orderpositions.values_list("pk", flat=True))
ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type)
linked_ops_pks = inst.linked_orderpositions.values_list("pk", flat=True)
for op_pk in prev_linked_ops_pks:
if op_pk not in linked_ops_pks:
inst.log_action(
'pretix.reusable_medium.linked_orderposition.removed',
user=self.request.user,
auth=self.request.auth,
data={
'linked_orderposition': op_pk,
}
)
for op_pk in linked_ops_pks:
if op_pk not in prev_linked_ops_pks:
inst.log_action(
'pretix.reusable_medium.linked_orderposition.added',
user=self.request.user,
auth=self.request.auth,
data={
'linked_orderposition': op_pk,
}
)
data = {k: v for k, v in self.request.data.items() if k not in ('linked_orderposition', 'linked_orderpositions')}
if data:
inst.log_action(
'pretix.reusable_medium.changed',
user=self.request.user,
auth=self.request.auth,
data=data,
)
inst.log_action(
'pretix.reusable_medium.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
def perform_destroy(self, instance):
@@ -183,6 +157,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
type=s.validated_data["type"],
identifier=s.validated_data["identifier"],
)
m.linked_orderposition = None # not relevant for cross-organizer
m.customer = None # not relevant for cross-organizer
s = self.get_serializer(m)
return Response({"result": s.data})
+30 -45
View File
@@ -194,7 +194,7 @@ with scopes_disabled():
)
).values('id')
matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderpositions__order_id', flat=True)
matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderposition__order_id', flat=True)
mainq = (
code
@@ -381,15 +381,12 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp
else:
return FileResponse(
ct.file.file,
filename='{}-{}-{}{}'.format(
self.request.event.slug.upper(), order.code,
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
resp = FileResponse(ct.file.file, content_type=ct.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
self.request.event.slug.upper(), order.code,
provider.identifier, ct.extension
)
return resp
@action(detail=True, methods=['POST'])
def mark_paid(self, request, **kwargs):
@@ -1034,7 +1031,7 @@ with scopes_disabled():
search = django_filters.CharFilter(method='search_qs')
def search_qs(self, queryset, name, value):
matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderpositions', flat=True)
matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderposition', flat=True)
return queryset.filter(
Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value)
@@ -1306,17 +1303,14 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
raise NotFound()
ftype, ignored = mimetypes.guess_type(answer.file.name)
return FileResponse(
answer.file,
filename='{}-{}-{}-{}'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
os.path.basename(answer.file.name).split('.', 1)[1]
),
as_attachment=True,
content_type=ftype or 'application/binary'
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
os.path.basename(answer.file.name).split('.', 1)[1]
)
return resp
@action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"])
def printlog(self, request, **kwargs):
@@ -1371,18 +1365,15 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
if hasattr(image_file, 'seek'):
image_file.seek(0)
return FileResponse(
image_file,
filename='{}-{}-{}-{}.{}'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
key,
extension,
),
as_attachment=True,
content_type=ftype or 'application/binary'
resp = FileResponse(image_file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
key,
extension,
)
return resp
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
@@ -1408,15 +1399,12 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp
else:
return FileResponse(
ct.file.file,
filename='{}-{}-{}-{}{}'.format(
self.request.event.slug.upper(), pos.order.code, pos.positionid,
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
resp = FileResponse(ct.file.file, content_type=ct.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), pos.order.code, pos.positionid,
provider.identifier, ct.extension
)
return resp
@action(detail=True, methods=['POST'])
def regenerate_secrets(self, request, **kwargs):
@@ -1998,12 +1986,9 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
if not invoice.file:
raise RetryException()
return FileResponse(
invoice.file.file,
filename='{}.pdf'.format(invoice.number),
as_attachment=True,
content_type='application/pdf'
)
resp = FileResponse(invoice.file.file, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
return resp
@action(detail=True, methods=['POST'])
def transmit(self, request, **kwargs):
-6
View File
@@ -408,12 +408,6 @@ 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'),
-80
View File
@@ -19,10 +19,7 @@
# 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 ipaddress
import logging
import smtplib
import socket
from itertools import groupby
from smtplib import SMTPResponseException
from typing import TypeVar
@@ -240,80 +237,3 @@ def base_renderers(sender, **kwargs):
def get_email_context(**kwargs):
return PlaceholderContext(**kwargs).render_all()
def create_connection(address, timeout=socket.getdefaulttimeout(),
source_address=None, *, all_errors=False):
# Taken from the python stdlib, extended with a check for local ips
host, port = address
exceptions = []
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
af, socktype, proto, canonname, sa = res
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
ip_addr = ipaddress.ip_address(sa[0])
if ip_addr.is_multicast:
raise socket.error(f"Request to multicast address {sa[0]} blocked")
if ip_addr.is_loopback or ip_addr.is_link_local:
raise socket.error(f"Request to local address {sa[0]} blocked")
if ip_addr.is_private:
raise socket.error(f"Request to private address {sa[0]} blocked")
sock = None
try:
sock = socket.socket(af, socktype, proto)
if timeout is not socket.getdefaulttimeout():
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sa)
# Break explicitly a reference cycle
exceptions.clear()
return sock
except socket.error as exc:
if not all_errors:
exceptions.clear() # raise only the last error
exceptions.append(exc)
if sock is not None:
sock.close()
if len(exceptions):
try:
if not all_errors:
raise exceptions[0]
raise ExceptionGroup("create_connection failed", exceptions)
finally:
# Break explicitly a reference cycle
exceptions.clear()
else:
raise socket.error("getaddrinfo returns an empty list")
class CheckPrivateNetworkMixin:
# _get_socket taken 1:1 from smtplib, just with a call to our own create_connection
def _get_socket(self, host, port, timeout):
# This makes it simpler for SMTP_SSL to use the SMTP connect code
# and just alter the socket connection bit.
if timeout is not None and not timeout:
raise ValueError('Non-blocking socket (timeout=0) is not supported')
if self.debuglevel > 0:
self._print_debug('connect: to', (host, port), self.source_address)
return create_connection((host, port), timeout, self.source_address)
class SMTP(CheckPrivateNetworkMixin, smtplib.SMTP):
pass
# SMTP used here instead of mixin, because smtp.SMTP_SSL._get_socket calls super()._get_socket and then wraps this socket
# super()._get_socket needs to be our version from the mixin
class SMTP_SSL(smtplib.SMTP_SSL, SMTP): # noqa: N801
pass
class CheckPrivateNetworkSmtpBackend(EmailBackend):
@property
def connection_class(self):
return SMTP_SSL if self.use_ssl else SMTP
+3 -13
View File
@@ -47,7 +47,6 @@ from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _
from pretix.base.models import Event
from pretix.base.models.auth import PermissionHolder
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
)
@@ -60,20 +59,11 @@ class BaseExporter:
This is the base class for all data exporters
"""
def __init__(self, event, organizer, permission_holder: PermissionHolder=None, progress_callback=lambda v: None):
"""
:param event: Event context, can also be a queryset of events for multi-event exports
:param organizer: Organizer context
:param user: The user who triggered the export (or None).
:param token: The API token that triggered the export (or None).
:param device: The device that triggered the export (or None)
:param progress_callback: Callback function with progress
"""
def __init__(self, event, organizer, progress_callback=lambda v: None):
self.event = event
self.organizer = organizer
self.progress_callback = progress_callback
self.is_multievent = isinstance(event, QuerySet)
self.permission_holder = permission_holder
if isinstance(event, QuerySet):
self.events = event
self.event = None
@@ -190,7 +180,7 @@ class BaseExporter:
return True
@classmethod
def get_required_event_permission(cls) -> Optional[str]:
def get_required_event_permission(cls) -> str:
"""
The permission level required to use this exporter for events. For multi-event-exports, this will be used
to limit the selection of events. Will be ignored if the ``OrganizerLevelExportMixin`` mixin is used.
@@ -205,7 +195,7 @@ class OrganizerLevelExportMixin:
raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin")
@classmethod
def get_required_organizer_permission(cls) -> Optional[str]:
def get_required_organizer_permission(cls) -> str:
"""
The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to
allow everyone with any access to the organizer.
+2 -17
View File
@@ -160,7 +160,7 @@ class OrderListExporter(MultiSheetListExporter):
def _get_all_payment_methods(self, qs):
pps = dict(get_all_payment_providers())
return sorted([(pp, pps.get(pp, pp)) for pp in set(
return sorted([(pp, pps[pp]) for pp in set(
OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list(
'provider', flat=True
).distinct()
@@ -330,7 +330,6 @@ 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
@@ -348,7 +347,6 @@ 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(
@@ -436,6 +434,7 @@ 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')) -
@@ -1104,25 +1103,13 @@ class PaymentListExporter(ListExporter):
def iterate_list(self, form_data):
provider_names = dict(get_all_payment_providers())
i_numbers = Invoice.objects.filter(
order=OuterRef('order_id'),
).values('order').annotate(
m=GroupConcat('full_invoice_no', delimiter=', ')
).values(
'm'
).order_by()
payments = OrderPayment.objects.filter(
order__event__in=self.events,
state__in=form_data.get('payment_states', [])
).annotate(
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
).select_related('order').prefetch_related('order__event').order_by('created')
refunds = OrderRefund.objects.filter(
order__event__in=self.events,
state__in=form_data.get('refund_states', [])
).annotate(
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
).select_related('order').prefetch_related('order__event').order_by('created')
if form_data.get('end_date_range'):
@@ -1148,7 +1135,6 @@ class PaymentListExporter(ListExporter):
headers = [
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Status code'), _('Amount'), _('Payment method'), _('Comment'), _('Matching ID'), _('Payment details'),
_('Invoice numbers'),
]
yield headers
@@ -1186,7 +1172,6 @@ class PaymentListExporter(ListExporter):
obj.comment if isinstance(obj, OrderRefund) else "",
matching_id,
payment_details,
obj.order_invoice_numbers,
]
yield row
+8 -14
View File
@@ -20,13 +20,12 @@
# <https://www.gnu.org/licenses/>.
#
from django.db.models import Prefetch
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy
from ..exporter import ListExporter, OrganizerLevelExportMixin
from ..models import OrderPosition, ReusableMedium
from ..models import ReusableMedium
from ..signals import register_multievent_data_exporters
@@ -45,9 +44,7 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
media = ReusableMedium.objects.filter(
organizer=self.organizer,
).select_related(
'customer', 'linked_giftcard',
).prefetch_related(
Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order"))
'customer', 'linked_orderposition', 'linked_giftcard',
).order_by('created')
headers = [
@@ -64,23 +61,20 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
yield headers
yield self.ProgressSetTotal(total=media.count())
can_read_giftcards = self.permission_holder.has_organizer_permission(self.organizer, 'organizer.giftcards:read')
for medium in media.iterator(chunk_size=1000):
giftcard_secret = medium.linked_giftcard.secret if medium.linked_giftcard_id else ''
if giftcard_secret and not can_read_giftcards:
giftcard_secret = giftcard_secret[:3] + ""
yield [
row = [
medium.type,
medium.identifier,
_('Yes') if medium.active else _('No'),
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
medium.customer.identifier if medium.customer_id else '',
', '.join([f"{op.order.code}-{op.positionid}" for op in medium.linked_orderpositions.all()]),
giftcard_secret,
f"{medium.linked_orderposition.order.code}-{medium.linked_orderposition.positionid}" if medium.linked_orderposition_id else '',
# we cannot determine here whether user has permission organizer.giftcards:read
# so default to not showing giftcard secret
medium.linked_giftcard.secret[:3] + "" if medium.linked_giftcard_id else '',
medium.notes,
]
yield row
def get_filename(self):
return f'{self.organizer.slug}_media'
+16 -36
View File
@@ -35,7 +35,6 @@
import copy
import json
import logging
import re
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
@@ -48,7 +47,9 @@ from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import MaxValueValidator, MinValueValidator
from django.core.validators import (
MaxValueValidator, MinValueValidator, RegexValidator,
)
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
@@ -89,7 +90,7 @@ from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
)
from pretix.base.templatetags.rich_text import URL_RE, rich_text
from pretix.base.templatetags.rich_text import rich_text
from pretix.base.timemachine import time_machine_now
from pretix.control.forms import (
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
@@ -219,8 +220,16 @@ class NamePartsFormField(forms.MultiValueField):
defaults = {
'widget': self.widget,
'max_length': kwargs.pop('max_length', None),
'validators': [
RegexValidator(
# The following characters should never appear in a name anywhere of
# the world. However, they commonly appear in inputs generated by spam
# bots.
r'^[^$€/%§{}<>~]*$',
message=_('Please do not use special characters in names.')
)
]
}
self.max_length = defaults['max_length']
self.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles')
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
@@ -240,6 +249,7 @@ class NamePartsFormField(forms.MultiValueField):
if fname == 'title' and self.scheme_titles:
d = dict(defaults)
d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField(
**d,
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
@@ -248,6 +258,7 @@ class NamePartsFormField(forms.MultiValueField):
elif fname == 'salutation':
d = dict(defaults)
d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField(
**d,
choices=[
@@ -276,40 +287,9 @@ class NamePartsFormField(forms.MultiValueField):
if self.require_all_fields and not all(v for v in value):
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
if sum(len(v) for v in value.values() if v) > (self.max_length or 250):
if sum(len(v) for v in value.values() if v) > 250:
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
for fname, label, size in self.scheme['fields']:
if fname == 'salutation' or (fname == 'title' and self.scheme_titles):
continue
v = value.get(fname)
if not v:
continue
special_chars = re.findall('[$€/%§{}<>~]', v)
if special_chars:
raise forms.ValidationError(
_('The field "%(label)s" may not contain special characters such as "%(chars)s".'),
code='name_special_chars',
params={
"label": label,
"chars": "".join(special_chars),
},
)
# URL_RE checks for valid domain names, including one special TLD med, which can be part of a title
if ".med" in v:
v = v.replace(".med", ". med")
value[fname] = v
url_matched = URL_RE.search(v)
if url_matched:
raise forms.ValidationError(
_('The field "%(label)s" may not contain an URL (%(url)s).'),
code='url_in_title',
params={
"label": label,
"url": url_matched.group(0),
}
)
if value.get("salutation") == "empty":
value["salutation"] = ""
+77 -91
View File
@@ -22,9 +22,7 @@
import datetime
import logging
import math
import re
import textwrap
import unicodedata
from collections import defaultdict
from decimal import Decimal
from io import BytesIO
@@ -58,8 +56,8 @@ from pretix.base.services.currencies import SOURCE_NAMES
from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import (
FontFallbackParagraph, ThumbnailingImageReader, register_ttf_font_if_new,
reshaper,
FontFallbackParagraph, PlainTextParagraph, ThumbnailingImageReader,
normalize_text, register_ttf_font_if_new, reshaper,
)
from pretix.presale.style import get_fonts
@@ -259,18 +257,8 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
def _normalize(self, text):
# reportlab does not support unicode combination characters
# It's important we do this before we use ArabicReshaper
text = unicodedata.normalize("NFKC", text)
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
# to resolve all ligatures and python-bidi to switch RTL texts.
try:
text = "<br />".join(get_display(reshaper.reshape(l)) for l in re.split("<br ?/>", text))
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
return text
# alias kept for plugin compatibility
return normalize_text(text)
def _upper(self, val):
# We uppercase labels, but not in every language
@@ -351,10 +339,15 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
return 'invoice.pdf', 'application/pdf', buffer.read()
def _clean_text(self, text, tags=None):
return self._normalize(bleach.clean(
text,
tags=set(tags) if tags else set()
).strip().replace('<br>', '<br />').replace('\n', '<br />\n'))
# For backwards compatibility with customer content, we need to support tags like <br> and <b> in a few text
# fields. Therefore, we can't use PlainTextParagraph for these, but run bleach instead to limit the allowed
# tags.
return self._normalize(
bleach.clean(
text,
tags=set(tags) if tags else set()
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')
)
class PaidMarker(Flowable):
@@ -405,8 +398,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas):
p = FontFallbackParagraph(self._clean_text(self.invoice.address_invoice_to),
style=self.stylesheet['Normal'])
p = PlainTextParagraph(self.invoice.address_invoice_to, style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
@@ -417,8 +409,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_from_top = 17 * mm
def _draw_invoice_from(self, canvas):
p = FontFallbackParagraph(
self._clean_text(self.invoice.full_invoice_from),
p = PlainTextParagraph(
self.invoice.full_invoice_from,
style=self.stylesheet['InvoiceFrom']
)
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
@@ -548,13 +540,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_event(self, canvas):
def shorten(txt):
txt = str(txt)
txt = bleach.clean(txt, tags=set()).strip()
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = PlainTextParagraph(txt, style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height)
while p_size[1] > 2 * self.stylesheet['Normal'].leading:
txt = ' '.join(txt.replace('', '').split()[:-1]) + ''
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = PlainTextParagraph(txt, style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height)
return txt
@@ -572,7 +563,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
else:
p_str = shorten(self.invoice.event.name)
p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = PlainTextParagraph(p_str, style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.event_width, self.event_height)
p_size = p.wrap(self.event_width, self.event_height)
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
@@ -645,39 +636,37 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
type_info_text = self.invoice.transmission_type_instance.pdf_info_text()
if type_info_text:
story.append(FontFallbackParagraph(
story.append(PlainTextParagraph(
type_info_text,
self.stylesheet['WarningBlock']
))
if self.invoice.custom_field:
story.append(FontFallbackParagraph(
story.append(PlainTextParagraph(
'{}: {}'.format(
self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)),
self._clean_text(self.invoice.custom_field),
str(self.invoice.event.settings.invoice_address_custom_field),
self.invoice.custom_field,
),
self.stylesheet['Normal']
))
if self.invoice.internal_reference:
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Customer reference: {reference}').format(
reference=self._clean_text(self.invoice.internal_reference),
)),
story.append(PlainTextParagraph(
pgettext('invoice', 'Customer reference: {reference}').format(
reference=self.invoice.internal_reference,
),
self.stylesheet['Normal']
))
if self.invoice.invoice_to_vat_id:
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' +
self._clean_text(self.invoice.invoice_to_vat_id),
story.append(PlainTextParagraph(
pgettext('invoice', 'Customer VAT ID') + ': ' + self.invoice.invoice_to_vat_id,
self.stylesheet['Normal']
))
if self.invoice.invoice_to_beneficiary:
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Beneficiary')) + ':<br />' +
self._clean_text(self.invoice.invoice_to_beneficiary),
story.append(PlainTextParagraph(
pgettext('invoice', 'Beneficiary') + ':\n' + self.invoice.invoice_to_beneficiary,
self.stylesheet['Normal']
))
@@ -707,11 +696,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story = [
NextPageTemplate('FirstPage'),
FontFallbackParagraph(
self._normalize(
PlainTextParagraph(
(
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
else pgettext('invoice', 'Invoice')
) if not self.invoice.is_cancellation else self._normalize(pgettext('invoice', 'Cancellation')),
) if not self.invoice.is_cancellation else pgettext('invoice', 'Cancellation'),
self.stylesheet['Heading1']
),
Spacer(1, 5 * mm),
@@ -733,17 +722,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
]
if has_taxes:
tdata = [(
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']),
PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Net'), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Gross'), self.stylesheet['BoldRightNoSplit']),
)]
else:
tdata = [(
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']),
PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']),
PlainTextParagraph(pgettext('invoice', 'Amount'), self.stylesheet['BoldRightNoSplit']),
)]
def _group_key(line):
@@ -780,8 +769,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
max_height = self.stylesheet['Normal'].leading * 5
p_style = self.stylesheet['Normal']
for __ in range(1000):
p = FontFallbackParagraph(
self._clean_text(curr_description, tags=['br']),
p = PlainTextParagraph(
curr_description,
p_style
)
h = p.wrap(max_width, doc.height)[1]
@@ -862,7 +851,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
# Group together at the end of the invoice
request_show_service_date = period_line
elif period_line:
description_p_list.append(FontFallbackParagraph(
description_p_list.append(PlainTextParagraph(
period_line,
self.stylesheet['Fineprint']
))
@@ -874,7 +863,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net_price=money_filter(net_value, self.invoice.event.currency),
gross_price=money_filter(gross_value, self.invoice.event.currency),
)
description_p_list.append(FontFallbackParagraph(
description_p_list.append(PlainTextParagraph(
single_price_line,
self.stylesheet['Fineprint']
))
@@ -883,11 +872,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
description_p_list.pop(0),
str(len(lines)),
localize(tax_rate) + " %",
FontFallbackParagraph(
PlainTextParagraph(
money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
FontFallbackParagraph(
PlainTextParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
@@ -904,14 +893,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
single_price_line = pgettext('invoice', 'Single price: {price}').format(
price=money_filter(gross_value, self.invoice.event.currency),
)
description_p_list.append(FontFallbackParagraph(
description_p_list.append(PlainTextParagraph(
single_price_line,
self.stylesheet['Fineprint']
))
tdata.append((
description_p_list.pop(0),
str(len(lines)),
FontFallbackParagraph(
PlainTextParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
@@ -944,12 +933,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if has_taxes:
tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '', '', '',
money_filter(total, self.invoice.event.currency)
])
else:
tdata.append([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '',
money_filter(total, self.invoice.event.currency)
])
@@ -958,12 +947,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
pending_sum = self.invoice.order.pending_sum
if pending_sum != total:
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] +
[PlainTextParagraph(pgettext('invoice', 'Received payments'), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum - total, self.invoice.event.currency)]
)
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] +
[PlainTextParagraph(pgettext('invoice', 'Outstanding payments'), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum, self.invoice.event.currency)]
)
@@ -980,12 +969,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
s=Sum('amount')
)['s'] or Decimal('0.00')
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] +
[PlainTextParagraph(pgettext('invoice', 'Paid by gift card'), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(giftcard_sum, self.invoice.event.currency)]
)
tdata.append(
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] +
[PlainTextParagraph(pgettext('invoice', 'Remaining amount'), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(total - giftcard_sum, self.invoice.event.currency)]
)
@@ -1008,14 +997,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 10 * mm))
if request_show_service_date:
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date)),
story.append(PlainTextParagraph(
pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date),
self.stylesheet['Normal']
))
if self.invoice.payment_provider_text:
story.append(FontFallbackParagraph(
self._normalize(self.invoice.payment_provider_text),
self._clean_text(self.invoice.payment_provider_text, tags=['br', 'b']),
self.stylesheet['Normal']
))
@@ -1039,10 +1028,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
]
thead = [
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']),
PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['Fineprint']),
PlainTextParagraph(pgettext('invoice', 'Net value'), self.stylesheet['FineprintRight']),
PlainTextParagraph(pgettext('invoice', 'Gross value'), self.stylesheet['FineprintRight']),
PlainTextParagraph(pgettext('invoice', 'Tax'), self.stylesheet['FineprintRight']),
''
]
tdata = [thead]
@@ -1053,7 +1042,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
continue
tax = taxvalue_map[idx]
tdata.append([
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']),
money_filter(gross - tax, self.invoice.event.currency),
money_filter(gross, self.invoice.event.currency),
money_filter(tax, self.invoice.event.currency),
@@ -1072,7 +1061,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
table.setStyle(TableStyle(tstyledata))
story.append(Spacer(5 * mm, 5 * mm))
story.append(KeepTogether([
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']),
PlainTextParagraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']),
table
]))
@@ -1089,7 +1078,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net = gross - tax
tdata.append([
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']),
fmt(net), fmt(gross), fmt(tax), ''
])
@@ -1098,13 +1087,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(KeepTogether([
Spacer(1, height=2 * mm),
FontFallbackParagraph(
self._normalize(pgettext(
PlainTextParagraph(
pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, this corresponds to:'
).format(rate=localize(self.invoice.foreign_currency_rate),
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"))),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT")),
self.stylesheet['Fineprint']
),
Spacer(1, height=3 * mm),
@@ -1113,14 +1102,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
story.append(Spacer(1, 5 * mm))
story.append(FontFallbackParagraph(self._normalize(
story.append(PlainTextParagraph(
pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, the invoice total corresponds to {total}.'
).format(rate=localize(self.invoice.foreign_currency_rate),
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
total=fmt(foreign_total))),
total=fmt(foreign_total)),
self.stylesheet['Fineprint']
))
@@ -1160,13 +1149,10 @@ class Modern1Renderer(ClassicInvoiceRenderer):
return stylesheet
def _draw_invoice_from(self, canvas):
if not self.invoice.address_invoice_from:
if not self.invoice.invoice_from:
return
c = [
self._clean_text(l)
for l in self.invoice.address_invoice_from.strip().split('\n')
]
p = FontFallbackParagraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender'])
c = self.invoice.address_invoice_from.strip().split('\n')
p = PlainTextParagraph(' · '.join(c), style=self.stylesheet['Sender'])
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
super()._draw_invoice_from(canvas)
@@ -1225,8 +1211,8 @@ class Modern1Renderer(ClassicInvoiceRenderer):
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs)
]
p = FontFallbackParagraph(
self._normalize(date_format(self.invoice.date, "DATE_FORMAT")),
p = PlainTextParagraph(
date_format(self.invoice.date, "DATE_FORMAT"),
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
)
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
@@ -1283,7 +1269,7 @@ class Modern1SimplifiedRenderer(Modern1Renderer):
i = []
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
i.append(FontFallbackParagraph(
i.append(PlainTextParagraph(
pgettext('invoice', 'Event date: {date_range}').format(
date_range=self.invoice.event.get_date_range_display(),
),
+1 -26
View File
@@ -24,7 +24,6 @@ from urllib.parse import urlparse, urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings
from django.core.exceptions import BadRequest
from django.http import Http404, HttpRequest, HttpResponse
from django.middleware.common import CommonMiddleware
from django.urls import get_script_prefix, resolve
@@ -281,7 +280,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,18 +294,6 @@ 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
@@ -360,18 +347,6 @@ class SecurityMiddleware(MiddlewareMixin):
return resp
class RejectInvalidInputMiddleware(MiddlewareMixin):
def process_request(self, request):
# Nullbytes in GET/POST parameters are mostly harmless, as they will later fail on database insertion, but it
# keeps spamming our error logs whenever someone tries to run a vulnerability scanner.
if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']:
raise BadRequest("Invalid characters in input.")
if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded":
if any("\x00" in value for key, value_list in request.POST.lists() for value in value_list):
raise BadRequest("Invalid characters in input.")
class CustomCommonMiddleware(CommonMiddleware):
def get_full_path_with_slash(self, request):
@@ -1,19 +0,0 @@
# Generated by Django 4.2.27 on 2026-01-21 12:06
import i18nfield.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0298_pluggable_permissions"),
]
operations = [
migrations.AddField(
model_name="itemprogramtime",
name="location",
field=i18nfield.fields.I18nTextField(max_length=200, null=True),
)
]
@@ -1,35 +0,0 @@
# Generated by Django 4.2.26 on 2025-11-24 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0299_itemprogramtime_location"),
]
operations = [
migrations.AddField(
model_name="reusablemedium",
name="claim_token",
field=models.CharField(max_length=200, null=True),
),
migrations.AddField(
model_name="reusablemedium",
name="label",
field=models.CharField(max_length=200, null=True),
),
# use temporary related_name "linked_mediums" for ManyToManyField, so we can migrate existing data
migrations.AddField(
model_name="reusablemedium",
name="linked_orderpositions",
field=models.ManyToManyField(
related_name="linked_mediums", to="pretixbase.orderposition"
),
),
migrations.RunSQL(
sql="INSERT INTO pretixbase_reusablemedium_linked_orderpositions (reusablemedium_id, orderposition_id) SELECT id, linked_orderposition_id FROM pretixbase_reusablemedium WHERE linked_orderposition_id IS NOT NULL;",
reverse_sql="DELETE FROM pretixbase_reusablemedium_linked_orderpositions;",
),
]
@@ -1,44 +0,0 @@
# Generated by Django 4.2.26 on 2025-11-24 11:32
from django.db import migrations, models
def reverse(apps, schema_editor):
ReusableMedium = apps.get_model('pretixbase', 'ReusableMedium')
qs = ReusableMedium.linked_orderpositions.through.objects
objs = []
# get last added orderposition from linked_orderpositions
for rm_id, op_id in qs.filter(id__in=qs.values("reusablemedium_id").annotate(max_id=models.Max('id')).values('max_id')).values_list("reusablemedium_id", "orderposition_id"):
obj = ReusableMedium(
id=rm_id,
linked_orderposition_id=op_id,
)
objs.append(obj)
ReusableMedium.objects.bulk_update(objs, ['linked_orderposition_id'])
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0300_add_reusablemedium_label"),
]
operations = [
# according to the docs, UPDATE FROM should run similarly on sqlite and postgres, but I could not get it to work
# so roll back the data migration with code before deleting data from through-table in 0297
migrations.RunPython(migrations.RunPython.noop, reverse),
migrations.RemoveField(
model_name="reusablemedium",
name="linked_orderposition",
),
# change related_name for new ManyToManyField to previously used linked_media
migrations.AlterField(
model_name="reusablemedium",
name="linked_orderpositions",
field=models.ManyToManyField(
related_name="linked_media", to="pretixbase.orderposition"
),
),
]
-4
View File
@@ -70,10 +70,6 @@ def parse_csv(file, length=None, mode="strict", charset=None):
except ImportError:
charset = file.charset
data = data.decode(charset or "utf-8", mode)
# remove stray linebreaks from the end of the file
data = data.rstrip("\n")
# If the file was modified on a Mac, it only contains \r as line breaks
if '\r' in data and '\n' not in data:
data = data.replace('\r', '\n')
+1 -1
View File
@@ -442,7 +442,7 @@ class AttendeeState(ImportColumn):
@property
def verbose_name(self):
return _('Attendee address') + ': ' + pgettext('address', 'State')
return _('Attendee address') + ': ' + _('State')
def clean(self, value, previous_values):
if value:
+3 -9
View File
@@ -29,9 +29,7 @@ import inspect
import logging
import os
import threading
from pathlib import Path
import django
from django.conf import settings
from django.db import transaction
@@ -76,14 +74,10 @@ def _transactions_mark_order_dirty(order_id, using=None):
if "PYTEST_CURRENT_TEST" in os.environ:
# We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code
# or not.
for frame in inspect.stack()[1:]:
if (
'pretix/base/models/orders' in frame.filename
or Path(frame.filename).is_relative_to(Path(django.__file__).parent)
):
# Ignore model- and django-internal code
for frame in inspect.stack():
if 'pretix/base/models/orders' in frame.filename:
continue
elif 'test_' in frame.filename or 'conftest.py' in frame.filename:
elif 'test_' in frame.filename or 'conftest.py in frame.filename':
return
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
# This went through non-test code, let's consider it non-test
-21
View File
@@ -38,7 +38,6 @@ import operator
import secrets
from datetime import timedelta
from functools import reduce
from typing import Protocol
from django.conf import settings
from django.contrib.auth.models import (
@@ -68,14 +67,6 @@ class EmailAddressTakenError(IntegrityError):
pass
class PermissionHolder(Protocol):
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
...
def has_organizer_permission(self, organizer, perm_name=None, request=None):
...
class UserManager(BaseUserManager):
"""
This is the user manager for our custom user model. See the User
@@ -705,18 +696,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return self.teams.exists()
class UserWithStaffSession:
# Wrapper around a User object with a staff session, implementing the PermissionHolder Protocol
def __init__(self, user):
self.user = user
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
return True
def has_organizer_permission(self, organizer, perm_name=None, request=None):
return True
class UserKnownLoginSource(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
agent_type = models.CharField(max_length=255, null=True, blank=True)
+1 -1
View File
@@ -125,7 +125,7 @@ class LoggingMixin:
elif isinstance(self, Event):
event = self
organizer_id = self.organizer_id
elif hasattr(self, 'event') and self.event:
elif hasattr(self, 'event'):
event = self.event
organizer_id = self.event.organizer_id
elif hasattr(self, 'organizer_id'):
+1 -2
View File
@@ -229,7 +229,7 @@ class Device(LoggedModel):
"""
return self._organizer_permission_set() if self.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
"""
Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``.
@@ -238,7 +238,6 @@ class Device(LoggedModel):
:param event: The event to check
:param perm_name: The permission, e.g. ``event.orders:read``
:param request: This parameter is ignored and only defined for compatibility reasons.
:param session_key: This parameter is ignored and only defined for compatibility reasons.
:return: bool
"""
has_event_access = (self.all_events and organizer == self.organizer) or (
-6
View File
@@ -715,12 +715,6 @@ class Event(EventMixin, LoggedModel):
self.settings.name_scheme = 'given_family'
self.settings.payment_banktransfer_invoice_immediately = True
self.settings.low_availability_percentage = 10
self.settings.mail_send_order_free_attendee = True
self.settings.mail_send_order_placed_attendee = True
self.settings.mail_send_order_paid_attendee = True
self.settings.mail_send_order_approved_attendee = True
self.settings.mail_send_order_approved_free_attendee = True
self.settings.mail_text_download_reminder_attendee = True
@property
def social_image(self):
-7
View File
@@ -2306,17 +2306,10 @@ class ItemProgramTime(models.Model):
:type start: datetime
:param end: The date and time this program time ends
:type end: datetime
:param location: venue
:type location: str
"""
item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE)
start = models.DateTimeField(verbose_name=_("Start"))
end = models.DateTimeField(verbose_name=_("End"))
location = I18nTextField(
null=True, blank=True,
max_length=200,
verbose_name=_("Location"),
)
def clean(self):
if hasattr(self, 'item') and self.item and self.item.event.has_subevents:
+4 -16
View File
@@ -72,16 +72,6 @@ class ReusableMedium(LoggedModel):
max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Identifier'),
)
claim_token = models.CharField(
max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Claim token'),
null=True, blank=True
)
label = models.CharField(
max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Label'),
null=True, blank=True
)
active = models.BooleanField(
verbose_name=_('Active'),
@@ -99,14 +89,12 @@ class ReusableMedium(LoggedModel):
on_delete=models.SET_NULL,
verbose_name=_('Customer account'),
)
linked_orderpositions = models.ManyToManyField(
linked_orderposition = models.ForeignKey(
OrderPosition,
null=True, blank=True,
related_name='linked_media',
verbose_name=_('Linked tickets'),
help_text=_(
'If you link to more than one ticket, make sure there is no overlap in validity. '
'If multiple tickets are valid at once, this will lead to failed check-ins.'
)
on_delete=models.SET_NULL,
verbose_name=_('Linked ticket'),
)
linked_giftcard = models.ForeignKey(
GiftCard,
+2 -2
View File
@@ -590,7 +590,7 @@ class Order(LockModel, LoggedModel):
not kwargs.get('force_save_with_deferred_fields', None) and
(not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields))
):
_fail("It is unsafe to call save() on an Order with deferred fields since we can't check if you missed "
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.")
@@ -2841,7 +2841,7 @@ class OrderPosition(AbstractPosition):
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
elif not kwargs.get('force_save_with_deferred_fields', None):
_fail("It is unsafe to call save() on an OrderPosition with deferred fields since we can't check if you missed "
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.")
+1 -8
View File
@@ -319,9 +319,6 @@ class TeamQuerySet(models.QuerySet):
def event_permission_q(cls, perm_name):
from ..permissions import assert_valid_event_permission
if perm_name is None:
return Q()
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_EVENT_COMPAT: # legacy
return reduce(operator.and_, [cls.event_permission_q(p) for p in OLD_TO_NEW_EVENT_COMPAT[perm_name]])
assert_valid_event_permission(perm_name, allow_legacy=False)
@@ -334,9 +331,6 @@ class TeamQuerySet(models.QuerySet):
def organizer_permission_q(cls, perm_name):
from ..permissions import assert_valid_organizer_permission
if perm_name is None:
return Q()
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_ORGANIZER_COMPAT: # legacy
return reduce(operator.and_, [cls.organizer_permission_q(p) for p in OLD_TO_NEW_ORGANIZER_COMPAT[perm_name]])
assert_valid_organizer_permission(perm_name, allow_legacy=False)
@@ -556,7 +550,7 @@ class TeamAPIToken(models.Model):
"""
return self.team.organizer_permission_set() if self.team.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
"""
Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``.
@@ -565,7 +559,6 @@ class TeamAPIToken(models.Model):
:param event: The event to check
:param perm_name: The permission, e.g. ``event.orders:read``
:param request: This parameter is ignored and only defined for compatibility reasons.
:param session_key: This parameter is ignored and only defined for compatibility reasons.
:return: bool
"""
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
+16 -33
View File
@@ -54,7 +54,7 @@ from bidi import get_display
from django.conf import settings
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db.models import Exists, Max, Min, OuterRef
from django.db.models import Max, Min
from django.db.models.fields.files import FieldFile
from django.dispatch import receiver
from django.utils.deconstruct import deconstructible
@@ -76,7 +76,7 @@ from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph
from pretix.base.i18n import language
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question
from pretix.base.models import Event, Order, OrderPosition, Question
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter
@@ -379,13 +379,6 @@ DEFAULT_VARIABLES = OrderedDict((
str(p) for p in generate_compressed_addon_list(op, order, ev)
])
}),
("checked_in_addons", {
"label": _("List of Checked-In Add-Ons"),
"editor_sample": _("Add-on 1\n2x Add-on 2"),
"evaluate": lambda op, order, ev: "\n".join([
str(p) for p in generate_compressed_addon_list(op, order, ev, only_checked_in=True)
])
}),
("organizer", {
"label": _("Organizer name"),
"editor_sample": _("Event organizer company"),
@@ -498,9 +491,9 @@ DEFAULT_VARIABLES = OrderedDict((
) if op.valid_until else ""
}),
("program_times", {
"label": _("Program times"),
"label": _("Program times: date and time"),
"editor_sample": _(
"2017-05-31 10:00 12:00, Room 1\n2017-05-31 14:00 16:00, Room 2\n2017-05-31 14:00 2017-06-01 14:00, Building A"),
"2017-05-31 10:00 12:00\n2017-05-31 14:00 16:00\n2017-05-31 14:00 2017-06-01 14:00"),
"evaluate": lambda op, order, ev: get_program_times(op, ev)
}),
("medium_identifier", {
@@ -748,31 +741,21 @@ def get_seat(op: OrderPosition):
def get_program_times(op: OrderPosition, ev: Event):
ptstr = []
for pt in op.item.program_times.all():
ptstr.append([
datetimerange(
pt.start.astimezone(ev.timezone),
pt.end.astimezone(ev.timezone),
as_html=False
),
(', ' + ', '.join(
l.strip() for l in str(pt.location).splitlines() if l.strip())
) if str(pt.location).strip() else ''
])
return '\n'.join(''.join(l) for l in ptstr)
return '\n'.join([
datetimerange(
pt.start.astimezone(ev.timezone),
pt.end.astimezone(ev.timezone),
as_html=False
) for pt in op.item.program_times.all()
])
def generate_compressed_addon_list(op, order, event, only_checked_in=False):
def generate_compressed_addon_list(op, order, event):
itemcount = defaultdict(int)
addon_qs = (
addons = [p for p in (
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
else op.addons.select_related('item', 'variation')
)
if only_checked_in:
addon_qs = addon_qs.filter(Exists(Checkin.objects.filter(position=OuterRef('pk'))), canceled=False)
addons = [p for p in addon_qs if not p.canceled]
) if not p.canceled]
for pos in addons:
itemcount[pos.item, pos.variation] += 1
@@ -929,7 +912,7 @@ class Renderer:
# We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this
# 1:1 on other platforms that render PDFs through our API (libpretixprint)
return re.sub(r'\{([-a-zA-Z0-9:_]+)\}', replace, text)
return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text)
elif o['content'].startswith('itemmeta:'):
if op.variation_id:
@@ -1062,7 +1045,7 @@ class Renderer:
except:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
p = Paragraph(text, style=style)
p = Paragraph(text, style=style) # not using AutoEscapeParagraph is safe as we escape above
return p, ad, lineheight
def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
+24 -29
View File
@@ -49,39 +49,14 @@ class PluginType(Enum):
EXPORT = 4
def plugin_is_available(meta, event=None, organizer=None):
if not hasattr(meta.app, 'is_available'):
return True
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if event:
return meta.app.is_available(event)
elif organizer:
if not hasattr(organizer, '_plugin_availability_fallback_event'):
with scope(organizer=organizer):
setattr(organizer, '_plugin_availability_fallback_event', organizer.events.first())
return (
organizer._plugin_availability_fallback_event
and meta.app.is_available(organizer._plugin_availability_fallback_event)
)
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer:
return meta.app.is_available(organizer)
elif event:
return meta.app.is_available(event.organizer)
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer):
return meta.app.is_available(event or organizer)
return True
def get_all_plugins(*, event=None, organizer=None) -> List[type]:
"""
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
"""
assert not event or not organizer
plugins = []
event_fallback = None
event_fallback_used = False
for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'):
meta = app.PretixPluginMeta
@@ -90,8 +65,28 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]:
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue
if not plugin_is_available(meta, event, organizer):
continue
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if event and hasattr(app, 'is_available'):
if not app.is_available(event):
continue
elif organizer and hasattr(app, 'is_available'):
if not event_fallback_used:
with scope(organizer=organizer):
event_fallback = organizer.events.first()
event_fallback_used = True
if not event_fallback or not app.is_available(event_fallback):
continue
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer and hasattr(app, 'is_available'):
if not app.is_available(organizer):
continue
elif event and hasattr(app, 'is_available'):
if not app.is_available(event.organizer):
continue
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer) and hasattr(app, 'is_available'):
if not app.is_available(event or organizer):
continue
plugins.append(meta)
return sorted(
+2 -2
View File
@@ -162,12 +162,12 @@ error_messages = {
'price_too_high': gettext_lazy('The entered price is to high.'),
'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'),
'voucher_min_usages': ngettext_lazy(
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product.',
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
'number'
),
'voucher_min_usages_removed': ngettext_lazy(
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product. '
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
'We have therefore removed some positions from your cart that can no longer be purchased like this.',
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
'We have therefore removed some positions from your cart that can no longer be purchased like this.',
-28
View File
@@ -38,7 +38,6 @@ SOURCE_NAMES = {
None: _('European Central Bank'), # backwards-compatibility
'eu:ecb:eurofxref-daily': _('European Central Bank'),
'cz:cnb:rate-fixing-daily': _('Czech National Bank'),
'pl:nbp:table-a': _('National Bank of Poland'),
}
@@ -50,7 +49,6 @@ def fetch_rates(sender, **kwargs):
source_tasks = {
'eu:ecb:eurofxref-daily': fetch_ecb_rates,
'cz:cnb:rate-fixing-daily': fetch_cnb_cz_rates,
'pl:nbp:table-a': fetch_nbp_pl_rates,
}
for source_name, task in source_tasks.items():
@@ -146,29 +144,3 @@ def fetch_cnb_cz_rates():
rate=rate,
)
)
@app.task()
def fetch_nbp_pl_rates():
"""
Fetches currency rates from the Polish National Bank.
"""
r = requests.get("https://api.nbp.pl/api/exchangerates/tables/A/", headers={
"Accept": "application/json",
})
r.raise_for_status()
data = r.json()[0]
source_date = datetime.strptime(data["effectiveDate"], "%Y-%m-%d").date()
for r in data["rates"]:
rate = Decimal(r["mid"]).quantize(Decimal('0.000001'))
ExchangeRate.objects.update_or_create(
source='pl:nbp:table-a',
source_currency=r["code"],
other_currency='PLN',
defaults=dict(
source_date=source_date,
rate=rate,
)
)
+3 -19
View File
@@ -40,7 +40,6 @@ from pretix.base.models import (
CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken,
User, cachedfile_name,
)
from pretix.base.models.auth import UserWithStaffSession
from pretix.base.models.exports import ScheduledOrganizerExport
from pretix.base.services.mail import mail
from pretix.base.services.tasks import (
@@ -212,12 +211,7 @@ def init_event_exporters(event, user=None, token=None, device=None, request=None
if not perm_holder.has_event_permission(event.organizer, event, permission_name, request) and not staff_session:
continue
exporter: BaseExporter = response(
event=event,
organizer=event.organizer,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**kwargs
)
exporter: BaseExporter = response(event=event, organizer=event.organizer, **kwargs)
if not exporter.available_for_user(user if user and user.is_authenticated else None):
continue
@@ -249,12 +243,7 @@ def init_organizer_exporters(
continue
if issubclass(response, OrganizerLevelExportMixin):
exporter: BaseExporter = response(
event=Event.objects.none(),
organizer=organizer,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**kwargs,
)
exporter: BaseExporter = response(event=Event.objects.none(), organizer=organizer, **kwargs)
try:
if not perm_holder.has_organizer_permission(organizer, response.get_required_organizer_permission(), request) and not staff_session:
@@ -306,12 +295,7 @@ def init_organizer_exporters(
if not _has_permission_on_any_team_cache[permission_name] and not staff_session:
continue
exporter: BaseExporter = response(
event=_event_list_cache[permission_name],
organizer=organizer,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**kwargs,
)
exporter: BaseExporter = response(event=_event_list_cache[permission_name], organizer=organizer, **kwargs)
if not exporter.available_for_user(user if user and user.is_authenticated else None):
continue
+1 -15
View File
@@ -58,7 +58,6 @@ from pretix.base.invoicing.transmission import (
from pretix.base.models import (
ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
)
from pretix.base.models.orders import OrderPayment
from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.tasks import (
TransactionAwareProfiledEventTask, TransactionAwareTask,
@@ -103,7 +102,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
if lp and lp.payment_provider and lp.state not in (OrderPayment.PAYMENT_STATE_FAILED, OrderPayment.PAYMENT_STATE_CANCELED):
if lp and lp.payment_provider:
if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters:
payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp))
else:
@@ -205,19 +204,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
invoice.foreign_currency_rate_date = rate.source_date
invoice.foreign_currency_source = 'cz:cnb:rate-fixing-daily'
elif invoice.event.settings.invoice_eu_currencies == 'PLN' and invoice.event.currency != 'PLN':
invoice.foreign_currency_display = 'PLN'
if settings.FETCH_ECB_RATES:
rate = ExchangeRate.objects.filter(
source='pl:nbp:table-a',
source_currency=invoice.event.currency,
other_currency=invoice.foreign_currency_display,
source_date__gt=now().date() - timedelta(days=7)
).first()
if rate:
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
invoice.foreign_currency_rate_date = rate.source_date
invoice.foreign_currency_source = 'pl:nbp:table-a'
except InvoiceAddress.DoesNotExist:
ia = None
+51 -71
View File
@@ -67,9 +67,9 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry,
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
SeatCategoryMapping, User, Voucher,
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
@@ -727,6 +727,8 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
_check_date(event, time_machine_now_dt)
products_seen = Counter()
q_avail = Counter()
v_avail = Counter()
v_usages = Counter()
v_budget = {}
deleted_positions = set()
@@ -791,9 +793,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
shared_lock_objects=[event]
)
q_avail = Counter()
v_avail = Counter()
# Check maximum order size
limit = min(int(event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE)
if sum(1 for cp in sorted_positions if not cp.addon_to) > limit:
@@ -1619,7 +1618,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until', 'is_bundled', 'result', 'count'))
'valid_from', 'valid_until', 'is_bundled', 'result'))
SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1633,24 +1632,16 @@ class OrderChangeManager:
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult:
_positions: Optional[List[OrderPosition]]
_position: Optional[OrderPosition]
def __init__(self):
self._positions = None
self._position = None
@property
def position(self) -> OrderPosition:
if self._positions is None:
if self._position is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
if len(self._positions) != 1:
raise RuntimeError("More than one position created.")
return self._positions[0]
@property
def positions(self) -> List[OrderPosition]:
if self._positions is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
return self._positions
return self._position
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order
@@ -1857,12 +1848,8 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult':
if count < 1:
raise ValueError("Count must be positive")
valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult':
if isinstance(seat, str):
if count > 1:
raise ValueError("Cannot combine count > 1 with seat")
if not seat:
seat = None
else:
@@ -1916,14 +1903,14 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff_guesstimate += price.gross * count
self._quotadiff.update({q: count for q in new_quotas})
self._totaldiff_guesstimate += price.gross
self._quotadiff.update(new_quotas)
if seat:
self._seatdiff.update([seat])
result = self.AddPositionResult()
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until, is_bundled, result, count))
valid_from, valid_until, is_bundled, result))
return result
def split(self, position: OrderPosition):
@@ -2543,35 +2530,29 @@ class OrderChangeManager:
secret_dirty.remove(position)
position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation):
new_pos = []
new_logs = []
for i in range(op.count):
pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
is_bundled=op.is_bundled,
)
nextposid += 1
new_pos.append(pos)
new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'position': pos.pk,
'item': op.item.pk,
'variation': op.variation.pk if op.variation else None,
'addon_to': op.addon_to.pk if op.addon_to else None,
'price': op.price.gross,
'positionid': pos.positionid,
'membership': pos.used_membership_id,
'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
}, save=False))
op.result._positions = new_pos
LogEntry.bulk_create_and_postprocess(new_logs)
pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
is_bundled=op.is_bundled,
)
nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'position': pos.pk,
'item': op.item.pk,
'variation': op.variation.pk if op.variation else None,
'addon_to': op.addon_to.pk if op.addon_to else None,
'price': op.price.gross,
'positionid': pos.positionid,
'membership': pos.used_membership_id,
'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
})
op.result._position = pos
elif isinstance(op, self.SplitOperation):
position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position)
@@ -2896,7 +2877,7 @@ class OrderChangeManager:
return total
def _check_order_size(self):
if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
raise OrderError(
self.error_messages['max_order_size'] % {
'max': settings.PRETIX_MAX_ORDER_SIZE,
@@ -2957,7 +2938,7 @@ class OrderChangeManager:
]) + len([
o for o in self._operations if isinstance(o, self.SplitOperation)
])
adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)])
adds = len([o for o in self._operations if isinstance(o, self.AddOperation)])
if current > 0 and current - cancels + adds < 1:
raise OrderError(self.error_messages['complete_cancel'])
@@ -3004,18 +2985,17 @@ class OrderChangeManager:
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation):
for i in range(op.count):
cp = CartPosition(
event=self.event,
item=op.item,
variation=op.variation,
used_membership=op.membership,
subevent=op.subevent,
seat=op.seat,
)
cp.override_valid_from = op.valid_from
cp.override_valid_until = op.valid_until
fake_cart.append(cp)
cp = CartPosition(
event=self.event,
item=op.item,
variation=op.variation,
used_membership=op.membership,
subevent=op.subevent,
seat=op.seat,
)
cp.override_valid_from = op.valid_from
cp.override_valid_until = op.valid_until
fake_cart.append(cp)
try:
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
except ValidationError as e:
@@ -3515,8 +3495,8 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
identifier=mt.generate_identifier(sender.organizer),
active=True,
customer=order.customer,
linked_orderposition=p,
)
rm.linked_orderpositions.add(p)
rm.log_action(
'pretix.reusable_medium.created',
data={
File diff suppressed because one or more lines are too long
@@ -55,12 +55,10 @@
{% trans "You receive these emails based on your notification settings." %}<br>
<a href="{{ settings_url }}">
{% trans "Click here to view and change your notification settings" %}
</a><br>
<a href="{{ disable_url }}">
{% trans "Click here disable all notifications immediately." %}
</a>
{% if disable_url %}<br>
<a href="{{ disable_url }}">
{% trans "Click here disable all notifications immediately." %}
</a>
{% endif %}
</div>
<!--[if gte mso 9]>
</td></tr></table>
@@ -14,6 +14,5 @@
{% trans "You receive these emails based on your notification settings." %}
{% trans "Click here to view and change your notification settings:" %}
{{ settings_url }}
{% if disable_url %}{% trans "Click here disable all notifications immediately:" %}
{% trans "Click here disable all notifications immediately:" %}
{{ disable_url }}
{% endif %}
@@ -0,0 +1,28 @@
{% extends "error.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load static %}
{% block content %}
<h1>{% trans "Please continue in a new tab" %}</h1>
<p class="larger">
{% blocktrans trimmed %}
For security reasons, the following step is only possible in a new tab.
{% endblocktrans %}
</p>
<p class="larger">
{% blocktrans trimmed %}
If the new tab did not open automatically, please click the following button:
{% endblocktrans %}
</p>
<div class="text-center">
<a href="{{ url }}"
class="btn btn-primary btn-lg" target="_blank">
<span class="fa fa-external-link-square"></span>
{% trans "Continue in new tab" %}
</a>
{{ url|json_script:"framebreak-url" }}
<script type="text/javascript" src="{% static "pretixbase/js/framebreak.js" %}"></script>
</div>
{% endblock %}
@@ -2,13 +2,14 @@
{% load i18n %}
{% load rich_text %}
{% load static %}
{% load wrap_in %}
{% block title %}{% trans "Redirect" %}{% endblock %}
{% block content %}
<i class="fa fa-link fa-fw big-icon"></i>
<div class="error-details">
<h1>{% trans "Redirect" %}</h1>
<h3>
{% blocktrans trimmed with host="<strong>"|add:hostname|add:"</strong>"|safe %}
{% blocktrans trimmed with host=hostname|wrap_in:'strong' %}
The link you clicked on wants to redirect you to a destination on the website {{ host }}.
{% endblocktrans %}
{% blocktrans trimmed %}
+2 -4
View File
@@ -42,8 +42,6 @@ from bleach import DEFAULT_CALLBACKS, html5lib_shim
from bleach.linkifier import build_email_re
from django import template
from django.conf import settings
from django.core import signing
from django.urls import reverse
from django.utils.functional import SimpleLazyObject
from django.utils.html import escape
from django.utils.http import url_has_allowed_host_and_scheme
@@ -54,6 +52,7 @@ from markdown.postprocessors import Postprocessor
from markdown.treeprocessors import UnescapeTreeprocessor
from tlds import tld_set
from pretix.base.views.redirect import safelink
from pretix.helpers.format import SafeFormatter, format_map
register = template.Library()
@@ -158,8 +157,7 @@ def safelink_callback(attrs, new=False):
"""
url = html.unescape(attrs.get((None, 'href'), '/'))
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
signer = signing.Signer(salt='safe-redirect')
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
attrs[None, 'href'] = safelink(url)
attrs[None, 'target'] = '_blank'
attrs[None, 'rel'] = 'noopener'
return attrs
-243
View File
@@ -1,243 +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 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>'
+1 -30
View File
@@ -24,12 +24,10 @@ import calendar
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator, validate_email
from django.core.validators import validate_email
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _
from pretix.base.templatetags.rich_text import URL_RE
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
@@ -115,33 +113,6 @@ def multimail_validate(val):
return s
class RegexValidatorInverseMatchAndParam(RegexValidator):
inverse_match = True
def __call__(self, value):
regex_matches = self.regex.search(str(value))
if regex_matches:
raise ValidationError(
self.message,
code=self.code,
params={
"value": value,
"match": regex_matches.group(0) if regex_matches else "",
}
)
class NoUrlValidator(RegexValidatorInverseMatchAndParam):
regex = URL_RE
def __init__(self, **kwargs):
if not kwargs.get("message"):
kwargs["message"] = _('You entered an URL, which is not allowed. Please remove %(match)s from your input.')
if not kwargs.get("code"):
kwargs["code"] = "contains_url"
super().__init__(**kwargs)
class RRuleValidator:
def __init__(self, enforce_simple=False):
self.enforce_simple = enforce_simple
+29 -6
View File
@@ -19,6 +19,7 @@
# 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
import urllib.parse
from django.core import signing
@@ -26,6 +27,8 @@ from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
logger = logging.getLogger(__name__)
def _is_samesite_referer(request):
referer = request.headers.get('referer')
@@ -42,11 +45,16 @@ def _is_samesite_referer(request):
def redir_view(request):
signer = signing.Signer(salt='safe-redirect')
framebreak = "framebreak" in request.GET
salt = 'framebreak-safelink-url' if framebreak else 'safelink-url'
try:
url = signer.unsign(request.GET.get('url', ''))
url = signing.Signer(salt=salt).unsign(request.GET.get('url', ''))
except signing.BadSignature:
return HttpResponseBadRequest('Invalid parameter')
try:
# Backwards-compatibility for a change in 2026-06, remove after a while
url = signing.Signer(salt='safe-redirect').unsign(request.GET.get('url', ''))
except signing.BadSignature:
return HttpResponseBadRequest('Invalid parameter')
if not _is_samesite_referer(request):
u = urllib.parse.urlparse(url)
@@ -55,11 +63,26 @@ def redir_view(request):
'url': url,
})
if framebreak:
r = render(request, 'pretixbase/framebreak.html', {
'url': url,
})
r.xframe_options_exempt = True
return r
r = HttpResponseRedirect(url)
r['X-Robots-Tag'] = 'noindex'
return r
def safelink(url):
signer = signing.Signer(salt='safe-redirect')
return reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
def safelink(url, framebreak=False):
url = str(url)
if not (url.startswith('https://') or url.startswith('http://') or url.startswith("/")):
logger.warning('Invalid URL passed to safelink: %r', url)
return '#invalid-url'
salt = 'framebreak-safelink-url' if framebreak else 'safelink-url'
signer = signing.Signer(salt=salt)
u = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
if framebreak:
u += "&framebreak=true"
return u
-4
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>.
#
import logging
import re
from collections import defaultdict
from datetime import timedelta
from importlib import import_module
@@ -53,7 +52,6 @@ from pretix.celery_app import app
from pretix.helpers.http import redirect_to_url
logger = logging.getLogger('pretix.base.tasks')
RE_ASYNC_ID = re.compile(r"^[a-zA-Z0-9\-]+$")
class AsyncMixin:
@@ -135,8 +133,6 @@ class AsyncMixin:
def get_result(self, request):
if not request.GET.get('async_id'):
raise BadRequest("No async_id given")
if not RE_ASYNC_ID.match(request.GET.get('async_id')):
raise BadRequest("Invalid async_id given")
res = AsyncResult(request.GET.get('async_id'))
if 'ajax' in self.request.GET:
return JsonResponse(self._return_ajax_result(res, timeout=0.25))
-28
View File
@@ -461,31 +461,3 @@ class SalesChannelCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
**super().create_option(name, value, label, selected, index, subindex, attrs),
"plugin_missing": plugin and plugin not in self.event.get_plugins(),
}
class ModelChoiceIteratorWithNone(forms.models.ModelChoiceIterator):
# see django.forms.models.ModelChoiceIterator for original implementation
def __iter__(self):
if self.field.empty_label is not None:
yield ("", self.field.empty_label)
if self.field.none_label is not None:
yield ("_none", self.field.none_label)
queryset = self.queryset
# Can't use iterator() when queryset uses prefetch_related()
if not queryset._prefetch_related_lookups:
queryset = queryset.iterator()
for obj in queryset:
yield self.choice(obj)
class ModelChoiceFieldWithNone(forms.ModelChoiceField):
iterator = ModelChoiceIteratorWithNone
def __init__(self, *args, **kwargs):
self.none_label = kwargs.pop("none_label", None)
super().__init__(*args, **kwargs)
def to_python(self, value):
if value == "_none":
return value
return super().to_python(value)
+1 -128
View File
@@ -1528,133 +1528,6 @@ class SubEventFilterForm(FilterForm):
return self.event.organizer.meta_properties.filter(filter_allowed=True)
class QuotaFilterForm(FilterForm):
orders = {
'-date': ('-subevent__date_from', 'name', 'pk'),
'date': ('subevent__date_from', '-name', '-pk'),
'size': ('size', 'name', 'pk'),
'-size': ('-size', '-name', '-pk'),
'name': ('name', 'pk'),
'-name': ('-name', '-pk'),
}
subevent = forms.ModelChoiceField(
label=pgettext_lazy('subevent', 'Date'),
queryset=SubEvent.objects.none(),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates')
)
date_from = forms.DateField(
label=_('Date from'),
required=False,
widget=DatePickerWidget({
'placeholder': _('Date from'),
}),
)
date_until = forms.DateField(
label=_('Date until'),
required=False,
widget=DatePickerWidget({
'placeholder': _('Date until'),
}),
)
time_from = forms.TimeField(
label=_('Start time from'),
required=False,
widget=TimePickerWidget({}),
)
time_until = forms.TimeField(
label=_('Start time until'),
required=False,
widget=TimePickerWidget({}),
)
weekday = forms.MultipleChoiceField(
label=_('Weekday'),
choices=(
('2', _('Monday')),
('3', _('Tuesday')),
('4', _('Wednesday')),
('5', _('Thursday')),
('6', _('Friday')),
('7', _('Saturday')),
('1', _('Sunday')),
),
widget=forms.CheckboxSelectMultiple,
required=False
)
query = forms.CharField(
label=_('Quota name'),
widget=forms.TextInput(),
required=False
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
if self.event.has_subevents:
self.fields['date_from'].widget = DatePickerWidget()
self.fields['date_until'].widget = DatePickerWidget()
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'All dates')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
else:
del self.fields['subevent']
del self.fields['date_from']
del self.fields['date_until']
del self.fields['time_from']
del self.fields['time_until']
del self.fields['weekday']
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('weekday'):
qs = qs.annotate(wday=ExtractWeekDay('subevent__date_from')).filter(wday__in=fdata.get('weekday'))
if fdata.get('subevent'):
qs = qs.filter(subevent=fdata["subevent"])
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(name__icontains=query)
if fdata.get('date_until'):
date_end = make_aware(datetime.combine(
fdata.get('date_until') + timedelta(days=1),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(
Q(subevent__date_to__isnull=True, subevent__date_from__lt=date_end) |
Q(subevent__date_to__isnull=False, subevent__date_to__lt=date_end)
)
if fdata.get('date_from'):
date_start = make_aware(datetime.combine(
fdata.get('date_from'),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(subevent__date_from__gte=date_start)
if fdata.get('time_until'):
qs = qs.filter(subevent__date_from__time__lte=fdata.get('time_until'))
if fdata.get('time_from'):
qs = qs.filter(subevent__date_from__time__gte=fdata.get('time_from'))
if fdata.get('ordering'):
qs = qs.order_by(*get_deterministic_ordering(Quota, self.get_order_by()))
else:
qs = qs.order_by('-subevent__date_from', 'name', 'pk')
return qs
class OrganizerFilterForm(FilterForm):
orders = {
'slug': 'slug',
@@ -1871,7 +1744,7 @@ class ReusableMediaFilterForm(FilterForm):
Q(identifier__icontains=query)
| Q(customer__identifier__icontains=query)
| Q(customer__external_identifier__istartswith=query)
| Q(linked_orderpositions__order__code__icontains=query)
| Q(linked_orderposition__order__code__icontains=query)
| Q(linked_giftcard__secret__icontains=query)
)
@@ -104,13 +104,6 @@ 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,
# Not translated on purpose, this is a temporary feature and contains too many special case words
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)
+1 -61
View File
@@ -43,7 +43,6 @@ from django.core.exceptions import ValidationError
from django.db.models import Max, Q
from django.forms import ChoiceField, RadioSelect
from django.forms.formsets import DELETION_FIELD_NAME
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape, format_html
@@ -376,60 +375,6 @@ class QuotaForm(I18nModelForm):
return inst
class QuotaBulkEditForm(QuotaForm):
def __init__(self, *args, **kwargs):
self.mixed_values = kwargs.pop('mixed_values')
self.queryset = kwargs.pop('queryset')
super().__init__(**kwargs)
self.fields.pop("subevent", None) # Would add extra complexity and it's hard to imagine a use case for that
self.fields["name"].required = False
self.fields["itemvars"].required = False
def clean(self):
d = super().clean()
if self.prefix + "name" in self.data.getlist('_bulk') and not d.get("name"):
raise ValidationError({"name": _("This field is required.")})
if self.prefix + "itemvars" in self.data.getlist('_bulk') and not d.get("itemvars"):
raise ValidationError({"itemvars": _("This field is required.")})
return d
def save(self, commit=True):
objs = list(self.queryset)
fields = set()
for k in self.fields:
cb_val = self.prefix + k
if cb_val not in self.data.getlist('_bulk'):
continue
fields.add(k)
if k == 'itemvars':
selected_items = set(list(self.event.items.filter(id__in=[
i.split('-')[0] for i in self.cleaned_data['itemvars']
])))
selected_variations = list(ItemVariation.objects.filter(item__event=self.event, id__in=[
i.split('-')[1] for i in self.cleaned_data['itemvars'] if '-' in i
]))
for obj in objs:
obj.items.set(selected_items)
obj.variations.set(selected_variations)
else:
for obj in objs:
setattr(obj, k, self.cleaned_data[k])
fields = [f for f in fields if f != 'itemvars']
if fields:
Quota.objects.bulk_update(objs, fields, 200)
def full_clean(self):
if len(self.data) == 0:
# form wasn't submitted
self._errors = ErrorDict()
return
super().full_clean()
class ItemCreateForm(I18nModelForm):
NONE = 'none'
EXISTING = 'existing'
@@ -629,7 +574,7 @@ class ItemCreateForm(I18nModelForm):
instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation,
count=b.count, designated_price=b.designated_price)
for pt in self.cleaned_data['copy_from'].program_times.all():
instance.program_times.create(start=pt.start, end=pt.end, location=pt.location)
instance.program_times.create(start=pt.start, end=pt.end)
item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance)
@@ -1409,10 +1354,6 @@ class ItemProgramTimeForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['end'].widget.attrs['data-date-after'] = '#id_{prefix}-start_0'.format(prefix=self.prefix)
self.fields['location'].widget.attrs['rows'] = '3'
self.fields['location'].widget.attrs['placeholder'] = _(
'Sample Conference Center, Heidelberg, Germany'
)
class Meta:
model = ItemProgramTime
@@ -1420,7 +1361,6 @@ class ItemProgramTimeForm(I18nModelForm):
fields = [
'start',
'end',
'location'
]
field_classes = {
'start': forms.SplitDateTimeField,
-8
View File
@@ -331,10 +331,6 @@ class OtherOperationsForm(forms.Form):
class OrderPositionAddForm(forms.Form):
count = forms.IntegerField(
label=_('Number of products to add'),
initial=1,
)
itemvar = forms.ChoiceField(
label=_('Product')
)
@@ -436,10 +432,6 @@ class OrderPositionAddForm(forms.Form):
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
else:
d['used_membership'] = None
if d.get("count", 1) > 1 and d.get("seat"):
raise ValidationError({
"seat": _("You can not choose a seat when adding multiple products at once.")
})
return d
+9 -18
View File
@@ -86,7 +86,7 @@ from pretix.control.forms import ExtFileField, SplitDateTimeField
from pretix.control.forms.event import (
SafeEventMultipleChoiceField, multimail_validate,
)
from pretix.control.forms.widgets import Select2, Select2Multiple
from pretix.control.forms.widgets import Select2
from pretix.multidomain.models import KnownDomain
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -249,15 +249,6 @@ class SafeOrderPositionChoiceField(forms.ModelChoiceField):
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
class SafeOrderPositionMultipleChoiceField(forms.ModelMultipleChoiceField):
def __init__(self, queryset, **kwargs):
queryset = queryset.model.all.none()
super().__init__(queryset, **kwargs)
def label_from_instance(self, op):
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
class EventMetaPropertyForm(I18nModelForm):
class Meta:
model = EventMetaProperty
@@ -972,12 +963,12 @@ class ReusableMediumUpdateForm(forms.ModelForm):
class Meta:
model = ReusableMedium
fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderpositions', 'notes']
fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderposition', 'notes']
field_classes = {
'expires': SplitDateTimeField,
'customer': SafeModelChoiceField,
'linked_giftcard': SafeModelChoiceField,
'linked_orderpositions': SafeOrderPositionMultipleChoiceField,
'linked_orderposition': SafeOrderPositionChoiceField,
}
widgets = {
'expires': SplitDateTimePickerWidget,
@@ -987,8 +978,8 @@ class ReusableMediumUpdateForm(forms.ModelForm):
super().__init__(*args, **kwargs)
organizer = self.instance.organizer
self.fields['linked_orderpositions'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all()
self.fields['linked_orderpositions'].widget = Select2Multiple(
self.fields['linked_orderposition'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all()
self.fields['linked_orderposition'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
@@ -996,8 +987,8 @@ class ReusableMediumUpdateForm(forms.ModelForm):
}),
}
)
self.fields['linked_orderpositions'].widget.choices = self.fields['linked_orderpositions'].choices
self.fields['linked_orderpositions'].required = False
self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices
self.fields['linked_orderposition'].required = False
self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all()
self.fields['linked_giftcard'].widget = Select2(
@@ -1051,12 +1042,12 @@ class ReusableMediumCreateForm(ReusableMediumUpdateForm):
class Meta:
model = ReusableMedium
fields = ['active', 'type', 'identifier', 'expires', 'linked_orderpositions', 'linked_giftcard', 'customer', 'notes']
fields = ['active', 'type', 'identifier', 'expires', 'linked_orderposition', 'linked_giftcard', 'customer', 'notes']
field_classes = {
'expires': SplitDateTimeField,
'customer': SafeModelChoiceField,
'linked_giftcard': SafeModelChoiceField,
'linked_orderpositions': SafeOrderPositionMultipleChoiceField,
'linked_orderposition': SafeOrderPositionChoiceField,
}
widgets = {
'expires': SplitDateTimePickerWidget,
+1 -11
View File
@@ -28,7 +28,7 @@ from django.forms import formset_factory
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django.utils.translation import gettext_lazy as _
from i18nfield.forms import I18nInlineFormSet
from pretix.base.forms import I18nModelForm
@@ -102,16 +102,6 @@ class SubEventBulkForm(SubEventForm):
required=False,
limit_choices=('date_from', 'date_to'),
)
skip_if_overlap = forms.BooleanField(
label=pgettext_lazy('subevent', 'Skip dates that overlap with any existing date'),
help_text=pgettext_lazy(
'subevent',
'This can be useful if all your dates happen in the same location and no repeated dates should '
'be created in conflict with existing special events. This respects even inactive dates and works best if '
'all dates have both a start and end time.'
),
required=False,
)
def __init__(self, *args, **kwargs):
self.event = kwargs['event']
+11 -24
View File
@@ -29,30 +29,17 @@ class Select2Mixin:
super().__init__(*args, **kwargs)
def options(self, name, value, attrs=None):
if not value or not value[0]:
return
has_none = "_none" in value
if has_none:
value = [v for v in value if v != "_none"]
yield self.create_option(
None,
"_none",
self.choices.field.none_label,
True,
0,
subindex=None,
attrs=attrs
)
for i, selected in enumerate(self.choices.queryset.filter(pk__in=value)):
yield self.create_option(
None,
self.choices.field.prepare_value(selected),
self.choices.field.label_from_instance(selected),
True,
i + (1 if has_none else 0),
subindex=None,
attrs=attrs
)
if value and value[0]:
for i, selected in enumerate(self.choices.queryset.filter(pk__in=value)):
yield self.create_option(
None,
self.choices.field.prepare_value(selected),
self.choices.field.label_from_instance(selected),
True,
i,
subindex=None,
attrs=attrs
)
return
def optgroups(self, name, value, attrs=None):
+6 -8
View File
@@ -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(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
value=date_format(dateutil.parser.parse(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(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else ''
value=date_format(dateutil.parser.parse(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 = datetime.fromisoformat(data.get('datetime'))
dt = dateutil.parser.parse(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(
datetime.fromisoformat(data["datetime"]).astimezone(logentry.event.timezone),
dateutil.parser.parse(data["datetime"]).astimezone(logentry.event.timezone),
"SHORT_DATETIME_FORMAT"
) if logentry.event else data["datetime"],
type=dict(PrintLog.PRINT_TYPES)[data["type"]],
@@ -743,8 +743,6 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.reusable_medium.created': _('The reusable medium has been created.'),
'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'),
'pretix.reusable_medium.changed': _('The reusable medium has been changed.'),
'pretix.reusable_medium.linked_orderposition.added': _('A new ticket has been added to the medium.'),
'pretix.reusable_medium.linked_orderposition.removed': _('A ticket has been removed from the medium.'),
'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'),
'pretix.reusable_medium.linked_giftcard.changed': _('The medium has been connected to a new gift card.'),
'pretix.email.error': _('Sending of an email has failed.'),
@@ -987,7 +985,7 @@ class LegacyCheckinLogEntryType(OrderLogEntryType):
def display(self, logentry, data):
# deprecated
dt = datetime.fromisoformat(data.get('datetime'))
dt = dateutil.parser.parse(data.get('datetime'))
tz = logentry.event.timezone
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
if 'list' in data:
+1 -1
View File
@@ -212,7 +212,7 @@ class AuditLogMiddleware:
if request.path.startswith(get_script_prefix() + 'control') and request.user.is_authenticated:
if getattr(request.user, "is_hijacked", False):
hijack_history = request.session.get('hijack_history', False)
hijacker = get_object_or_404(User, pk=hijack_history[0])
hijacker = get_object_or_404(User, pk=hijack_history[0]["user"])
ss = hijacker.get_active_staff_session(request.session.get('hijacker_session'))
if ss:
ss.logs.create(
-20
View File
@@ -213,16 +213,6 @@ quota as argument in the ``quota`` keyword argument.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
subevent_detail_html = EventPluginSignal()
"""
Arguments: 'subevent'
This signal allows you to append HTML to a SubEvent's detail view. You receive the
subevent as argument in the ``subevent`` keyword argument.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
organizer_edit_tabs = DeprecatedSignal()
"""
Arguments: 'organizer', 'request'
@@ -271,16 +261,6 @@ As with all event plugin signals, the ``sender`` keyword argument will contain t
Additionally, the argument ``order`` and ``request`` are available.
"""
order_approve_info = EventPluginSignal()
"""
Arguments: ``order``, ``request``
This signal is sent out to display additional information on the order approve page
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available.
"""
order_position_buttons = EventPluginSignal()
"""
Arguments: ``order``, ``position``, ``request``
+23 -22
View File
@@ -38,6 +38,7 @@ from pretix import __version__
from pretix.base.models import Order, OrderPayment, Transaction
from pretix.base.plugins import get_all_plugins
from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import PlainTextParagraph
from pretix.plugins.reports.exporters import ReportlabExportMixin
from pretix.settings import DATA_DIR
@@ -79,23 +80,23 @@ class SysReport(ReportlabExportMixin):
style_small.fontSize = 6
story = [
Paragraph("System report", headlinestyle),
PlainTextParagraph("System report", headlinestyle),
Spacer(1, 5 * mm),
Paragraph("Usage", subheadlinestyle),
PlainTextParagraph("Usage", subheadlinestyle),
Spacer(1, 5 * mm),
self._usage_table(),
Spacer(1, 5 * mm),
Paragraph("Installed versions", subheadlinestyle),
PlainTextParagraph("Installed versions", subheadlinestyle),
Spacer(1, 5 * mm),
self._tech_table(),
Spacer(1, 5 * mm),
Paragraph("Plugins", subheadlinestyle),
PlainTextParagraph("Plugins", subheadlinestyle),
Spacer(1, 5 * mm),
Paragraph(self._get_plugin_versions(), style_small),
PlainTextParagraph(self._get_plugin_versions(), style_small),
Spacer(1, 5 * mm),
Paragraph("Custom templates", subheadlinestyle),
PlainTextParagraph("Custom templates", subheadlinestyle),
Spacer(1, 5 * mm),
Paragraph(self._get_custom_templates(), style_small),
PlainTextParagraph(self._get_custom_templates(), style_small),
Spacer(1, 5 * mm),
]
@@ -121,13 +122,13 @@ class SysReport(ReportlabExportMixin):
("RIGHTPADDING", (-1, 0), (-1, -1), 0),
]
tdata = [
[Paragraph("Site URL:", style), Paragraph(settings.SITE_URL, style)],
[Paragraph("pretix version:", style), Paragraph(__version__, style)],
[Paragraph("Python version:", style), Paragraph(sys.version, style)],
[Paragraph("Platform:", style), Paragraph(platform.platform(), style)],
[PlainTextParagraph("Site URL:", style), Paragraph(settings.SITE_URL, style)],
[PlainTextParagraph("pretix version:", style), Paragraph(__version__, style)],
[PlainTextParagraph("Python version:", style), Paragraph(sys.version, style)],
[PlainTextParagraph("Platform:", style), Paragraph(platform.platform(), style)],
[
Paragraph("Database engine:", style),
Paragraph(settings.DATABASES["default"]["ENGINE"], style),
PlainTextParagraph("Database engine:", style),
PlainTextParagraph(settings.DATABASES["default"]["ENGINE"], style),
],
]
table = Table(tdata, colWidths=colwidths, repeatRows=0)
@@ -206,7 +207,7 @@ class SysReport(ReportlabExportMixin):
year_last = now().year
tdata = [
[
Paragraph(l, style_small_head)
PlainTextParagraph(l, style_small_head)
for l in (
"Time frame",
"Currency",
@@ -257,19 +258,19 @@ class SysReport(ReportlabExportMixin):
tdata.append(
(
Paragraph(
PlainTextParagraph(
date_format(first_day, "M Y")
+ " "
+ date_format(after_day - timedelta(days=1), "M Y"),
style_small,
),
Paragraph(c, style_small),
Paragraph(str(orders_count), style_small) if i == 0 else "",
Paragraph(money_filter(revenue_data.get("s_net") or 0, c), style_small),
Paragraph(str(testmode_count), style_small) if i == 0 else "",
Paragraph(str(unconfirmed_count), style_small) if i == 0 else "",
Paragraph(str(revenue_data.get("c") or 0), style_small),
Paragraph(money_filter(revenue_data.get("s_gross") or 0, c), style_small),
PlainTextParagraph(c, style_small),
PlainTextParagraph(str(orders_count), style_small) if i == 0 else "",
PlainTextParagraph(money_filter(revenue_data.get("s_net") or 0, c), style_small),
PlainTextParagraph(str(testmode_count), style_small) if i == 0 else "",
PlainTextParagraph(str(unconfirmed_count), style_small) if i == 0 else "",
PlainTextParagraph(str(revenue_data.get("c") or 0), style_small),
PlainTextParagraph(money_filter(revenue_data.get("s_gross") or 0, c), style_small),
)
)
@@ -2,7 +2,6 @@
{% load static %}
{% load i18n %}
{% load statici18n %}
{% load vite %}
{% load eventsignal %}
{% load eventurl %}
{% load dialog %}
@@ -85,7 +84,6 @@
<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,7 +3,6 @@
{% load bootstrap3 %}
{% load static %}
{% load compress %}
{% load vite %}
{% block title %}
{% if checkinlist %}
{% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %}
@@ -75,8 +74,45 @@
{% bootstrap_field form.ignore_in_statistics layout="control" %}
<h3>{% trans "Custom check-in rule" %}</h3>
<div id="rules-editor">
<!-- Vue app mount point -->
<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>
<div class="disabled-withoutjs sr-only">
{{ form.rules }}
@@ -89,10 +125,13 @@
</button>
</div>
</form>
{% if items %}
{{ items|json_script:"items" }}
{% endif %}
{{ items|json_script:"items" }}
{% if DEBUG %}
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
{% else %}
<script type="text/javascript" src="{% static "vuejs/vue.min.js" %}"></script>
{% endif %}
{% 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>
@@ -105,6 +144,15 @@
<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 %}
{% vite_hmr %}
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts" %}
{% 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 %}
{% endblock %}
@@ -5,7 +5,6 @@
{% load getitem %}
{% load static %}
{% load compress %}
{% load vite %}
{% block title %}{% trans "Check-in simulator" %}{% endblock %}
{% block inside %}
<h1>
@@ -125,9 +124,11 @@
{% endif %}
{% if result.rule_graph %}
<div id="rules-editor" class="form-inline">
<!-- Vue app mount point -->
<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>
</div>
<textarea id="id_rules" class="sr-only">{{ result.rule_graph|attr_escapejson_dumps }}</textarea>
{% endif %}
</div>
</div>
@@ -151,6 +152,10 @@
<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 %}
{% vite_hmr %}
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts" %}
{% 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 %}
{% endblock %}
@@ -9,24 +9,23 @@
<h3 class="panel-title">{% trans "Go offline" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-lg-6">
<p>
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can take your event offline. Nobody except your team will be able to see or access it any more.
{% endblocktrans %}
</p>
</div>
<form class="col-sm-12 col-lg-6 text-right"
action="{% url "control:event.live" event=request.event.slug organizer=request.organizer.slug %}"
method="post">
{% csrf_token %}
<input type="hidden" name="live" value="false">
</div>
<div class="col-sm-12 col-md-3">
<form action="{% url "control:event.live" event=request.event.slug organizer=request.organizer.slug %}"
method="post">
{% csrf_token %}
<input type="hidden" name="live" value="false">
<button type="submit" class="btn btn-primary btn-lg">
<span class="fa fa-power-off"></span>
{% trans "Go offline" %}
</button>
</form>
<button type="submit" class="btn btn-primary btn-lg btn-block">
<span class="fa fa-power-off"></span>
{% trans "Go offline" %}
</button>
</form>
</div>
</div>
</div>
@@ -35,24 +34,22 @@
<h3 class="panel-title">{% trans "Cancel event" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-lg-6">
<p>
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
If you need to call off your event you want to cancel and refund all tickets, you can do so through
this option.
{% endblocktrans %}
</p>
</div>
<div class="col-sm-12 col-lg-6 text-right">
<a href="{% url "control:event.cancel" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-lg pull-right {% if "event:cancel" not in request.eventpermset %}disabled{% endif %}">
<span class="fa fa-ban"></span>
{% if "event:cancel" in request.eventpermset %}
<div class="col-sm-12 col-md-3 text-center">
{% if "event:cancel" in request.eventpermset %}
<a href="{% url "control:event.cancel" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-block btn-lg">
<span class="fa fa-ban"></span>
{% trans "Cancel event" %}
{% else %}
{% trans "No permission" %}
{% endif %}
</a>
</a>
{% else %}
{% trans "No permission" %}
{% endif %}
</div>
</div>
</div>
@@ -62,16 +59,15 @@
<h3 class="panel-title">{% trans "Delete personal data" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-lg-6">
<p>
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can remove personal data such as names and email addresses from your event and only retain the
financial information such as the number and type of tickets sold.
{% endblocktrans %}
</p>
</div>
<div class="col-sm-12 col-lg-6 text-right">
<a href="{% url "control:event.shredder.start" event=request.event.slug organizer=request.organizer.slug %}" class="btn btn-danger btn-lg">
<div class="col-sm-12 col-md-3">
<a href="
{% url "control:event.shredder.start" event=request.event.slug organizer=request.organizer.slug %}" class="btn btn-danger btn-lg btn-block">
<span class="fa fa-eraser"></span>
{% trans "Delete personal data" %}
</a>
@@ -84,17 +80,15 @@
<h3 class="panel-title">{% trans "Delete event" %}</h3>
</div>
<div class="row panel-body">
<div class="col-sm-12 col-lg-6">
<p>
<div class="col-sm-12 col-md-9 nomargin-bottom">
{% blocktrans trimmed %}
You can delete your event completely only as long as it does not contain any undeletable data, such as
orders not performed in test mode.
{% endblocktrans %}
</p>
</div>
<div class="col-sm-12 col-lg-6 text-right">
<div class="col-sm-12 col-md-3">
<a href="{% url "control:event.delete" organizer=request.organizer.slug event=request.event.slug %}"
class="btn btn-danger btn-lg {% if not request.event.allow_delete %}disabled{% endif %}">
class="btn btn-danger btn-block btn-lg {% if not request.event.allow_delete %}disabled{% endif %}">
<span class="fa fa-trash"></span>
{% trans "Delete event" %}
</a>
@@ -34,7 +34,6 @@
{% bootstrap_form_errors form %}
{% bootstrap_field form.start layout="control" %}
{% bootstrap_field form.end layout="control" %}
{% bootstrap_field form.location layout="control" %}
</div>
</div>
{% endfor %}
@@ -60,7 +59,6 @@
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.start layout="control" %}
{% bootstrap_field formset.empty_form.end layout="control" %}
{% bootstrap_field formset.empty_form.location layout="control" %}
</div>
</div>
{% endescapescript %}

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