diff --git a/.node-version b/.node-version
new file mode 100644
index 0000000000..98d9bcb75a
--- /dev/null
+++ b/.node-version
@@ -0,0 +1 @@
+17
diff --git a/MANIFEST.in b/MANIFEST.in
index 331555b61c..adf0476f8d 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -10,6 +10,8 @@ recursive-include src/pretix/helpers/locale *
recursive-include src/pretix/base/templates *
recursive-include src/pretix/control/templates *
recursive-include src/pretix/presale/templates *
+recursive-include src/pretix/plugins/autocheckin/templates *
+recursive-include src/pretix/plugins/autocheckin/static *
recursive-include src/pretix/plugins/banktransfer/templates *
recursive-include src/pretix/plugins/banktransfer/static *
recursive-include src/pretix/plugins/manualpayment/templates *
diff --git a/doc/admin/installation/manual_smallscale.rst b/doc/admin/installation/manual_smallscale.rst
index a0d3c16ad2..ac2722160b 100644
--- a/doc/admin/installation/manual_smallscale.rst
+++ b/doc/admin/installation/manual_smallscale.rst
@@ -65,7 +65,7 @@ Package dependencies
To build and run pretix, you will need the following debian packages::
# apt-get install git build-essential python3-dev python3-venv python3 python3-pip \
- python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
+ libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
gettext libpq-dev libjpeg-dev libopenjp2-7-dev
Config file
diff --git a/doc/admin/updates.rst b/doc/admin/updates.rst
index caf7afe6a8..eaeaeadd83 100644
--- a/doc/admin/updates.rst
+++ b/doc/admin/updates.rst
@@ -73,4 +73,11 @@ This release includes a migration that changes retroactively fills an `organizer
`pretixbase_logentry`. If you have a large database, the migration step of the upgrade might take significantly
longer than usual, so plan the update accordingly.
+Upgrade to 2024.7.0 or newer
+"""""""""""""""""""""""""""""
+
+This release includes a migration that changes how sales channels are referred on orders.
+If you have a large database, the migration step of the upgrade might take significantly longer than usual, so plan
+the update accordingly.
+
.. _blog: https://pretix.eu/about/en/blog/
diff --git a/doc/api/resources/auto_checkin_rules.rst b/doc/api/resources/auto_checkin_rules.rst
new file mode 100644
index 0000000000..83c20c82f3
--- /dev/null
+++ b/doc/api/resources/auto_checkin_rules.rst
@@ -0,0 +1,259 @@
+.. _rest-autocheckinrules:
+
+Auto check-in rules
+===================
+
+This feature requires the bundled ``pretix.plugins.autocheckin`` plugin to be active for the event in order to work properly.
+
+Resource description
+--------------------
+
+Auto check-in rules specify that tickets should under specific conditions automatically be considered checked in after
+they have been purchased.
+
+.. rst-class:: rest-resource-table
+
+===================================== ========================== =======================================================
+Field Type Description
+===================================== ========================== =======================================================
+id integer Internal ID of the rule
+list integer ID of the check-in list to check the ticket in on. If
+ ``None``, the system will select all matching check-in lists.
+mode string ``"placed"`` if the rule should be evaluated right after
+ an order has been created, ``"paid"`` if the rule should
+ be evaluated after the order has been fully paid.
+all_sales_channels boolean If ``true`` (default), the rule applies to tickets sold on all sales channels.
+limit_sales_channels list of strings List of sales channel identifiers the rule should apply to
+ if ``all_sales_channels`` is ``false``.
+all_products boolean If ``true`` (default), the rule affects all products and variations.
+limit_products list of integers List of item IDs, if ``all_products`` is not set. If the
+ product listed here has variations, all variations will be matched.
+limit_variations list of integers List of product variation IDs, if ``all_products`` is not set.
+ The parent product does not need to be part of ``limit_products``.
+all_payment_methods boolean If ``true`` (default), the rule applies to tickets paid with all payment methods.
+limit_payment_methods list of strings List of payment method identifiers the rule should apply to
+ if ``all_payment_methods`` is ``false``.
+===================================== ========================== =======================================================
+
+.. versionadded:: 2024.7
+
+Endpoints
+---------
+
+.. http:get:: /api/v1/organizers/(organizer)/events/(event)/auto_checkin_rules/
+
+ Returns a list of all rules configured for an event.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /api/v1/organizers/bigevents/events/sampleconf/auto_checkin_rules/ 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,
+ "list": 12345,
+ "mode": "placed",
+ "all_sales_channels": false,
+ "limit_sales_channels": ["web"],
+ "all_products": False,
+ "limit_products": [2, 3],
+ "limit_variations": [456],
+ "all_payment_methods": true,
+ "limit_payment_methods": []
+ }
+ ]
+ }
+
+ :query page: The page number in case of a multi-page result set, default is 1
+ :param organizer: The ``slug`` field of a valid organizer
+ :param event: The ``slug`` field of the event to fetch
+ :statuscode 200: no error
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
+
+.. http:get:: /api/v1/organizers/(organizer)/events/(event)/auto_checkin_rules/(id)/
+
+ Returns information on one rule, identified by its ID.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /api/v1/organizers/bigevents/events/sampleconf/auto_checkin_rules/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,
+ "list": 12345,
+ "mode": "placed",
+ "all_sales_channels": false,
+ "limit_sales_channels": ["web"],
+ "all_products": False,
+ "limit_products": [2, 3],
+ "limit_variations": [456],
+ "all_payment_methods": true,
+ "limit_payment_methods": []
+ }
+
+ :param organizer: The ``slug`` field of the organizer to fetch
+ :param event: The ``slug`` field of the event to fetch
+ :param id: The ``id`` field of the rule to fetch
+ :statuscode 200: no error
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to view it.
+
+.. http:post:: /api/v1/organizers/(organizer)/events/(event)/auto_checkin_rules/
+
+ Create a new rule.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ POST /api/v1/organizers/bigevents/events/sampleconf/auto_checkin_rules/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+ Content-Type: application/json
+ Content-Length: 166
+
+ {
+ "list": 12345,
+ "mode": "placed",
+ "all_sales_channels": false,
+ "limit_sales_channels": ["web"],
+ "all_products": False,
+ "limit_products": [2, 3],
+ "limit_variations": [456],
+ "all_payment_methods": true,
+ "limit_payment_methods": []
+ }
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 201 Created
+ Vary: Accept
+ Content-Type: application/json
+
+ {
+ "id": 1,
+ "list": 12345,
+ "mode": "placed",
+ "all_sales_channels": false,
+ "limit_sales_channels": ["web"],
+ "all_products": False,
+ "limit_products": [2, 3],
+ "limit_variations": [456],
+ "all_payment_methods": true,
+ "limit_payment_methods": []
+ }
+
+ :param organizer: The ``slug`` field of the organizer to create a rule for
+ :param event: The ``slug`` field of the event to create a rule for
+ :statuscode 201: no error
+ :statuscode 400: The rule could not be created due to invalid submitted data.
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create rules.
+
+
+.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/auto_checkin_rules/(id)/
+
+ Update a rule. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
+ the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
+ want to change.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ PATCH /api/v1/organizers/bigevents/events/sampleconf/auto_checkin_rules/1/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+ Content-Type: application/json
+ Content-Length: 34
+
+ {
+ "mode": "paid",
+ }
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 200 OK
+ Vary: Accept
+ Content-Type: text/javascript
+
+ {
+ "id": 1,
+ "list": 12345,
+ "mode": "placed",
+ "all_sales_channels": false,
+ "limit_sales_channels": ["web"],
+ "all_products": False,
+ "limit_products": [2, 3],
+ "limit_variations": [456],
+ "all_payment_methods": true,
+ "limit_payment_methods": []
+ }
+
+ :param organizer: The ``slug`` field of the organizer to modify
+ :param event: The ``slug`` field of the event to modify
+ :param id: The ``id`` field of the rule to modify
+ :statuscode 200: no error
+ :statuscode 400: The rule could not be modified due to invalid submitted data.
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it.
+
+
+.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/auto_checkin_rules/(id)/
+
+ Delete a rule.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ DELETE /api/v1/organizers/bigevents/events/sampleconf/auto_checkin_rules/1/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 204 No Content
+ Vary: Accept
+
+ :param organizer: The ``slug`` field of the organizer to modify
+ :param event: The ``slug`` field of the event to modify
+ :param id: The ``id`` field of the rule to delete
+ :statuscode 204: no error
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this rule cannot be deleted since it is currently in use.
diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst
index 4f3b57838a..a6bd3327bf 100644
--- a/doc/api/resources/checkinlists.rst
+++ b/doc/api/resources/checkinlists.rst
@@ -32,6 +32,7 @@ position_count integer Number of ticke
checkin_count integer Number of check-ins performed on this list (read-only).
include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state.
auto_checkin_sales_channels list of strings All items on the check-in list will be automatically marked as checked-in when purchased through any of the listed sales channels.
+ **Deprecated, will be removed in pretix 2024.10.** Use :ref:`rest-autocheckinrules`: instead.
allow_multiple_entries boolean If ``true``, subsequent scans of a ticket on this list should not show a warning but instead be stored as an additional check-in.
allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan.
rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged.
diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst
index 89130bfaa0..b9d5187acf 100644
--- a/doc/api/resources/index.rst
+++ b/doc/api/resources/index.rst
@@ -44,5 +44,7 @@ at :ref:`plugin-docs`.
scheduled_exports
shredders
sendmail_rules
+ auto_checkin_rules
billing_invoices
- billing_var
\ No newline at end of file
+ billing_var
+ seats
diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst
index 422eb5d23b..4b64959c1d 100644
--- a/doc/api/resources/invoices.rst
+++ b/doc/api/resources/invoices.rst
@@ -217,6 +217,9 @@ List of all invoices
:query boolean is_cancellation: If set to ``true`` or ``false``, only invoices with this value for the field
``is_cancellation`` will be returned.
:query string order: If set, only invoices belonging to the order with the given order code will be returned.
+ This parameter may be given multiple times. In this case, all invoices matching one of the inputs will be returned.
+ :query string number: If set, only invoices with the given invoice number will be returned.
+ This parameter may be given multiple times. In this case, all invoices matching one of the inputs will be returned.
:query string refers: If set, only invoices referring to the given invoice will be returned.
:query string locale: If set, only invoices with the given locale will be returned.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date`` and
diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst
index 043a51112d..f204e260e3 100644
--- a/doc/api/resources/orders.rst
+++ b/doc/api/resources/orders.rst
@@ -203,7 +203,8 @@ checkins list of objects List of **succe
├ datetime datetime Time of check-in
├ type string Type of scan (defaults to ``entry``)
├ gate integer Internal ID of the gate. Can be ``null``.
-├ device integer Internal ID of the device. Can be ``null``.
+├ device integer Internal ID of the device. Can be ``null``. **Deprecated**, since this ID is not otherwise used in the API and is therefore not very useful.
+├ device_id integer Attribute ``device_id`` of the device. Can be ``null``.
└ auto_checked_in boolean Indicates if this check-in been performed automatically by the system
downloads list of objects List of ticket download options
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
diff --git a/doc/api/resources/seats.rst b/doc/api/resources/seats.rst
new file mode 100644
index 0000000000..b9c6e60cc0
--- /dev/null
+++ b/doc/api/resources/seats.rst
@@ -0,0 +1,262 @@
+.. _`rest-reusablemedia`:
+
+Seats
+=====
+
+The seat resource represents the seats in a seating plan in a specific event or subevent.
+
+Resource description
+--------------------
+
+The seat resource contains the following public fields:
+
+.. rst-class:: rest-resource-table
+
+===================================== ========================== =======================================================
+Field Type Description
+===================================== ========================== =======================================================
+id integer Internal ID of this seat
+subevent integer Internal ID of the subevent this seat belongs to
+zone_name string Name of the zone the seat is in
+row_name string Name/number of the row the seat is in
+row_label string Additional label of the row (or ``null``)
+seat_number string Number of the seat within the row
+seat_label string Additional label of the seat (or ``null``)
+seat_guid string Identifier of the seat within the seating plan
+product integer Internal ID of the product that is mapped to this seat
+blocked boolean Whether this seat is blocked manually.
+orderposition integer / object Internal ID of an order position reserving this seat.
+cartposition integer / object Internal ID of a cart position reserving this seat.
+voucher integer / object Internal ID of a voucher reserving this seat.
+===================================== ========================== =======================================================
+
+Endpoints
+---------
+
+.. http:get:: /api/v1/organizers/(organizer)/events/(event)/seats/
+.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(subevent_id)/seats/
+
+ Returns a list of all seats in the specified event or subevent. Depending on whether the event has subevents, the
+ according endpoint has to be used.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /api/v1/organizers/bigevents/events/sampleconf/seats/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 200 OK
+ Vary: Accept
+ Content-Type: application/json
+
+ {
+ "count": 500,
+ "next": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/seats/?page=2",
+ "previous": null,
+ "results": [
+ {
+ "id": 1633,
+ "subevent": null,
+ "zone_name": "Ground floor",
+ "row_name": "1",
+ "row_label": null,
+ "seat_number": "1",
+ "seat_label": null,
+ "seat_guid": "b9746230-6f31-4f41-bbc9-d6b60bdb3342",
+ "product": 104,
+ "blocked": false,
+ "orderposition": null,
+ "cartposition": null,
+ "voucher": 51
+ },
+ {
+ "id": 1634,
+ "subevent": null,
+ "zone_name": "Ground floor",
+ "row_name": "1",
+ "row_label": null,
+ "seat_number": "2",
+ "seat_label": null,
+ "seat_guid": "1d29fe20-8e1e-4984-b0ee-2773b0d07e07",
+ "product": 104,
+ "blocked": true,
+ "orderposition": 4321,
+ "cartposition": null,
+ "voucher": null
+ },
+ // ...
+ ]
+ }
+
+ :query integer page: The page number in case of a multi-page result set, default is 1.
+ :query string zone_name: Only show seats with the given zone_name.
+ :query string row_name: Only show seats with the given row_name.
+ :query string row_label: Only show seats with the given row_label.
+ :query string seat_number: Only show seats with the given seat_number.
+ :query string seat_label: Only show seats with the given seat_label.
+ :query string seat_guid: Only show seats with the given seat_guid.
+ :query boolean blocked: Only show seats with the given blocked status.
+ :query boolean is_available: Only show seats that are (not) currently available.
+ :query string expand: If you pass ``"orderposition"``, ``"cartposition"``, or ``"voucher"``, the respective field will be
+ shown as a nested value instead of just an ID. This requires permission to access that object.
+ 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, and won't include the `seat` attribute, as that would be redundant.
+ The parameter can be given multiple times.
+ :param organizer: The ``slug`` field of the organizer to fetch
+ :param event: The ``slug`` field of the event to fetch
+ :param subevent_id: The ``id`` field of the subevent to fetch
+ :statuscode 200: no error
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
+ :statuscode 404: Endpoint without subevent id was used for event with subevents, or vice versa.
+
+.. http:get:: /api/v1/organizers/(organizer)/events/(event)/seats/(id)/
+.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(subevent_id)/seats/(id)/
+
+ Returns information on one seat, identified by its ID.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /api/v1/organizers/bigevents/events/sampleconf/seats/1634/?expand=orderposition HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 200 OK
+ Vary: Accept
+ Content-Type: application/json
+
+ {
+ "id": 1634,
+ "subevent": null,
+ "zone_name": "Ground floor",
+ "row_name": "1",
+ "row_label": null,
+ "seat_number": "2",
+ "seat_label": null,
+ "seat_guid": "1d29fe20-8e1e-4984-b0ee-2773b0d07e07",
+ "product": 104,
+ "blocked": true,
+ "orderposition": {
+ "id": 134,
+ "order": {
+ "code": "U0HW7",
+ "event": "sampleconf"
+ },
+ "positionid": 1,
+ "item": 104,
+ "variation": 59,
+ "price": "60.00",
+ "attendee_name": "",
+ "attendee_name_parts": {
+ "_scheme": "given_family"
+ },
+ "company": null,
+ "street": null,
+ "zipcode": null,
+ "city": null,
+ "country": null,
+ "state": null,
+ "discount": null,
+ "attendee_email": null,
+ "voucher": null,
+ "tax_rate": "0.00",
+ "tax_value": "0.00",
+ "secret": "4rfgp263jduratnsvwvy6cc6r6wnptbj",
+ "addon_to": null,
+ "subevent": null,
+ "checkins": [],
+ "downloads": [],
+ "answers": [],
+ "tax_rule": null,
+ "pseudonymization_id": "ZSNYSG3URZ",
+ "canceled": false,
+ "valid_from": null,
+ "valid_until": null,
+ "blocked": null,
+ "voucher_budget_use": null
+ },
+ "cartposition": null,
+ "voucher": null
+ }
+
+ :param organizer: The ``slug`` field of the organizer to fetch
+ :param event: The ``slug`` field of the event to fetch
+ :param subevent_id: The ``id`` field of the subevent to fetch
+ :param id: The ``id`` field of the seat to fetch
+ :query string expand: If you pass ``"orderposition"``, ``"cartposition"``, or ``"voucher"``, the respective field will be
+ shown as a nested value instead of just an ID. This requires permission to access that object.
+ 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, and won't include the `seat` attribute, as that would be redundant.
+ The parameter can be given multiple times.
+ :statuscode 200: no error
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
+ :statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
+
+.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/seats/(id)/
+.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/(id)/
+
+ Update a seat.
+
+ You can only change the ``blocked`` field.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/1636/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+ Content-Type: application/json
+
+ {
+ "blocked": true
+ }
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 200 OK
+ Vary: Accept
+ Content-Type: application/json
+
+ {
+ "id": 1636,
+ "subevent": null,
+ "zone_name": "Ground floor",
+ "row_name": "1",
+ "row_label": null,
+ "seat_number": "4",
+ "seat_label": null,
+ "seat_guid": "6c0e29e5-05d6-421f-99f3-afd01478ecad",
+ "product": 104,
+ "blocked": true,
+ "orderposition": null,
+ "cartposition": null,
+ "voucher": null
+ },
+
+ :param organizer: The ``slug`` field of the organizer to modify
+ :param event: The ``slug`` field of the event to modify
+ :param subevent_id: The ``id`` field of the subevent to modify
+ :param id: The ``id`` field of the seat to modify
+ :statuscode 200: no error
+ :statuscode 400: The seat could not be modified due to invalid submitted data
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
+ :statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
diff --git a/doc/api/resources/sendmail_rules.rst b/doc/api/resources/sendmail_rules.rst
index 89afe46b9f..17d6096e43 100644
--- a/doc/api/resources/sendmail_rules.rst
+++ b/doc/api/resources/sendmail_rules.rst
@@ -1,6 +1,8 @@
Scheduled email rules
=====================
+This feature requires the bundled ``pretix.plugins.sendmail`` plugin to be active for the event in order to work properly.
+
Resource description
--------------------
@@ -48,6 +50,7 @@ send_to string Can be ``"order
or ``"both"``.
date. Otherwise it is relative to the event start date.
===================================== ========================== =======================================================
+
.. versionchanged:: 2023.7
The ``include_pending`` field has been deprecated.
diff --git a/doc/api/resources/webhooks.rst b/doc/api/resources/webhooks.rst
index a9abe52c0c..c7be77326b 100644
--- a/doc/api/resources/webhooks.rst
+++ b/doc/api/resources/webhooks.rst
@@ -116,6 +116,7 @@ Endpoints
}
:query integer page: The page number in case of a multi-page result set, default is 1
+ :query boolean enabled: Only show webhooks that are or are not enabled
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
diff --git a/doc/development/setup.rst b/doc/development/setup.rst
index ae166d18cf..a691876f49 100644
--- a/doc/development/setup.rst
+++ b/doc/development/setup.rst
@@ -136,9 +136,7 @@ It is a good idea to put this command into your git hook ``.git/hooks/pre-commit
for example, to check for any errors in any staged files when committing::
#!/bin/bash
- cd $GIT_DIR/../src
- export GIT_WORK_TREE=../
- export GIT_DIR=../.git
+
source ../env/bin/activate # Adjust to however you activate your virtual environment
for file in $(git diff --cached --name-only | grep -E '\.py$' | grep -Ev "migrations|mt940\.py|pretix/settings\.py|make_testdata\.py|testutils/settings\.py|tests/settings\.py|pretix/base/models/__init__\.py|.*_pb2\.py")
do
diff --git a/doc/plugins/getyourguide.rst b/doc/plugins/getyourguide.rst
new file mode 100644
index 0000000000..e15743084b
--- /dev/null
+++ b/doc/plugins/getyourguide.rst
@@ -0,0 +1,105 @@
+GetYourGuide
+============
+
+.. note::
+
+ The GetYourGuide integration is currently in Beta. Please contact support@pretix.eu to enable the integration
+ for your pretix.eu organizer account.
+
+Introduction
+------------
+Using third party aggregators, such als GetYourGuide, event organizers can sell tickets to their events not only on
+their own ticket-shop but also on the aggregator's portal. While this service is not for free, it allows event
+organizers to reacher a larger audience that would otherwise not have found their way into the organizers webshop.
+
+Using pretix' integration with GetYourGuide, event organizers can profit from an additional sales and revenue channel,
+while keeping the effort for setting up and maintaining multiple ticket shops to a minimum.
+
+Preparing your organizer account
+--------------------------------
+The first step in enabling the GetYourGuide integration, is to setup a corresponding Sales Channel, which will be used
+to properly attribute the sales generated. This needs to be done only once per organizer account.
+
+To do so, log into the pretix backend, select ``Organizers`` from the navigation and then the organizer in question.
+Extending the ``Settings``-menu, find the ``Sales channels`` configuration and click the ``Add a new channel`` button.
+
+On the following page, you will be able to select ``GetYourGuide`` as the sales channel type and give it a custom name.
+
+Preparing your event
+--------------------
+In order to now sell your events on GetYourGuide, you will need to configure each event in question.
+
+1. Enabling the plugin
+ Within your event, extend the ``Settings`` menu and navigate to ``Plugins``. Activate the plugin in the
+ ``Integrations`` tab.
+
+2. Sell the event on the sales channel
+ Pick the sales channel or channels, on which you would like to sell your event by navigating to the event's general
+ settings page using the ``Sell on all sales channels`` or ``Restrict to specific sales channels`` checkboxes.
+
+3. Configure one or more products to be sold on GetYourGuide
+ Either create a new or edit an existing product, that you would like to sell on GetYourGuide. To do so, you will
+ need to have checked the ``Sell on all sales channels`` or appropriate ``Restrict to specific sales channels``
+ checkbox of the product within it's ``Availability`` tab.
+ In addition, you will also need to set the GetYourGuide equivalent ticket category in the product's accordingly
+ named settings tab. Within your event, there can be only one product per ticket category. Depending on your further
+ configuration, you must at least select one product to be in the ``Adult`` or ``Group`` category.
+
+4. Configuring the GetYourGuide-plugin
+ Once you have configured one or more products to be eligible to be sold on GetYourGuide, you'll need to configure a
+ few basic settings within the event (``Settings`` --> ``GetYourGuide``). The most important settings can be found
+ the in the ``Configuration`` tab, such as the location of the event on sale.
+
+Ticket Categories
+-----------------
+While pretix only uses the ticket category term loosely to group together multiple products for nicer display,
+GetYourGuide is relying on the ticket categories to price the tickets.
+
+First of all, you need to make the decision on how you are planning on selling your tickets on GetYourGuide - in most
+cases, this will reflect your current sales strategy within your pretix shop.
+
+- Individual tickets
+ Every single person attending will need to purchase their own ticket. A family of two adults and two
+ children will have to purchase and pay for a total of 4 tickets.
+ In this case, you will need to offer *at least* a ticket of the ``Adult`` type, but may offer any other ticket
+ category type (Child, Youth, Senior, ...) in addition. But you cannot offer a ``Group`` ticket.
+
+- Group tickets
+ Two groups, consisting of 10 and 20 participants respectively, won't need to purchase a total of 30 tickets, but
+ rather two group tickets. It is up to you to configure the group size limits within the GetYourGuide-settings of your
+ product.
+ Choosing this option, you cannot offer any other ticket categories besides ``Group``.
+
+Setting up event dates and quotas
+---------------------------------
+Of course, in addition to creating products, you will also need to add them to a quota for them to be available for
+sale. The process for doing this is the very same as for any regular event or event series.
+
+.. note::
+
+ When selling individual tickets through GetYourGuide, you will not be able to offer differing quantities for
+ individual ticket categories.
+
+For this reason, we recommend to place all GetYourGuide-eligible products into the same quota. Should you however opt
+to create multiple quotas which create an imbalance, pretix will report only the available number of tickets for the
+lowest relevant quota.
+
+Connecting your event to GetYourGuide
+-------------------------------------
+Once you have set up your event and products and performed all necessary configuration, you may want to use the
+Analyzer-feature of our GetYourGuide-plugin (``Settings`` -> ``GetYourGuide`` -> tab ``Analyzer``).
+
+The Analyzer should not display any blocking error messages and at least one event date that is ready for publishing on
+the GetYourGuide platform.
+
+At this point, you will need to setup your event (called ``product`` in the GetYourGuide universe) on their
+`Supplier Portal`_ and connect it with your pretix shop. To do so, please follow the
+`Connecting a new product to your Reservation System`_ on the GetYourGuide Supply Partner Help Center.
+
+Select ``pretix.eu`` as your reservation system; the required ``product ID`` can be found in the ``Configuration`` tab
+of the GetYourGuide plugin settings page.
+
+From this point on, GetYourGuide will automatically import the availabilities and products and offer them for sale.
+
+.. _Supplier Portal: https://suppliers.getyourguide.com/
+.. _Connecting a new product to your Reservation System: https://supply.getyourguide.support/hc/en-us/articles/18008029689373-Connecting-a-new-product-to-your-Reservation-system
diff --git a/doc/plugins/index.rst b/doc/plugins/index.rst
index 55b842cd9d..8960123063 100644
--- a/doc/plugins/index.rst
+++ b/doc/plugins/index.rst
@@ -25,3 +25,4 @@ If you want to **create** a plugin, please go to the
webinar
presale-saml
kulturpass
+ getyourguide
diff --git a/doc/requirements.rtd.txt b/doc/requirements.rtd.txt
index 7a5f855a63..54f1045c73 100644
--- a/doc/requirements.rtd.txt
+++ b/doc/requirements.rtd.txt
@@ -1,4 +1,4 @@
-sphinx==7.3.*
+sphinx==7.4.*
jinja2==3.1.*
sphinx-rtd-theme
sphinxcontrib-httpdomain
@@ -6,5 +6,4 @@ sphinxcontrib-images
sphinxcontrib-jquery
sphinxcontrib-spelling==8.*
sphinxemoji
-pygments-markdown-lexer
pyenchant==3.2.*
diff --git a/doc/requirements.txt b/doc/requirements.txt
index 965c3ad703..5b39f516d3 100644
--- a/doc/requirements.txt
+++ b/doc/requirements.txt
@@ -1,5 +1,5 @@
-e ../
-sphinx==7.3.*
+sphinx==7.4.*
jinja2==3.1.*
sphinx-rtd-theme
sphinxcontrib-httpdomain
@@ -7,5 +7,4 @@ sphinxcontrib-images
sphinxcontrib-jquery
sphinxcontrib-spelling==8.*
sphinxemoji
-pygments-markdown-lexer
pyenchant==3.2.*
diff --git a/doc/user/android-version-support.rst b/doc/user/android-version-support.rst
index 2607113e1e..b37629cb6c 100644
--- a/doc/user/android-version-support.rst
+++ b/doc/user/android-version-support.rst
@@ -31,8 +31,7 @@ Android 9 Support planned until at least 12/2025.
Android 8 Support planned until at least 12/2025.
Android 7 Support planned until at least 06/2025.
Android 6 Support planned until at least 06/2025.
-Android 5 | Support planned until at least 06/2025.
- | No support for COVID certificate verification.
+Android 5 Support planned until at least 06/2025.
Android 4 Support dropped.
=========================== ==========================================================
@@ -57,16 +56,17 @@ Android 8 | Support planned until at least 12/2025.
Android 7 | Support planned until at least 12/2024.
| Support for Stripe Terminal to be dropped 05/2024.
| No support for Cryptovision TSE.
+ | No support for SumUp.
Android 6 | Support planned until at least 12/2024.
| No support for Cryptovision TSE.
| No support for Fiskal Cloud.
| No support for Stripe Terminal.
+ | No support for SumUp.
Android 5 | Support planned until at least 12/2024.
| No support for Cryptovision TSE.
| No support for Fiskal Cloud.
| No support for Stripe Terminal.
| No support for SumUp.
- | No support for COVID certificate verification.
Android 4 Support dropped.
=========================== ==========================================================
@@ -87,9 +87,6 @@ Android 7 Support planned until at least 06/2025.
Android 6 Support planned until at least 06/2025.
Android 5 | Support planned until at least 06/2025.
| No support for Evolis printers on some devices.
-Android 4.4 | Support planned until at least 06/2024.
- | No support for USB printers.
- | No support for Evolis printers.
Android 4 Support dropped.
=========================== ==========================================================
diff --git a/doc/user/events/widget.rst b/doc/user/events/widget.rst
index ce06232b95..289615798f 100644
--- a/doc/user/events/widget.rst
+++ b/doc/user/events/widget.rst
@@ -450,6 +450,19 @@ Further reading:
* `Stripe Payment Method Domain registration`_
+Content Security Policy
+-----------------------
+
+When using a Content Security Policy (CSP) on your website, you may need to make some adjustments. If your pretix
+shop is running under a custom domain, you need to add the following rules:
+
+* ``script-src``: ``'unsafe-eval' https://pretix.eu`` (adjust to your domain for self-hosted pretix)
+* ``style-src``: ``https://pretix.eu`` (adjust to your domain for self-hosted pretix **and** for custom domain on pretix Hosted)
+* ``connect-src``: ``https://pretix.eu`` (adjust to your domain for self-hosted pretix **and** for custom domain on pretix Hosted)
+* ``frame-src``: ``https://pretix.eu`` (adjust to your domain for self-hosted pretix **and** for custom domain on pretix Hosted)
+* ``img-src``: ``https://pretix.eu`` (adjust to your domain for self-hosted pretix **and** for custom domain on pretix Hosted) and for pretix Hosted additionally add ``https://cdn.pretix.space``
+
+
External payment providers and Cross-Origin-Opener-Policy
---------------------------------------------------------
diff --git a/pyproject.toml b/pyproject.toml
index e2c73fe829..7bc7faa3da 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,7 @@ classifiers = [
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
- "Framework :: Django :: 4.1",
+ "Framework :: Django :: 4.2",
]
dependencies = [
@@ -35,12 +35,11 @@ dependencies = [
"cryptography>=3.4.2",
"css-inline==0.14.*",
"defusedcsv>=1.1.0",
- "dj-static",
- "Django[argon2]==4.2.*",
+ "Django[argon2]==4.2.*,>=4.2.15",
"django-bootstrap3==24.2",
- "django-compressor==4.5",
+ "django-compressor==4.5.1",
"django-countries==7.6.*",
- "django-filter==24.2",
+ "django-filter==24.3",
"django-formset-js-improved==0.5.0.3",
"django-formtools==2.5.1",
"django-hierarkey==1.2.*",
@@ -62,10 +61,10 @@ dependencies = [
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
"isoweek",
"jsonschema",
- "kombu==5.3.*",
+ "kombu==5.4.*",
"libsass==0.23.*",
"lxml",
- "markdown==3.6", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
+ "markdown==3.7", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*",
"oauthlib==3.2.*",
@@ -73,7 +72,7 @@ dependencies = [
"packaging",
"paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*",
- "PyJWT==2.8.*",
+ "PyJWT==2.9.*",
"phonenumberslite==8.13.*",
"Pillow==10.4.*",
"pretix-plugin-build",
@@ -82,8 +81,8 @@ dependencies = [
"pycountry",
"pycparser==2.22",
"pycryptodome==3.20.*",
- "pypdf==4.2.*",
- "python-bidi==0.4.*", # Support for Arabic in reportlab
+ "pypdf==4.3.*",
+ "python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
"pytz-deprecation-shim==0.1.*",
@@ -92,10 +91,9 @@ dependencies = [
"redis==5.0.*",
"reportlab==4.2.*",
"requests==2.31.*",
- "sentry-sdk==2.5.*",
+ "sentry-sdk==2.13.*",
"sepaxml==2.6.*",
"slimit",
- "static3==0.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
"tlds>=2020041600",
@@ -110,10 +108,10 @@ dependencies = [
[project.optional-dependencies]
memcached = ["pylibmc"]
dev = [
- "aiohttp==3.9.*",
+ "aiohttp==3.10.*",
"coverage",
"coveralls",
- "fakeredis==2.23.*",
+ "fakeredis==2.24.*",
"flake8==7.1.*",
"freezegun",
"isort==5.13.*",
@@ -127,7 +125,7 @@ dev = [
"pytest-rerunfailures==14.*",
"pytest-sugar",
"pytest-xdist==3.6.*",
- "pytest==8.2.*",
+ "pytest==8.3.*",
"responses",
]
diff --git a/src/pretix/__init__.py b/src/pretix/__init__.py
index 2b1962fb67..4a748f45b5 100644
--- a/src/pretix/__init__.py
+++ b/src/pretix/__init__.py
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
-__version__ = "2024.7.0.dev0"
+__version__ = "2024.9.0.dev0"
diff --git a/src/pretix/_base_settings.py b/src/pretix/_base_settings.py
index 37dac95fa5..af5cf5bf1f 100644
--- a/src/pretix/_base_settings.py
+++ b/src/pretix/_base_settings.py
@@ -62,6 +62,7 @@ INSTALLED_APPS = [
'pretix.plugins.badges',
'pretix.plugins.manualpayment',
'pretix.plugins.returnurl',
+ 'pretix.plugins.autocheckin',
'pretix.plugins.webcheckin',
'django_countries',
'oauth2_provider',
diff --git a/src/pretix/api/filters.py b/src/pretix/api/filters.py
new file mode 100644
index 0000000000..e9217139f7
--- /dev/null
+++ b/src/pretix/api/filters.py
@@ -0,0 +1,82 @@
+#
+# This file is part of pretix (Community Edition).
+#
+# Copyright (C) 2014-2020 Raphael Michel and contributors
+# Copyright (C) 2020-2021 rami.io 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 .
+#
+# 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
+# .
+#
+from django import forms
+from django.core.exceptions import ValidationError
+from django.db.models import Q
+from django.db.models.constants import LOOKUP_SEP
+from django.forms import MultipleChoiceField
+from django_filters import Filter
+from django_filters.conf import settings
+
+
+class MultipleCharField(forms.CharField):
+ widget = forms.MultipleHiddenInput
+
+ def to_python(self, value):
+ if not value:
+ return []
+ elif not isinstance(value, (list, tuple)):
+ raise ValidationError(
+ MultipleChoiceField.default_error_messages["invalid_list"], code="invalid_list"
+ )
+ return [str(val) for val in value]
+
+
+class MultipleCharFilter(Filter):
+ """
+ This filter performs OR(by default) or AND(using conjoined=True) query
+ on the selected inputs.
+ """
+
+ field_class = MultipleCharField
+
+ def __init__(self, *args, **kwargs):
+ self.conjoined = kwargs.pop("conjoined", False)
+ super().__init__(*args, **kwargs)
+
+ def filter(self, qs, value):
+ if not value:
+ # Even though not a noop, no point filtering if empty.
+ return qs
+
+ if not self.conjoined:
+ q = Q()
+ for v in set(value):
+ predicate = self.get_filter_predicate(v)
+ if self.conjoined:
+ qs = self.get_method(qs)(**predicate)
+ else:
+ q |= Q(**predicate)
+
+ if not self.conjoined:
+ qs = self.get_method(qs)(q)
+
+ return qs.distinct() if self.distinct else qs
+
+ def get_filter_predicate(self, v):
+ name = self.field_name
+ if name and self.lookup_expr != settings.DEFAULT_LOOKUP_EXPR:
+ name = LOOKUP_SEP.join([name, self.lookup_expr])
+ try:
+ return {name: getattr(v, self.field.to_field_name)}
+ except (AttributeError, TypeError):
+ return {name: v}
diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py
index 10a4e7fe49..dcd9f29454 100644
--- a/src/pretix/api/serializers/event.py
+++ b/src/pretix/api/serializers/event.py
@@ -35,7 +35,7 @@
import logging
from django.conf import settings
-from django.core.exceptions import ValidationError
+from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
@@ -52,7 +52,8 @@ from pretix.api.serializers import (
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.models import (
- Device, Event, SalesChannel, TaxRule, TeamAPIToken,
+ CartPosition, Device, Event, OrderPosition, SalesChannel, Seat, TaxRule,
+ TeamAPIToken, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import (
@@ -844,6 +845,7 @@ class EventSettingsSerializer(SettingsSerializer):
'reusable_media_type_nfc_mf0aes_autocreate_giftcard',
'reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency',
'reusable_media_type_nfc_mf0aes_random_uid',
+ 'seating_allow_blocked_seats_for_channel',
]
readonly_fields = [
# These are read-only since they are currently only settable on organizers, not events
@@ -894,6 +896,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'locale',
'last_order_modification_date',
'show_quota_left',
+ 'show_dates_on_frontpage',
'max_items_per_order',
'attendee_names_asked',
'attendee_names_required',
@@ -969,3 +972,77 @@ class ItemMetaPropertiesSerializer(I18nAwareModelSerializer):
class Meta:
model = ItemMetaProperty
fields = ('id', 'name', 'default', 'required', 'allowed_values')
+
+
+def prefetch_by_id(items, qs, id_attr, target_attr):
+ """
+ Prefetches a related object on each item in the given list of items by searching by id or another
+ unique field. The id value is read from the attribute on item specified in `id_attr`, searched on queryset `qs` by
+ the primary key, and the resulting prefetched model object is stored into `target_attr` on the item.
+ """
+ ids = [getattr(item, id_attr) for item in items if getattr(item, id_attr)]
+ if ids:
+ result = qs.in_bulk(id_list=ids)
+ for item in items:
+ setattr(item, target_attr, result.get(getattr(item, id_attr)))
+
+
+class SeatSerializer(I18nAwareModelSerializer):
+ orderposition = serializers.IntegerField(source='orderposition_id')
+ cartposition = serializers.IntegerField(source='cartposition_id')
+ voucher = serializers.IntegerField(source='voucher_id')
+
+ class Meta:
+ model = Seat
+ read_only_fields = (
+ 'id', 'subevent', 'zone_name', 'row_name', 'row_label',
+ 'seat_number', 'seat_label', 'seat_guid', 'product',
+ 'orderposition', 'cartposition', 'voucher',
+ )
+ fields = (
+ 'id', 'subevent', 'zone_name', 'row_name', 'row_label',
+ 'seat_number', 'seat_label', 'seat_guid', 'product', 'blocked',
+ 'orderposition', 'cartposition', 'voucher',
+ )
+
+ def prefetch_expanded_data(self, items, request, expand_fields):
+ if 'orderposition' in expand_fields:
+ if 'can_view_orders' not in request.eventpermset:
+ raise PermissionDenied('can_view_orders permission required for expand=orderposition')
+ prefetch_by_id(items, OrderPosition.objects.prefetch_related('order'), 'orderposition_id', 'orderposition')
+ if 'cartposition' in expand_fields:
+ if 'can_view_orders' not in request.eventpermset:
+ raise PermissionDenied('can_view_orders permission required for expand=cartposition')
+ prefetch_by_id(items, CartPosition.objects, 'cartposition_id', 'cartposition')
+ if 'voucher' in expand_fields:
+ if 'can_view_vouchers' not in request.eventpermset:
+ raise PermissionDenied('can_view_vouchers permission required for expand=voucher')
+ prefetch_by_id(items, Voucher.objects, 'voucher_id', 'voucher')
+
+ def __init__(self, instance, *args, **kwargs):
+ if not kwargs.get('data'):
+ self.prefetch_expanded_data(instance if hasattr(instance, '__iter__') else [instance],
+ kwargs['context']['request'],
+ kwargs['context']['expand_fields'])
+
+ super().__init__(instance, *args, **kwargs)
+
+ if 'orderposition' in self.context['expand_fields']:
+ from pretix.api.serializers.media import (
+ NestedOrderPositionSerializer,
+ )
+ self.fields['orderposition'] = NestedOrderPositionSerializer(read_only=True, context=self.context['order_context'])
+ try:
+ del self.fields['orderposition'].fields['seat']
+ except KeyError:
+ pass
+
+ if 'cartposition' in self.context['expand_fields']:
+ from pretix.api.serializers.cart import CartPositionSerializer
+ self.fields['cartposition'] = CartPositionSerializer(read_only=True)
+ del self.fields['cartposition'].fields['seat']
+
+ if 'voucher' in self.context['expand_fields']:
+ from pretix.api.serializers.voucher import VoucherSerializer
+ self.fields['voucher'] = VoucherSerializer(read_only=True)
+ del self.fields['voucher'].fields['seat']
diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py
index b9869a7c78..807dd55792 100644
--- a/src/pretix/api/serializers/order.py
+++ b/src/pretix/api/serializers/order.py
@@ -273,9 +273,15 @@ class AnswerSerializer(I18nAwareModelSerializer):
class CheckinSerializer(I18nAwareModelSerializer):
+ device_id = serializers.SlugRelatedField(
+ source='device',
+ slug_field='device_id',
+ read_only=True,
+ )
+
class Meta:
model = Checkin
- fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'type')
+ fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'device_id', 'type')
class FailedCheckinSerializer(I18nAwareModelSerializer):
diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py
index 78d9cb68a8..d853fa1dc6 100644
--- a/src/pretix/api/urls.py
+++ b/src/pretix/api/urls.py
@@ -87,6 +87,7 @@ event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
event_router.register(r'blockedsecrets', order.BlockedSecretViewSet, basename='blockedsecrets')
event_router.register(r'taxrules', event.TaxRuleViewSet)
+event_router.register(r'seats', event.SeatViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
event_router.register(r'cartpositions', cart.CartPositionViewSet)
@@ -95,6 +96,9 @@ event_router.register(r'exporters', exporters.EventExportersViewSet, basename='e
event_router.register(r'shredders', shredders.EventShreddersViewSet, basename='shredders')
event_router.register(r'item_meta_properties', event.ItemMetaPropertiesViewSet)
+subevent_router = routers.DefaultRouter()
+subevent_router.register(r'seats', event.SeatViewSet)
+
checkinlist_router = routers.DefaultRouter()
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')
@@ -132,6 +136,7 @@ urlpatterns = [
re_path(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/settings/$', event.EventSettingsView.as_view(),
name="event.settings"),
re_path(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/', include(event_router.urls)),
+ re_path(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/subevents/(?P\d+)/', include(subevent_router.urls)),
re_path(r'^organizers/(?P[^/]+)/teams/(?P[^/]+)/', include(team_router.urls)),
re_path(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/items/(?P[^/]+)/', include(item_router.urls)),
re_path(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/questions/(?P[^/]+)/',
diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py
index 4f374f6e0a..668b2e19c3 100644
--- a/src/pretix/api/views/checkin.py
+++ b/src/pretix/api/views/checkin.py
@@ -377,7 +377,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
Prefetch(
'positions',
OrderPosition.objects.prefetch_related(
- Prefetch('checkins', queryset=Checkin.objects.all()),
+ Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
'item', 'variation', 'answers', 'answers__options', 'answers__question',
)
)
diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py
index 043785c0d4..0a33f58e57 100644
--- a/src/pretix/api/views/event.py
+++ b/src/pretix/api/views/event.py
@@ -40,7 +40,9 @@ from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import serializers, views, viewsets
-from rest_framework.exceptions import PermissionDenied, ValidationError
+from rest_framework.exceptions import (
+ NotFound, PermissionDenied, ValidationError,
+)
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
@@ -48,12 +50,12 @@ from pretix.api.auth.permission import EventCRUDPermission
from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.event import (
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
- EventSettingsSerializer, ItemMetaPropertiesSerializer, SubEventSerializer,
- TaxRuleSerializer,
+ EventSettingsSerializer, ItemMetaPropertiesSerializer, SeatSerializer,
+ SubEventSerializer, TaxRuleSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
- CartPosition, Device, Event, ItemMetaProperty, SeatCategoryMapping,
+ CartPosition, Device, Event, ItemMetaProperty, Seat, SeatCategoryMapping,
TaxRule, TeamAPIToken,
)
from pretix.base.models.event import SubEvent
@@ -667,3 +669,77 @@ class EventSettingsView(views.APIView):
'request': request
})
return Response(s.data)
+
+
+class SeatFilter(FilterSet):
+ is_available = django_filters.BooleanFilter(method="is_available_qs")
+
+ def is_available_qs(self, queryset, name, value):
+ expr = (
+ Q(orderposition_id__isnull=True, cartposition_id__isnull=True, voucher_id__isnull=True)
+ )
+ if self.request.event.settings.seating_minimal_distance:
+ expr = expr & Q(has_closeby_taken=False)
+ if value:
+ return queryset.filter(expr)
+ else:
+ return queryset.exclude(expr)
+
+ class Meta:
+ model = Seat
+ fields = ('zone_name', 'row_name', 'row_label', 'seat_number', 'seat_label', 'seat_guid', 'blocked',)
+
+
+class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
+ serializer_class = SeatSerializer
+ queryset = Seat.objects.none()
+ write_permission = 'can_change_event_settings'
+ filter_backends = (DjangoFilterBackend, )
+ filterset_class = SeatFilter
+
+ def get_queryset(self):
+ if self.request.event.has_subevents and 'subevent' in self.request.resolver_match.kwargs:
+ try:
+ subevent = self.request.event.subevents.get(pk=self.request.resolver_match.kwargs['subevent'])
+ except SubEvent.DoesNotExist:
+ raise NotFound('Subevent not found')
+ qs = Seat.annotated(
+ event_id=self.request.event.id,
+ subevent=subevent,
+ qs=subevent.seats.all(),
+ annotate_ids=True,
+ minimal_distance=self.request.event.settings.seating_minimal_distance,
+ distance_only_within_row=self.request.event.settings.seating_distance_only_within_row,
+ )
+ elif not self.request.event.has_subevents and 'subevent' not in self.request.resolver_match.kwargs:
+ qs = Seat.annotated(
+ event_id=self.request.event.id,
+ subevent=None,
+ qs=self.request.event.seats.all(),
+ annotate_ids=True,
+ minimal_distance=self.request.event.settings.seating_minimal_distance,
+ distance_only_within_row=self.request.event.settings.seating_distance_only_within_row,
+ )
+ else:
+ raise NotFound('Please use the subevent-specific endpoint' if self.request.event.has_subevents
+ else 'This event has no subevents')
+
+ return qs
+
+ def get_serializer_context(self):
+ ctx = super().get_serializer_context()
+ ctx['expand_fields'] = self.request.query_params.getlist('expand')
+ ctx['order_context'] = {
+ 'event': self.request.event,
+ 'pdf_data': None,
+ }
+ return ctx
+
+ def perform_update(self, serializer):
+ super().perform_update(serializer)
+ serializer.instance.event.log_action(
+ "pretix.event.seats.blocks.changed",
+ user=self.request.user,
+ auth=self.request.auth,
+ data={"seats": [serializer.instance.pk]},
+ )
diff --git a/src/pretix/api/views/media.py b/src/pretix/api/views/media.py
index b6335033c7..e70f1bf769 100644
--- a/src/pretix/api/views/media.py
+++ b/src/pretix/api/views/media.py
@@ -78,7 +78,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
queryset=OrderPosition.objects.select_related(
'order', 'order__event', 'order__event__organizer', 'seat',
).prefetch_related(
- Prefetch('checkins', queryset=Checkin.objects.all()),
+ Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
)
),
diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py
index 5ca5876c68..a96c373448 100644
--- a/src/pretix/api/views/order.py
+++ b/src/pretix/api/views/order.py
@@ -49,6 +49,7 @@ from rest_framework.mixins import CreateModelMixin
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response
+from pretix.api.filters import MultipleCharFilter
from pretix.api.models import OAuthAccessToken
from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.order import (
@@ -257,7 +258,7 @@ class OrderViewSetMixin:
return Prefetch(
'positions',
opq.all().prefetch_related(
- Prefetch('checkins', queryset=Checkin.objects.all()),
+ Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
Prefetch('item', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached')
)),
@@ -278,7 +279,7 @@ class OrderViewSetMixin:
return Prefetch(
'positions',
opq.all().prefetch_related(
- Prefetch('checkins', queryset=Checkin.objects.all()),
+ Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
'item', 'variation',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
'seat',
@@ -1091,7 +1092,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
'item_meta_properties',
)
qs = qs.prefetch_related(
- Prefetch('checkins', queryset=Checkin.objects.all()),
+ Prefetch('checkins', queryset=Checkin.objects.select_related("device")),
Prefetch('item', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached')
@@ -1110,7 +1111,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
Prefetch(
'positions',
qs.prefetch_related(
- Prefetch('checkins', queryset=Checkin.objects.all()),
+ Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
Prefetch('item', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached')
@@ -1134,7 +1135,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
)
else:
qs = qs.prefetch_related(
- Prefetch('checkins', queryset=Checkin.objects.all()),
+ Prefetch('checkins', queryset=Checkin.objects.select_related("device")),
'answers', 'answers__options', 'answers__question',
).select_related(
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
@@ -1825,17 +1826,14 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
with scopes_disabled():
class InvoiceFilter(FilterSet):
refers = django_filters.CharFilter(method='refers_qs')
- number = django_filters.CharFilter(method='nr_qs')
- order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
+ number = MultipleCharFilter(field_name='nr', lookup_expr='iexact')
+ order = MultipleCharFilter(field_name='order', lookup_expr='code__iexact')
def refers_qs(self, queryset, name, value):
return queryset.annotate(
refers_nr=Concat('refers__prefix', 'refers__invoice_no')
).filter(refers_nr__iexact=value)
- def nr_qs(self, queryset, name, value):
- return queryset.filter(nr__iexact=value)
-
class Meta:
model = Invoice
fields = ['order', 'number', 'is_cancellation', 'refers', 'locale']
diff --git a/src/pretix/api/views/webhooks.py b/src/pretix/api/views/webhooks.py
index 3877059c60..b2d18971ea 100644
--- a/src/pretix/api/views/webhooks.py
+++ b/src/pretix/api/views/webhooks.py
@@ -19,6 +19,8 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
+import django_filters
+from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from pretix.api.models import WebHook
@@ -26,11 +28,17 @@ from pretix.api.serializers.webhooks import WebHookSerializer
from pretix.helpers.dicts import merge_dicts
+class WebhookFilter(FilterSet):
+ enabled = django_filters.rest_framework.BooleanFilter()
+
+
class WebHookViewSet(viewsets.ModelViewSet):
serializer_class = WebHookSerializer
queryset = WebHook.objects.none()
permission = 'can_change_organizer_settings'
write_permission = 'can_change_organizer_settings'
+ filter_backends = (DjangoFilterBackend,)
+ filterset_class = WebhookFilter
def get_queryset(self):
return self.request.organizer.webhooks.prefetch_related('listeners')
diff --git a/src/pretix/base/exporter.py b/src/pretix/base/exporter.py
index a6058930b5..fb85dc3ab3 100644
--- a/src/pretix/base/exporter.py
+++ b/src/pretix/base/exporter.py
@@ -207,10 +207,13 @@ class ListExporter(BaseExporter):
def get_filename(self):
return 'export'
+ def get_csv_encoding(self):
+ return 'utf-8'
+
def _render_csv(self, form_data, output_file=None, **kwargs):
if output_file:
if 'b' in output_file.mode:
- output_file = io.TextIOWrapper(output_file, encoding='utf-8', newline='')
+ output_file = io.TextIOWrapper(output_file, encoding=self.get_csv_encoding(), errors='replace', newline='')
writer = csv.writer(output_file, **kwargs)
total = 0
counter = 0
@@ -246,7 +249,7 @@ class ListExporter(BaseExporter):
if counter % max(10, total // 100) == 0:
self.progress_callback(counter / total * 100)
writer.writerow(line)
- return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
+ return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode(self.get_csv_encoding(), errors='replace')
def prepare_xlsx_sheet(self, ws):
pass
@@ -256,7 +259,7 @@ class ListExporter(BaseExporter):
ws = wb.create_sheet()
self.prepare_xlsx_sheet(ws)
try:
- ws.title = str(self.verbose_name)
+ ws.title = str(self.verbose_name)[:30]
except:
pass
total = 0
@@ -374,7 +377,7 @@ class MultiSheetListExporter(ListExporter):
wb = SafeWorkbook(write_only=True)
n_sheets = len(self.sheets)
for i_sheet, (s, l) in enumerate(self.sheets):
- ws = wb.create_sheet(str(l))
+ ws = wb.create_sheet(str(l)[:30])
if hasattr(self, 'prepare_xlsx_sheet_' + s):
getattr(self, 'prepare_xlsx_sheet_' + s)(ws)
diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py
index a0cc2208d8..bf3c9e87c3 100644
--- a/src/pretix/base/exporters/orderlist.py
+++ b/src/pretix/base/exporters/orderlist.py
@@ -560,7 +560,7 @@ class OrderListExporter(MultiSheetListExporter):
),
).select_related(
'order', 'order__invoice_address', 'order__customer', 'item', 'variation',
- 'voucher', 'tax_rule'
+ 'voucher', 'tax_rule', 'addon_to',
).prefetch_related(
'subevent', 'subevent__meta_values',
'answers', 'answers__question', 'answers__options'
@@ -619,6 +619,7 @@ class OrderListExporter(MultiSheetListExporter):
_('Valid until'),
_('Order comment'),
_('Follow-up date'),
+ _('Add-on to position ID'),
]
questions = list(Question.objects.filter(event__in=self.events))
@@ -652,7 +653,8 @@ class OrderListExporter(MultiSheetListExporter):
_('VAT ID'),
]
headers += [
- _('Sales channel'), _('Order locale'),
+ _('Sales channel'),
+ _('Order locale'),
_('E-mail address verified'),
_('External customer ID'),
_('Check-in lists'),
@@ -743,6 +745,7 @@ class OrderListExporter(MultiSheetListExporter):
]
row.append(order.comment)
row.append(order.custom_followup_at.strftime("%Y-%m-%d") if order.custom_followup_at else "")
+ row.append(op.addon_to.positionid if op.addon_to_id else "")
acache = {}
for a in op.answers.all():
# We do not want to localize Date, Time and Datetime question answers, as those can lead
diff --git a/src/pretix/base/forms/widgets.py b/src/pretix/base/forms/widgets.py
index 917b339d38..69620b201e 100644
--- a/src/pretix/base/forms/widgets.py
+++ b/src/pretix/base/forms/widgets.py
@@ -38,6 +38,7 @@ from datetime import datetime
from django import forms
from django.utils.formats import get_format
from django.utils.functional import lazy
+from django.utils.html import escape
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _
@@ -64,7 +65,7 @@ def format_placeholders_help_text(placeholders, event=None):
placeholders = [(k, v.render_sample(event) if event else v) for k, v in placeholders.items()]
placeholders.sort(key=lambda x: x[0])
phs = [
- '' % (_("Sample: %s") % v if v else "", k)
+ '' % (escape(_("Sample: %s") % v) if v else "", escape(k))
for k, v in placeholders
]
return _('Available placeholders: {list}').format(
diff --git a/src/pretix/base/invoice.py b/src/pretix/base/invoice.py
index 0b391cfd0d..30b1d3207d 100644
--- a/src/pretix/base/invoice.py
+++ b/src/pretix/base/invoice.py
@@ -30,7 +30,7 @@ from typing import Tuple
import bleach
import vat_moss.exchange_rates
-from bidi.algorithm import get_display
+from bidi import get_display
from django.contrib.staticfiles import finders
from django.db.models import Sum
from django.dispatch import receiver
diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py
index f69da42754..3f0f3bea75 100644
--- a/src/pretix/base/models/checkin.py
+++ b/src/pretix/base/models/checkin.py
@@ -102,9 +102,9 @@ class CheckinList(LoggedModel):
auto_checkin_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_('Sales channels to automatically check in'),
- help_text=_('All items on this check-in list will be automatically marked as checked-in when purchased through '
- 'any of the selected sales channels. This option can be useful when tickets sold at the box office '
- 'are not checked again before entry and should be considered validated directly upon purchase.'),
+ help_text=_('This option is deprecated and will be removed in the next months. As a replacement, our new plugin '
+ '"Auto check-in" can be used. When we remove this option, we will automatically migrate your event '
+ 'to use the new plugin.'),
blank=True,
)
rules = models.JSONField(default=dict, blank=True)
diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py
index 29876ba06a..8a02cf5ebe 100644
--- a/src/pretix/base/models/event.py
+++ b/src/pretix/base/models/event.py
@@ -60,7 +60,6 @@ from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.formats import date_format
from django.utils.functional import cached_property
-from django.utils.html import format_html
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext, gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
@@ -180,14 +179,10 @@ class EventMixin:
"""
tz = tz or self.timezone
if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
- if as_html:
- return format_html(
- "",
- _date(self.date_from.astimezone(tz), "Y-m-d"),
- _date(self.date_from.astimezone(tz), "DATE_FORMAT"),
- )
- return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
- return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz), as_html)
+ df, dt = self.date_from, self.date_from
+ else:
+ df, dt = self.date_from, self.date_to
+ return daterange(df.astimezone(tz), dt.astimezone(tz), as_html)
def get_date_range_display_as_html(self, tz=None, force_show_end=False) -> str:
return self.get_date_range_display(tz, force_show_end, as_html=True)
diff --git a/src/pretix/base/models/seating.py b/src/pretix/base/models/seating.py
index c55d635508..b96a034895 100644
--- a/src/pretix/base/models/seating.py
+++ b/src/pretix/base/models/seating.py
@@ -185,7 +185,7 @@ class Seat(models.Model):
@classmethod
def annotated(cls, qs, event_id, subevent, ignore_voucher_id=None, minimal_distance=0,
- ignore_order_id=None, ignore_cart_id=None, distance_only_within_row=False):
+ ignore_order_id=None, ignore_cart_id=None, distance_only_within_row=False, annotate_ids=False):
from . import CartPosition, Order, OrderPosition, Voucher
vqs = Voucher.objects.filter(
@@ -214,17 +214,24 @@ class Seat(models.Model):
)
if ignore_cart_id:
cqs = cqs.exclude(cart_id=ignore_cart_id)
- qs_annotated = qs.annotate(
- has_order=Exists(
- opqs
- ),
- has_cart=Exists(
- cqs
- ),
- has_voucher=Exists(
- vqs
+ if annotate_ids:
+ qs_annotated = qs.annotate(
+ orderposition_id=Subquery(opqs.values('id')),
+ cartposition_id=Subquery(cqs.values('id')),
+ voucher_id=Subquery(vqs.values('id')),
+ )
+ else:
+ qs_annotated = qs.annotate(
+ has_order=Exists(
+ opqs
+ ),
+ has_cart=Exists(
+ cqs
+ ),
+ has_voucher=Exists(
+ vqs
+ )
)
- )
if minimal_distance > 0:
# TODO: Is there a more performant implementation on PostgreSQL using
@@ -235,7 +242,11 @@ class Seat(models.Model):
Power(F('y') - OuterRef('y'), Value(2), output_field=models.FloatField())
)
).filter(
- Q(has_order=True) | Q(has_cart=True) | Q(has_voucher=True),
+ (
+ (Q(orderposition_id__isnull=False) | Q(cartposition_id__isnull=False) | Q(voucher_id__isnull=False))
+ if annotate_ids else
+ (Q(has_order=True) | Q(has_cart=True) | Q(has_voucher=True))
+ ),
distance__lt=minimal_distance ** 2
)
if distance_only_within_row:
diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py
index 2f4148879a..7e9c9611cc 100644
--- a/src/pretix/base/payment.py
+++ b/src/pretix/base/payment.py
@@ -587,7 +587,7 @@ class BasePaymentProvider:
return rel_date.datetime(self.event).date()
def _is_available_by_time(self, now_dt=None, cart_id=None, order=None):
- now_dt = now_dt or now()
+ now_dt = now_dt or time_machine_now()
tz = ZoneInfo(self.event.settings.timezone)
try:
diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py
index 96d4d1f0ee..5e9834ead8 100644
--- a/src/pretix/base/pdf.py
+++ b/src/pretix/base/pdf.py
@@ -49,7 +49,7 @@ from io import BytesIO
import jsonschema
import reportlab.rl_config
-from bidi.algorithm import get_display
+from bidi import get_display
from django.conf import settings
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
@@ -956,7 +956,7 @@ class Renderer:
)
canvas.restoreState()
- def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
+ def _text_paragraph(self, op: OrderPosition, order: Order, o: dict, legacy_lineheight=False, override_fontsize=None):
font = o['fontfamily']
# Since pdfmetrics.registerFont is global, we want to make sure that no one tries to sneak in a font, they
@@ -970,12 +970,13 @@ class Renderer:
if o['italic']:
font += ' I'
+ fontsize = override_fontsize if override_fontsize is not None else float(o['fontsize'])
try:
- ad = getAscentDescent(font, float(o['fontsize']))
+ ad = getAscentDescent(font, fontsize)
except KeyError: # font not known, fall back
logger.warning(f'Use of unknown font "{font}"')
font = 'Open Sans'
- ad = getAscentDescent(font, float(o['fontsize']))
+ ad = getAscentDescent(font, fontsize)
align_map = {
'left': TA_LEFT,
@@ -985,16 +986,17 @@ class Renderer:
# lineheight display differs from browser canvas. This calc is just empirical values to get
# reportlab render similarly to browser canvas.
# for backwards compatability use „uncorrected“ lineheight of 1.0 instead of 1.15
- lineheight = float(o['lineheight']) * 1.15 if 'lineheight' in o else 1.0
+ lineheight = float(o['lineheight']) * 1.15 if not legacy_lineheight or 'lineheight' in o else 1.0
style = ParagraphStyle(
name=uuid.uuid4().hex,
fontName=font,
- fontSize=float(o['fontsize']),
- leading=lineheight * float(o['fontsize']),
+ fontSize=fontsize,
+ leading=lineheight * fontsize,
# for backwards compatability use autoLeading if no lineheight is given
- autoLeading='off' if 'lineheight' in o else 'max',
+ autoLeading='off' if not legacy_lineheight or 'lineheight' in o else 'max',
textColor=Color(o['color'][0] / 255, o['color'][1] / 255, o['color'][2] / 255),
- alignment=align_map[o['align']]
+ alignment=align_map[o['align']],
+ splitLongWords=o.get('splitlongwords', True),
)
# add an almost-invisible space   after hyphens as word-wrap in ReportLab only works on space chars
text = conditional_escape(
@@ -1013,6 +1015,41 @@ class Renderer:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
p = Paragraph(text, style=style)
+ return p, ad, lineheight
+
+ def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
+ fontsize = float(o['fontsize'])
+ height = float(o['height']) * mm
+ width = float(o['width']) * mm
+ while True:
+ p, ad, lineheight = self._text_paragraph(op, order, o, override_fontsize=fontsize)
+ w, h = p.wrapOn(canvas, width, 1000 * mm)
+ widths = p.getActualLineWidths0()
+ if not widths:
+ break
+ actual_w = max(widths)
+ if not o.get('autoresize', False) or (h <= height and actual_w <= width) or fontsize <= 1.0:
+ break
+ if h > height: # we can do larger steps for height
+ fontsize -= max(1.0, fontsize * .1)
+ else:
+ fontsize -= max(.25, fontsize * .025)
+
+ canvas.saveState()
+ # The ascent/descent offsets here are not really proven to be correct, they're just empirical values to get
+ # reportlab render similarly to browser canvas.
+ canvas.translate(float(o['left']) * mm, float(o['bottom']) * mm + height)
+ canvas.rotate(o.get('rotation', 0) * -1)
+ if o.get('verticalalign', 'top') == 'top':
+ p.drawOn(canvas, 0, - h)
+ elif o.get('verticalalign', 'top') == 'middle':
+ p.drawOn(canvas, 0, (-height - h) / 2)
+ elif o.get('verticalalign', 'top') == 'bottom':
+ p.drawOn(canvas, 0, -height)
+ canvas.restoreState()
+
+ def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
+ p, ad, lineheight = self._text_paragraph(op, order, o, legacy_lineheight=True)
w, h = p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
# p_size = p.wrap(float(o['width']) * mm, 1000 * mm)
canvas.saveState()
@@ -1051,6 +1088,8 @@ class Renderer:
self._draw_barcodearea(canvas, op, order, o)
elif o['type'] == "imagearea":
self._draw_imagearea(canvas, op, order, o)
+ elif o['type'] == "textcontainer":
+ self._draw_textcontainer(canvas, op, order, o)
elif o['type'] == "textarea":
self._draw_textarea(canvas, op, order, o)
elif o['type'] == "poweredby":
diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py
index b8d94c7df9..7028add1f0 100644
--- a/src/pretix/base/services/checkin.py
+++ b/src/pretix/base/services/checkin.py
@@ -1154,7 +1154,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
)
-@receiver(order_placed, dispatch_uid="autocheckin_order_placed")
+@receiver(order_placed, dispatch_uid="legacy_autocheckin_order_placed")
def order_placed(sender, **kwargs):
order = kwargs['order']
event = sender
@@ -1171,7 +1171,7 @@ def order_placed(sender, **kwargs):
checkin_created.send(event, checkin=ci)
-@receiver(periodic_task, dispatch_uid="autocheckin_exit_all")
+@receiver(periodic_task, dispatch_uid="autocheckout_exit_all")
@scopes_disabled()
def process_exit_all(sender, **kwargs):
qs = CheckinList.objects.filter(
diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py
index a1746334c6..ae5387ebc6 100644
--- a/src/pretix/base/services/mail.py
+++ b/src/pretix/base/services/mail.py
@@ -58,6 +58,7 @@ from django.core.mail import (
from django.core.mail.message import SafeMIMEText
from django.db import transaction
from django.template.loader import get_template
+from django.utils.html import escape
from django.utils.timezone import now, override
from django.utils.translation import gettext as _, pgettext
from django_scopes import scope, scopes_disabled
@@ -109,6 +110,22 @@ def clean_sender_name(sender_name: str) -> str:
return sender_name
+def prefix_subject(settings_holder, subject, highlight=False):
+ prefix = settings_holder.settings.get('mail_prefix')
+ if prefix and prefix.startswith('[') and prefix.endswith(']'):
+ prefix = prefix[1:-1]
+ if prefix:
+ prefix = f"[{prefix}]"
+ if highlight:
+ prefix = '{}'.format(
+ _('This prefix has been set in your event or organizer settings.'),
+ escape(prefix)
+ )
+
+ subject = f"{prefix} {subject}"
+ return subject
+
+
def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
@@ -240,11 +257,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = settings_holder.settings.contact_mail
- prefix = settings_holder.settings.get('mail_prefix')
- if prefix and prefix.startswith('[') and prefix.endswith(']'):
- prefix = prefix[1:-1]
- if prefix:
- subject = "[%s] %s" % (prefix, subject)
+ subject = prefix_subject(settings_holder, subject)
body_plain += "\r\n\r\n-- \r\n"
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index b48eec48d1..2e08f9316c 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -3152,7 +3152,7 @@ def signal_listener_issue_memberships(sender: Event, order: Order, **kwargs):
if order.status != Order.STATUS_PAID or not order.customer:
return
for p in order.positions.all():
- if p.item.grant_membership_type_id:
+ if p.item.grant_membership_type_id and not p.granted_memberships.exists():
create_membership(order.customer, p)
diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py
index 2b31904457..48b5cfa47e 100644
--- a/src/pretix/base/settings.py
+++ b/src/pretix/base/settings.py
@@ -1295,7 +1295,8 @@ DEFAULTS = {
'form_kwargs': dict(
label=_("Show event times and dates on the ticket shop"),
help_text=_("If disabled, no date or time will be shown on the ticket shop's front page. This settings "
- "does however not affect the display in other locations."),
+ "also affects a few other locations, however it should not be expected that the date of the "
+ "event is shown nowhere to users."),
)
},
'show_date_to': {
@@ -1480,7 +1481,7 @@ DEFAULTS = {
widget=forms.NumberInput(),
help_text=_('With an increased limit, a customer may request more than one ticket for a specific product '
'using the same, unique email address. However, regardless of this setting, they will need to '
- 'fill the waitlist form multiple times if they want more than one ticket, as every entry only '
+ 'fill the waiting list form multiple times if they want more than one ticket, as every entry only '
'grants one single ticket at a time.'),
)
},
@@ -3363,7 +3364,9 @@ Your {organizer} team""")) # noqa: W291
},
'seating_allow_blocked_seats_for_channel': {
'default': [],
- 'type': list
+ 'type': list,
+ 'serializer_class': serializers.ListField,
+ 'serializer_kwargs': lambda: dict(child=serializers.CharField()),
},
'seating_distance_within_row': {
'default': 'False',
@@ -3801,6 +3804,16 @@ def validate_event_settings(event, settings_dict):
'payment_term_last': _('The last payment date cannot be before the end of presale.')
})
+ if settings_dict.get('seating_allow_blocked_seats_for_channel'):
+ allowed_channels = set(event.organizer.sales_channels.values_list("identifier", flat=True))
+ for channel in settings_dict['seating_allow_blocked_seats_for_channel']:
+ if channel not in allowed_channels:
+ raise ValidationError({
+ 'seating_allow_blocked_seats_for_channel': _('The value "{identifier}" is not a valid sales channel.').format(
+ identifier=channel
+ )
+ })
+
if isinstance(event, Event):
validate_event_settings.send(sender=event, settings_dict=settings_dict)
diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py
index 4f26abc8ba..f3c5ce258a 100644
--- a/src/pretix/control/forms/filter.py
+++ b/src/pretix/control/forms/filter.py
@@ -48,6 +48,7 @@ from django.utils.formats import date_format, localize
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
+from django_countries.fields import CountryField
from django_scopes.forms import SafeModelChoiceField
from pretix.base.forms.widgets import (
@@ -60,6 +61,7 @@ from pretix.base.models import (
SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher,
)
from pretix.base.signals import register_payment_providers
+from pretix.control.forms import SplitDateTimeField
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
from pretix.control.signals import order_search_filter_q
from pretix.helpers.countries import CachedCountries
@@ -67,7 +69,7 @@ from pretix.helpers.database import (
get_deterministic_ordering, rolledback_transaction,
)
from pretix.helpers.dicts import move_to_end
-from pretix.helpers.i18n import i18ncomp
+from pretix.helpers.i18n import get_format_without_seconds, i18ncomp
PAYMENT_PROVIDERS = []
@@ -687,11 +689,71 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
)
self.fields['quota'].widget.choices = self.fields['quota'].choices
for q in self.event.questions.all():
- self.fields['question_{}'.format(q.pk)] = forms.CharField(
- label=q.question,
- required=False,
- help_text=_('Exact matches only')
- )
+ kwargs = {
+ "label": q.question,
+ "required": False,
+ }
+ fname = 'question_{}'.format(q.pk)
+ if q.type == Question.TYPE_NUMBER:
+ self.fields[fname] = forms.DecimalField(
+ help_text=_('Exact matches only'),
+ **kwargs,
+ )
+ elif q.type == Question.TYPE_BOOLEAN:
+ self.fields[fname] = forms.ChoiceField(
+ choices=(
+ ("", ""),
+ ("True", _("Yes")),
+ ("False", _("No")),
+ ),
+ **kwargs,
+ )
+ elif q.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
+ self.fields[fname] = forms.ModelChoiceField(
+ queryset=q.options,
+ widget=forms.Select,
+ to_field_name='identifier',
+ empty_label='',
+ **kwargs,
+ )
+ elif q.type == Question.TYPE_COUNTRYCODE:
+ self.fields[fname] = CountryField(
+ countries=CachedCountries,
+ blank=True, null=True, blank_label=' ',
+ ).formfield(
+ **kwargs,
+ widget=forms.Select,
+ empty_label=' ',
+ )
+ elif q.type == Question.TYPE_DATE:
+ self.fields[fname] = forms.DateField(
+ widget=DatePickerWidget(),
+ help_text=_('Exact matches only'),
+ **kwargs,
+ )
+ elif q.type == Question.TYPE_TIME:
+ self.fields[fname] = forms.TimeField(
+ widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
+ help_text=_('Exact matches only'),
+ **kwargs,
+ )
+ elif q.type == Question.TYPE_DATETIME:
+ self.fields[fname] = SplitDateTimeField(
+ widget=SplitDateTimePickerWidget(
+ time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
+ min_date=q.valid_datetime_min,
+ max_date=q.valid_datetime_max
+ ),
+ help_text=_('Exact matches only'),
+ **kwargs,
+ )
+ elif q.type == Question.TYPE_FILE:
+ continue
+ else:
+ self.fields[fname] = forms.CharField(
+ help_text=_('Exact matches only'),
+ **kwargs,
+ )
def filter_qs(self, qs):
fdata = self.cleaned_data
@@ -787,11 +849,24 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
).distinct()
for q in self.event.questions.all():
if fdata.get(f'question_{q.pk}'):
- answers = QuestionAnswer.objects.filter(
- question_id=q.pk,
- orderposition__order_id=OuterRef('pk'),
- answer__iexact=fdata.get(f'question_{q.pk}')
- )
+ if q.type == Question.TYPE_BOOLEAN:
+ answers = QuestionAnswer.objects.filter(
+ question_id=q.pk,
+ orderposition__order_id=OuterRef('pk'),
+ answer__exact=fdata.get(f'question_{q.pk}')
+ )
+ elif q.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
+ answers = QuestionAnswer.objects.filter(
+ question_id=q.pk,
+ orderposition__order_id=OuterRef('pk'),
+ options=fdata.get(f'question_{q.pk}')
+ )
+ else:
+ answers = QuestionAnswer.objects.filter(
+ question_id=q.pk,
+ orderposition__order_id=OuterRef('pk'),
+ answer__iexact=fdata.get(f'question_{q.pk}')
+ )
qs = qs.annotate(**{f'q_{q.pk}': Exists(answers)}).filter(**{f'q_{q.pk}': True})
return qs
@@ -2577,6 +2652,9 @@ class DeviceFilterForm(FilterForm):
if fdata.get('gate'):
qs = qs.filter(gate=fdata['gate'])
+ if fdata.get('software_brand'):
+ qs = qs.filter(software_brand=fdata['software_brand'])
+
if fdata.get('state') == 'active':
qs = qs.filter(revoked=False)
elif fdata.get('state') == 'revoked':
diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py
index 3f5c667964..e2e695bd79 100644
--- a/src/pretix/control/forms/organizer.py
+++ b/src/pretix/control/forms/organizer.py
@@ -58,7 +58,8 @@ from pretix.api.models import WebHook
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.customersso.oidc import oidc_validate_and_complete_config
from pretix.base.forms import (
- I18nMarkdownTextarea, I18nModelForm, PlaceholderValidator, SettingsForm,
+ SECRET_REDACTED, I18nMarkdownTextarea, I18nModelForm, PlaceholderValidator,
+ SecretKeySettingsField, SettingsForm,
)
from pretix.base.forms.questions import (
NamePartsFormField, WrappedPhoneNumberPrefixWidget, get_country_by_locale,
@@ -958,7 +959,7 @@ class SSOProviderForm(I18nModelForm):
label=pgettext_lazy('sso_oidc', 'Client ID'),
required=False,
)
- config_oidc_client_secret = forms.CharField(
+ config_oidc_client_secret = SecretKeySettingsField(
label=pgettext_lazy('sso_oidc', 'Client secret'),
required=False,
)
@@ -1015,7 +1016,13 @@ class SSOProviderForm(I18nModelForm):
if self.instance and self.instance.method == method:
f.initial = self.instance.configuration.get(suffix)
+ def _unmask_secret_fields(self):
+ for k, v in self.cleaned_data.items():
+ if isinstance(self.fields.get(k), SecretKeySettingsField) and self.cleaned_data.get(k) == SECRET_REDACTED:
+ self.cleaned_data[k] = self.fields[k].initial
+
def clean(self):
+ self._unmask_secret_fields()
data = self.cleaned_data
if not data.get("method"):
return data
diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py
index e1ffa823bd..c45fc9e2e1 100644
--- a/src/pretix/control/forms/vouchers.py
+++ b/src/pretix/control/forms/vouchers.py
@@ -49,6 +49,7 @@ from pretix.base.forms import (
I18nModelForm, MarkdownTextarea, PlaceholderValidator,
)
from pretix.base.forms.widgets import format_placeholders_help_text
+from pretix.base.i18n import language
from pretix.base.models import Item, Voucher
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
@@ -289,8 +290,9 @@ class VoucherBulkForm(VoucherForm):
)
}),
required=False,
- help_text=_('You can either supply a list of email addresses with one email address per line, or a CSV file with a title column '
- 'and one or more of the columns "email", "number", "name", or "tag".')
+ help_text=_('You can either supply a list of email addresses with one email address per line, or the contents '
+ 'of a CSV file with a title column and one or more of the columns "email", "number", "name", '
+ 'or "tag".')
)
Recipient = namedtuple('Recipient', 'email number name tag')
@@ -332,6 +334,11 @@ class VoucherBulkForm(VoucherForm):
super().__init__(*args, **kwargs)
self._set_field_placeholders('send_subject', ['event', 'name'])
self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'])
+
+ with language(self.instance.event.settings.locale, self.instance.event.settings.region):
+ for f in ("send_subject", "send_message"):
+ self.fields[f].initial = str(self.fields[f].initial)
+
if 'seat' in self.fields:
self.fields['seats'] = forms.CharField(
label=_("Specific seat IDs"),
diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html
index befac052fb..2b397fbd40 100644
--- a/src/pretix/control/templates/pretixcontrol/event/settings.html
+++ b/src/pretix/control/templates/pretixcontrol/event/settings.html
@@ -372,6 +372,19 @@
{% bootstrap_field sform.waiting_list_enabled layout="control" %}
{% bootstrap_field sform.waiting_list_auto layout="control" %}
+
{% bootstrap_field position.form.itemvar layout='inline' %}
+ {% if position.granted_memberships.all %}
+
+
+ {% trans "The sale of this position created a membership. Changing the product here will not affect the membership. Memberships can be managed in the customer account." %}
+
+ {% endif %}
@@ -254,6 +260,12 @@
{% trans "–" %}
{% bootstrap_field position.form.valid_until layout='inline' %}
+ {% if position.granted_memberships.all %}
+
+
+ {% trans "The sale of this position created a membership. Changing the validity of the ticket here will not affect the membership. Memberships can be managed in the customer account." %}
+
+ {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/team_members.html b/src/pretix/control/templates/pretixcontrol/organizers/team_members.html
index 57c159aa59..4d88699305 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/team_members.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/team_members.html
@@ -102,16 +102,24 @@
- {% for t in team.active_tokens %}
+ {% for t in tokens %}
-
+
+ {% if not t.active %}
+
+ {% endif %}
{{ t.name }}
+ {% if not t.active %}
+
+ {% endif %}
-
+ {% if t.active %}
+
+ {% endif %}
{% endfor %}
diff --git a/src/pretix/control/templates/pretixcontrol/pdf/index.html b/src/pretix/control/templates/pretixcontrol/pdf/index.html
index d9198fa9ff..72eaee686c 100644
--- a/src/pretix/control/templates/pretixcontrol/pdf/index.html
+++ b/src/pretix/control/templates/pretixcontrol/pdf/index.html
@@ -177,7 +177,7 @@
{% if name %}
-
+
@@ -185,11 +185,11 @@
-
+
-
+
@@ -227,7 +227,7 @@
-
+
-
+
-
+
-
+
-
-
+
+
-
+
@@ -349,7 +349,7 @@
-
+
-
+
-
+
-
-
+
+
+
+
+
+
-
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -423,9 +469,13 @@
{% trans "Add a new object" %}
+
-
+
+
+ {% blocktrans trimmed with print_version="2.18" scan_version="1.22" %}
+ This layout uses new features. If you print from your device, make sure you use pretixPRINT version
+ {{ print_version }} (or newer) or pretixSCAN Desktop version {{ scan_version }} (or newer).
+ {% endblocktrans %}
+