Compare commits

..

3 Commits

Author SHA1 Message Date
Raphael Michel
e116299311 Bump to 4.5.1 2022-01-26 13:42:32 +01:00
Raphael Michel
6f14fb176e [SECURITY] Make redirect view dependent on referer 2022-01-26 13:41:13 +01:00
Raphael Michel
b03daab452 [SECURITY] Fix (non-exploitable) XSS issue 2022-01-26 13:41:13 +01:00
437 changed files with 132052 additions and 246361 deletions

View File

@@ -18,17 +18,17 @@ jobs:
name: Tests
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9"]
python-version: [3.6, 3.7, 3.8]
database: [sqlite, postgres, mysql]
exclude:
- database: mysql
python-version: "3.8"
python-version: 3.7
- database: sqlite
python-version: 3.7
- database: mysql
python-version: "3.9"
python-version: 3.6
- database: sqlite
python-version: "3.7"
- database: sqlite
python-version: "3.8"
python-version: 3.6
steps:
- uses: actions/checkout@v2
- uses: getong/mariadb-action@v1.1
@@ -55,7 +55,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system dependencies
run: sudo apt update && sudo apt install gettext mariadb-client-10.3
run: sudo apt update && sudo apt install gettext mysql-client
- name: Install Python dependencies
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
working-directory: ./src

View File

@@ -157,7 +157,7 @@
<div class="rst-content">
{% include "breadcrumbs.html" %}
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody" class="section">
<div itemprop="articleBody">
{% block body %}{% endblock %}
</div>
<div class="articleComments">

View File

@@ -65,9 +65,6 @@ Example::
A comma-separated list of plugins that are not available even though they are installed.
Defaults to an empty string.
``plugins_show_meta``
Whether to show authors and versions of plugins, defaults to ``on``.
``auth_backends``
A comma-separated list of available auth backends. Defaults to ``pretix.base.auth.NativeAuthBackend``.
@@ -223,30 +220,12 @@ Example::
``user``, ``password``
The SMTP user data to use for the connection. Empty by default.
``tls``, ``ssl``
Use STARTTLS or SSL for the SMTP connection. Off by default.
``from``
The email address to set as ``From`` header in outgoing emails by the system.
Default: ``pretix@localhost``
``from_notifications``
The email address to set as ``From`` header in admin notification emails by the system.
Defaults to the value of ``from``.
``from_organizers``
The email address to set as ``From`` header in outgoing emails by the system sent on behalf of organizers.
Defaults to the value of ``from``.
``custom_sender_verification_required``
If this is on (the default), organizers need to verify email addresses they want to use as senders in their event.
``custom_sender_spf_string``
If this is set to a valid SPF string, pretix will show a warning if organizers use a sender address from a domain
that does not include this value.
``custom_smtp_allow_private_networks``
If this is off (the default), custom SMTP servers cannot be private network addresses.
``tls``, ``ssl``
Use STARTTLS or SSL for the SMTP connection. Off by default.
``admins``
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
@@ -303,7 +282,7 @@ You can use an existing memcached server as pretix's caching backend::
``location``
The location of memcached, either a host:port combination or a socket file.
If no memcached is configured, pretix will use redis for caching. If neither is configured, pretix will not use any caching.
If no memcached is configured, pretix will use Django's built-in local-memory caching method.
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
shared memcached instance, not multiple ones, because cache invalidations would not be
@@ -466,10 +445,8 @@ You can configure the maximum file size for uploading various files::
max_size_image = 12
; Max upload size for favicons in MiB, defaults to 1 MiB
max_size_favicon = 2
; Max upload size for email attachments of manually sent emails in MiB, defaults to 10 MiB
; Max upload size for email attachments in MiB, defaults to 10 MiB
max_size_email_attachment = 15
; Max upload size for email attachments of automatically sent emails in MiB, defaults to 1 MiB
max_size_email_auto_attachment = 2
; Max upload size for other files in MiB, defaults to 10 MiB
; This includes all file upload type order questions
max_size_other = 100

View File

@@ -36,6 +36,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
installations except for evaluation purposes.
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
**MariaDB 10.2.7 or newer**.
.. warning:: By default, using `ufw` in conjunction will not have any effect. Please make sure to either bind the exposed
ports of your docker container explicitly to 127.0.0.1 or configure docker to respect any set up firewall
rules.
@@ -58,9 +61,6 @@ directory writable to the user that runs pretix inside the docker container::
Database
--------
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
Next, we need a database and a database user. We can create these with any kind of database managing tool or directly on
our database's shell. Please make sure that UTF8 is used as encoding for the best compatibility. You can check this with
the following command::
@@ -91,8 +91,6 @@ When using MySQL, make sure you set the character set of the database to ``utf8m
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
Redis
-----
@@ -108,18 +106,6 @@ Now restart redis-server::
# systemctl restart redis-server
In this setup, systemd will delete ``/var/run/redis`` on every redis restart, which will cause issues with pretix. To
prevent this, you can execute::
# systemctl edit redis-server
And insert the following::
[Service]
# Keep the directory around so that pretix.service in docker does not need to be
# restarted when redis is restarted.
RuntimeDirectoryPreserve=yes
.. warning:: Setting the socket permissions to 777 is a possible security problem. If you have untrusted users on your
system or have high security requirements, please don't do this and let redis listen to a TCP socket
instead. We recommend the socket approach because the TCP socket in combination with docker's networking

View File

@@ -34,6 +34,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
installations except for evaluation purposes.
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
**MariaDB 10.2.7 or newer**.
Unix user
---------
@@ -47,9 +50,6 @@ In this guide, all code lines prepended with a ``#`` symbol are commands that yo
Database
--------
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
Having the database server installed, we still need a database and a database user. We can create these with any kind
of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the
best compatibility. You can check this with the following command::
@@ -65,8 +65,6 @@ When using MySQL, make sure you set the character set of the database to ``utf8m
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
Package dependencies
--------------------
@@ -144,7 +142,7 @@ If you're running MySQL, also install the client library::
(venv)$ pip3 install mysqlclient
Note that you need Python 3.7 or newer. You can find out your Python version using ``python -V``.
Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
We also need to create a data directory::
@@ -261,14 +259,14 @@ The following snippet is an example on how to configure a nginx proxy for pretix
}
location /static/ {
alias /var/pretix/venv/lib/python3.10/site-packages/pretix/static.dist/;
alias /var/pretix/venv/lib/python3.7/site-packages/pretix/static.dist/;
access_log off;
expires 365d;
add_header Cache-Control "public";
}
}
.. note:: Remember to replace the ``python3.10`` in the ``/static/`` path in the config
.. note:: Remember to replace the ``python3.7`` in the ``/static/`` path in the config
above with your python version.
We recommend reading about setting `strong encryption settings`_ for your web server.

View File

@@ -99,8 +99,7 @@ following endpoint:
"hardware_brand": "Samsung",
"hardware_model": "Galaxy S",
"software_brand": "pretixdroid",
"software_version": "4.1.0",
"info": {"arbitrary": "data"}
"software_version": "4.1.0"
}
You will receive a response equivalent to the response of your initialization request.

View File

@@ -43,8 +43,6 @@ Possible permissions are:
* Can view vouchers
* Can change vouchers
.. _`rest-compat`:
Compatibility
-------------
@@ -60,7 +58,6 @@ that your clients can deal with them properly:
* Support of new HTTP methods for a given API endpoint
* Support of new query parameters for a given API endpoint
* New fields contained in API responses
* Response body structure or message texts on failed requests (``4xx``, ``5xx`` response codes)
We treat the following types of changes as *backwards-incompatible*:

View File

@@ -97,8 +97,7 @@ For example, if you want users to be redirected to ``https://example.org/order/r
either enter ``https://example.org`` or ``https://example.org/order/``.
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
**regardless of whether it was successful or not**. We will append an ``error=…`` query parameter with an error
message, but you should not rely on that and instead make sure you use our API to check if the payment actually
**regardless of whether it was successful or not**. Make sure you use our API to check if the payment actually
worked! Your final URL could look like this::
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234

View File

@@ -189,7 +189,7 @@ Cart position endpoints
* ``attendee_email`` (optional)
* ``subevent`` (optional)
* ``expires`` (optional)
* ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
* ``includes_tax`` (optional)
* ``sales_channel`` (optional)
* ``answers``

View File

@@ -14,7 +14,6 @@ The customer resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
identifier string Internal ID of the customer
external_identifier string External ID of the customer (or ``null``)
email string Customer email address
name string Name of this customer (or ``null``)
name_parts object of strings Decomposition of name (i.e. given name, family name)
@@ -25,7 +24,6 @@ last_login datetime Date and time o
date_joined datetime Date and time of registration
locale string Preferred language of the customer
last_modified datetime Date and time of modification of the record
notes string Internal notes and comments (or ``null``)
===================================== ========================== =======================================================
.. versionadded:: 4.0
@@ -60,7 +58,6 @@ Endpoints
"results": [
{
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "customer@example.org",
"name": "John Doe",
"name_parts": {
@@ -72,8 +69,7 @@ Endpoints
"last_login": null,
"date_joined": "2021-04-06T13:44:22.809216Z",
"locale": "de",
"last_modified": "2021-04-06T13:44:22.809377Z",
"notes": null
"last_modified": "2021-04-06T13:44:22.809377Z"
}
]
}
@@ -107,7 +103,6 @@ Endpoints
{
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "customer@example.org",
"name": "John Doe",
"name_parts": {
@@ -119,8 +114,7 @@ Endpoints
"last_login": null,
"date_joined": "2021-04-06T13:44:22.809216Z",
"locale": "de",
"last_modified": "2021-04-06T13:44:22.809377Z",
"notes": null
"last_modified": "2021-04-06T13:44:22.809377Z"
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -156,7 +150,6 @@ Endpoints
{
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "test@example.org",
...
}
@@ -200,7 +193,6 @@ Endpoints
{
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "test@example.org",
}
@@ -234,7 +226,6 @@ Endpoints
{
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": null,
}

View File

@@ -1,306 +0,0 @@
.. _`rest-discounts`:
Discounts
=========
Resource description
--------------------
Discounts provide a way to automatically reduce the price of a cart if it matches a given set of conditions.
Discounts are available to everyone. If you want to give a discount just to specific persons, look at
:ref:`vouchers <rest-vouchers>` instead. If you are interested in the behind-the-scenes details of how
discounts are calculated for a specific order, have a look at :ref:`our algorithm documentation <algorithms-pricing>`.
.. rst-class:: rest-resource-table
======================================== ========================== =======================================================
Field Type Description
======================================== ========================== =======================================================
id integer Internal ID of the discount rule
active boolean The discount will be ignored if this is ``false``
internal_name string A name for the rule used in the backend
position integer An integer, used for sorting the rules which are applied in order
sales_channels list of strings Sales channels this discount is available on, such as
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
available_from datetime The first date time at which this discount can be applied
(or ``null``).
available_until datetime The last date time at which this discount can be applied
(or ``null``).
subevent_mode strings Determines how the discount is handled when used in an
event series. Can be ``"mixed"`` (no special effect),
``"same"`` (discount is only applied for groups within
the same date), or ``"distinct"`` (discount is only applied
for groups with no two same dates).
condition_all_products boolean If ``true``, the discount applies to all items.
condition_limit_products list of integers If ``condition_all_products`` is not set, this is a list
of internal item IDs that the discount applies to.
condition_apply_to_addons boolean If ``true``, the discount applies to add-on products as well,
otherwise it only applies to top-level items. The discount never
applies to bundled products.
condition_ignore_voucher_discounted boolean If ``true``, the discount does not apply to products which have
been discounted by a voucher.
condition_min_count integer The minimum number of matching products for the discount
to be activated.
condition_min_value money (string) The minimum value of matching products for the discount
to be activated. Cannot be combined with ``condition_min_count``,
or with ``subevent_mode`` set to ``distinct``.
benefit_discount_matching_percent decimal (string) The percentage of price reduction for matching products.
benefit_only_apply_to_cheapest_n_matches integer If set higher than 0, the discount will only be applied to
the cheapest matches. Useful for a "3 for 2"-style discount.
Cannot be combined with ``condition_min_value``.
======================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/discounts/
Returns a list of all discounts within a given event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/discounts/ 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,
"active": true,
"internal_name": "3 for 2",
"position": 1,
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:query boolean active: If set to ``true`` or ``false``, only discounts with this value for the field ``active`` will be
returned.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``.
Default: ``position``
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/discounts/(id)/
Returns information on one discount, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/discounts/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,
"active": true,
"internal_name": "3 for 2",
"position": 1,
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
: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 discount to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/discounts/
Creates a new discount
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/discounts/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"active": true,
"internal_name": "3 for 2",
"position": 1,
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"active": true,
"internal_name": "3 for 2",
"position": 1,
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
:param organizer: The ``slug`` field of the organizer of the event to create a discount for
:param event: The ``slug`` field of the event to create a discount for
:statuscode 201: no error
:statuscode 400: The discount 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 this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/discounts/(id)/
Update a discount. 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.
You can change all fields of the resource except the ``id`` field.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/discounts/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"active": false
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"active": false,
"internal_name": "3 for 2",
"position": 1,
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
: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 discount to modify
:statuscode 200: no error
:statuscode 400: The discount could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/discount/(id)/
Delete a discount.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/discount/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 discount to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.

View File

@@ -1,11 +1,6 @@
Resources and endpoints
=======================
With a few exceptions, this only lists resources bundled in the pretix core modules.
Additional endpoints are provided by pretix plugins. Some of them are documented
at :ref:`plugin-docs`.
.. toctree::
:maxdepth: 2
@@ -24,7 +19,6 @@ at :ref:`plugin-docs`.
orders
invoices
vouchers
discounts
checkinlists
waitinglist
customers
@@ -39,4 +33,4 @@ at :ref:`plugin-docs`.
exporters
sendmail_rules
billing_invoices
billing_var
billing_var

View File

@@ -58,12 +58,6 @@ lines list of objects The actual invo
created before this field was introduced as well as for
all lines not created by a product (e.g. a shipping or
cancellation fee).
├ subevent integer Event series date ID used to create this line. Note that everything
about the subevent might have changed since the creation
of the invoice. Can be ``null`` for all invoice lines
created before this field was introduced as well as for
all lines not created by a product (e.g. a shipping or
cancellation fee) as well as for all events that are not a series.
├ fee_type string Fee type, e.g. ``shipping``, ``service``, ``payment``,
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
all invoice lines
@@ -126,10 +120,6 @@ internal_reference string Customer's refe
The attribute ``lines.event_location`` has been added.
.. versionchanged:: 4.6
The attribute ``lines.subevent`` has been added.
Endpoints
---------
@@ -195,7 +185,6 @@ Endpoints
"description": "Budget Ticket",
"item": 1234,
"variation": 245,
"subevent": null,
"fee_type": null,
"fee_internal_type": null,
"event_date_from": "2017-12-27T10:00:00Z",
@@ -285,7 +274,6 @@ Endpoints
"description": "Budget Ticket",
"item": 1234,
"variation": 245,
"subevent": null,
"fee_type": null,
"fee_internal_type": null,
"event_date_from": "2017-12-27T10:00:00Z",

View File

@@ -24,9 +24,6 @@ active boolean If ``false``, t
description multi-lingual string A public description of the variation. May contain
Markdown syntax or can be ``null``.
position integer An integer, used for sorting
require_approval boolean If ``true``, orders with this variation will need to be
approved by the event organizer before they can be
paid.
require_membership boolean If ``true``, booking this variation requires an active membership.
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
be hidden from users without a valid membership.
@@ -79,7 +76,6 @@ Endpoints
"en": "S"
},
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
@@ -101,7 +97,6 @@ Endpoints
"en": "L"
},
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
@@ -152,7 +147,6 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
@@ -189,7 +183,6 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
@@ -216,7 +209,6 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": true,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
@@ -274,7 +266,6 @@ Endpoints
"price": "10.00",
"original_price": null,
"active": false,
"require_approval": false,
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],

View File

@@ -68,7 +68,6 @@ positions list of objects List of order p
non-canceled positions are included.
fees list of objects List of fees included in the order total. By default, only
non-canceled fees are included.
├ id integer Internal ID of the fee record
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
``other``)
├ value money (string) Fee amount
@@ -133,14 +132,6 @@ last_modified datetime Last modificati
The ``item`` and ``variation`` query parameters have been added.
.. versionchanged:: 4.6
The ``subevent`` query parameters has been added.
.. versionchanged:: 4.8
The ``order.fees.id`` attribute has been added.
.. _order-position-resource:
@@ -442,7 +433,6 @@ List of all orders
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
you will not notice it using this method.
:query datetime created_since: Only return orders that have been created since the given date.
:query integer subevent: Only return orders with a position that contains this subevent ID. *Warning:* Result will also include orders if they contain mixed subevents, and it will even return orders where the subevent is only contained in a canceled position.
:query datetime subevent_after: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive after, and it considers the **end** of the subevent (or its start, if the end is not set).
:query datetime subevent_before: Only return orders that contain a ticket for a subevent taking place after the given date. This is an exclusive before, and it considers the **start** of the subevent.
:query string exclude: Exclude a field from the output, e.g. ``fees`` or ``positions.downloads``. Can be used as a performance optimization. Can be passed multiple times.
@@ -740,37 +730,6 @@ Generating new secrets
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/regenerate_secrets/
Triggers generation of a new ``secret`` attribute for a single order position.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23/regenerate_secrets/ 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
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``id`` field of the order position to update
:statuscode 200: no error
:statuscode 400: The order position could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position.
Deleting orders
---------------
@@ -875,7 +834,6 @@ Creating orders
* ``comment`` (optional)
* ``custom_followup_at`` (optional)
* ``checkin_attention`` (optional)
* ``require_approval`` (optional)
* ``invoice_address`` (optional)
* ``company``
@@ -935,9 +893,8 @@ Creating orders
* ``force`` (optional). If set to ``true``, quotas will be ignored.
* ``send_email`` (optional). If set to ``true``, the same emails will be sent as for a regular order, regardless of
whether these emails are enabled for certain sales channels. If set to ``null``, behavior will be controlled by pretix'
settings based on the sales channels (added in pretix 4.7). Defaults to ``false``.
Used to be ``send_mail`` before pretix 3.14.
whether these emails are enabled for certain sales channels. Defaults to
``false``. Used to be ``send_mail`` before pretix 3.14.
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
to incrementing integers starting with ``1``. Then, you can reference one of these
@@ -1082,9 +1039,6 @@ Order state operations
will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation
fee as the only component of the order.
You can control whether the customer is notified through ``send_email`` (defaults to ``true``).
You can pass a ``comment`` that can be visible to the user if it is used in the email template.
**Example request**:
.. sourcecode:: http
@@ -1096,7 +1050,6 @@ Order state operations
{
"send_email": true,
"comment": "Event was canceled.",
"cancellation_fee": null
}
@@ -1682,8 +1635,6 @@ Order position ticket download
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
.. _rest-orderpositions-manipulate:
Manipulating individual positions
---------------------------------
@@ -1691,11 +1642,6 @@ Manipulating individual positions
The ``PATCH`` method has been added for individual positions.
.. versionchanged:: 4.8
The ``PATCH`` method now supports changing items, variations, subevents, seats, prices, and tax rules.
The ``POST`` endpoint to add individual positions has been added.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Updates specific fields on an order position. Currently, only the following fields are supported:
@@ -1722,21 +1668,6 @@ Manipulating individual positions
and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value
``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it.
* ``item``
* ``variation``
* ``subevent``
* ``seat`` (specified as a string mapping to a ``string_guid``)
* ``price``
* ``tax_rule``
Changing parameters such as ``item`` or ``price`` will **not** automatically trigger creation of a new invoice,
you need to take care of that yourself.
**Example request**:
.. sourcecode:: http
@@ -1758,7 +1689,7 @@ Manipulating individual positions
Vary: Accept
Content-Type: application/json
(Full order position resource, see above.)
(Full order resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
@@ -1769,83 +1700,9 @@ Manipulating individual positions
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
Adds a new position to an order. Currently, only the following fields are supported:
* ``order`` (mandatory, specified as a string mapping to a ``code``)
* ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position)
* ``item`` (mandatory)
* ``variation`` (mandatory depending on item)
* ``subevent`` (mandatory depending on event)
* ``seat`` (specified as a string mapping to a ``string_guid``, mandatory depending on event and item)
* ``price`` (default price will be used if unset)
* ``attendee_email``
* ``attendee_name_parts`` or ``attendee_name``
* ``company``
* ``street``
* ``zipcode``
* ``city``
* ``country``
* ``state``
* ``answers``: Validation is handled the same way as when creating orders through the API. You are therefore
expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier``
and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value
``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it.
This will **not** automatically trigger creation of a new invoice, you need to take care of that yourself.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"order": "ABC12",
"item": 5,
"addon_to": 1
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:statuscode 200: no error
:statuscode 400: The position 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 this position.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Cancels an order position, identified by its internal ID.
Deletes an order position, identified by its internal ID.
**Example request**:
@@ -1871,128 +1728,6 @@ Manipulating individual positions
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order position does not exist.
Changing order contents
-----------------------
While you can :ref:`change positions individually <rest-orderpositions-manipulate>` sometimes it is necessary to make
multiple changes to an order at once within one transaction. This makes it possible to e.g. swap the seats of two
attendees in an order without running into conflicts. This interface also offers some possibilities not available
otherwise, such as splitting an order or changing fees.
.. versionchanged:: 4.8
This endpoint has been added to the system.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/change/
Performs a change operation on an order. You can supply the following fields:
* ``patch_positions``: A list of objects with the two keys ``position`` specifying an order position ID and
``body`` specifying the desired changed values of the position (``item``, ``variation``, ``subevent``, ``seat``,
``price``, ``tax_rule``).
* ``cancel_positions``: A list of objects with the single key ``position`` specifying an order position ID.
* ``split_positions``: A list of objects with the single key ``position`` specifying an order position ID.
* ``create_positions``: A list of objects describing new order positions with the same fields supported as when
creating them individually through the ``POST …/orderpositions/`` endpoint.
* ``patch_fees``: A list of objects with the two keys ``fee`` specifying an order fee ID and
``body`` specifying the desired changed values of the position (``value``).
* ``cancel_fees``: A list of objects with the single key ``fee`` specifying an order fee ID.
* ``recalculate_taxes``: If set to ``"keep_net"``, all taxes will be recalculated based on the tax rule and invoice
address, the net price will be kept. If set to ``"keep_gross"``, the gross price will be kept. If set to ``null``
(the default) the taxes are not recalculated.
* ``send_email``: If set to ``true``, the customer will be notified about the change. Defaults to ``false``.
* ``reissue_invoice``: If set to ``true`` and an invoice exists for the order, it will be canceled and a new invoice
will be issued. Defaults to ``true``.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"cancel_positions": [
{
"position": 12373
}
],
"patch_positions": [
{
"position": 12374,
"body": {
"item": 12,
"variation": None,
"subevent": 562,
"seat": "seat-guid-2",
"price": "99.99",
"tax_rule": 15
}
}
],
"split_positions": [
{
"position": 12375
}
],
"create_positions": [
{
"item": 12,
"variation": None,
"subevent": 562,
"seat": "seat-guid-2",
"price": "99.99",
"addon_to": 12374,
"attendee_name": "Peter",
}
],
"cancel_fees": [
{
"fee": 49
}
],
"change_fees": [
{
"fee": 51,
"body": {
"value": "12.00"
}
}
],
"reissue_invoice": true,
"send_email": true,
"recalculate_taxes": "keep_gross"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``code`` field of the order to update
:statuscode 200: no error
:statuscode 400: The order could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
Order payment endpoints
-----------------------

View File

@@ -16,22 +16,15 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the tax rule
name multi-lingual string The tax rules' name
internal_name string An optional name that is only used in the backend
rate decimal (string) Tax rate in percent
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
the specified product price
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
home_country string Merchant country (required for reverse charge), can be
``null`` or empty string
keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom
rules keep the gross price constant (default is ``false``)
===================================== ========================== =======================================================
.. versionchanged:: 4.6
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
Endpoints
---------
@@ -63,11 +56,9 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "19.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}
]
@@ -103,11 +94,9 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "19.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}
@@ -151,11 +140,9 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "19.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}
@@ -198,11 +185,9 @@ Endpoints
{
"id": 1,
"name": {"en": "VAT"},
"internal_name": "VAT",
"rate": "20.00",
"price_includes_tax": true,
"eu_reverse_charge": false,
"keep_gross_if_rate_changes": false,
"home_country": "DE"
}

View File

@@ -1,5 +1,3 @@
.. _`rest-vouchers`:
Vouchers
========

View File

@@ -2,13 +2,12 @@ Algorithms
==========
The business logic inside pretix is full of complex algorithms making decisions based on all the hundreds of settings
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very important
and input parameters available. Some of them are documented here as graphs, either because fully understanding them is very
when working on features close to them, or because they also need to be re-implemented by client-side components like our
ticket scanning apps and we want to ensure the implementations are as similar as possible to avoid confusion.
.. toctree::
:maxdepth: 2
pricing
checkin
layouts

View File

@@ -1,180 +0,0 @@
.. _`algorithms-pricing`:
Pricing algorithms
==================
With pretix being an e-commerce application, one of its core tasks is to determine the price of a purchase. With the
complexity allowed by our range of features, this is not a trivial task and there are many edge cases that need to be
clearly defined. The most challenging part about this is that there are many situations in which a price might change
while the user is going through the checkout process and we're learning more information about them or their purchase.
For example, prices change when
* The cart expires and the listed prices changed in the meantime
* The user adds an invoice address that triggers a change in taxation
* The user chooses a custom price for an add-on product and adjusts the price later on
* The user adds a voucher to their cart
* An automatic discount is applied
For the purposes of this page, we're making a distinction between "naive prices" (which are just a plain number like 23.00), and
"taxed prices" (which are a combination of a net price, a tax rate, and a gross price, like 19.33 + 19% = 23.00).
Computation of listed prices
----------------------------
When showing a list of products, e.g. on the event front page, we always need to show a price. This price is what we
call the "listed price" later on.
To compute the listed price, we first use the ``default_price`` attribute of the ``Item`` that is being shown.
If we are showing an ``ItemVariation`` and that variation has a ``default_price`` set on itself, the variation's price
takes precedence and replaces the item's price.
If we're in an event series and there exists a ``SubEventItem`` or ``SubEventItemVariation`` with a price set, the
subevent's price configuration takes precedence over both the item as well as the variation and replaces the listed price.
Listed prices are naive prices. Before we actually show them to the user, we need to check if ``TaxRule.price_includes_tax``
is set to determine if we need to add tax or subtract tax to get to the taxed price. We then consider the event's
``display_net_prices`` setting to figure out which way to present the taxed price in the interface.
Guarantees on listed prices
---------------------------
One goal of all further logic is that if a user sees a listed price, they are guaranteed to get the product at that
price as long as they complete their purchase within the cart expiration time frame. For example, if the cart expiration
time is set to 30 minutes and someone puts a item listed at €23 in their cart at 4pm, they can still complete checkout
at €23 until 4.30pm, even if the organizer decides to raise the price to €25 at 4.10pm. If they complete checkout after
4.30pm, their cart will be adjusted to the new price and the user will see a warning that the price has changed.
Computation of cart prices
--------------------------
Input
"""""
To ensure the guarantee mentioned above, even in the light of all possible dynamic changes, the ``listed_price``
is explicitly stored in the ``CartPosition`` model after the item has been added to the cart.
If ``Item.free_price`` is set, the user is allowed to voluntarily increase the price. In this case, the user's input
is stored as ``custom_price_input`` without much further validation for use further down below in the process.
If ``display_net_prices`` is set, the user's input is also considered to be a net price and ``custom_price_input_is_net``
is stored for the cart position. In any other case, the user's input is considered to be a gross price based on the tax
rules' default tax rate.
The computation of prices in the cart always starts from the ``listed_price``. The ``list_price`` is only computed
when adding the product to the cart or when extending the cart's lifetime after it expired. All other steps such as
creating an order based on the cart trust ``list_price`` without further checks.
Vouchers
""""""""
As a first step, the cart is checked for any voucher that should be applied to the position. If such a voucher exists,
it's discount (percentage or fixed) is applied to the listed price. The result of this is stored to ``price_after_voucher``.
Since ``listed_price`` naive, ``price_after_voucher`` is naive as well. As a consequence, if you have a voucher configured
to "set the price to €10", it depends on ``TaxRule.price_includes_tax`` again whether this is €10 including or excluding
taxes.
The ``price_after_voucher`` is only computed when adding the product to the cart or when extending the cart's
lifetime after it expired. It is also checked again when the order is created, since the available discount might have
changed due to the voucher's budget being (almost) exhausted.
Line price
""""""""""
The next step computes the final price of this position if it is the only position in the cart. This happens in "reverse
order", i.e. before the computation can be performed for a cart position, the step needs to be performed on all of its
bundled positions. The sum of ``price_after_voucher`` of all bundled positions is now called ``bundled_sum``.
First, the value from ``price_after_voucher`` will be processed by the applicable ``TaxRule.tax()`` (which is complex
in itself but is not documented here in detail at the moment).
If ``custom_price_input`` is not set, ``bundled_sum`` will be subtracted from the gross price and the net price is
adjusted accordingly. The result is stored as ``tax_rate`` and ``line_price_gross`` in the cart position.
If ``custom_price_input`` is set, the value will be compared to either the gross or the net value of the ``tax()``
result, depending on ``custom_price_input_is_net``. If the comparison yields that the custom price is higher, ``tax()``
will be called again . Then, ``bundled_sum`` will be subtracted from the gross price and the result is stored like
above.
The computation of ``line_price_gross`` from ``price_after_voucher``, ``custom_price_input``, and tax settings
is repeated after every change of anything in the cart or after every change of the invoice address.
Discounts
---------
After ``line_price_gross`` has been computed for all positions, the discount engine will run to apply any automatic
discounts. Organizers can add rules for automatic discounts in the pretix backend. These rules are ordered and
will be applied in order. Every cart position can only be "used" by one discount rule. "Used" can either mean that
the price of the position was actually discounted, but it can also mean that the position was required to enable
a discount for a different position, e.g. in case of a "buy 3 for the price of 2" offer.
The algorithm for applying an individual discount rule first starts with eliminating all products that do not match
the rule based on its product scope. Then, the algorithm is handled differently for different configurations.
Case 1: Discount based on minimum value without respect to subevents
""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
* Check whether the gross sum of all positions is at least ``condition_min_value``, otherwise abort.
* Reduce the price of all positions by ``benefit_discount_matching_percent``.
* Mark all positions as "used" to hide them from further rules
Case 2: Discount based on minimum number of tickets without respect to subevents
""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
* Check whether the number of all positions is at least ``condition_min_count``, otherwise abort.
* If ``benefit_only_apply_to_cheapest_n_maches`` is set,
* Sort all positions by price.
* Reduce the price of the first ``n_positions // condition_min_count * benefit_only_apply_to_cheapest_n_matches`` positions by ``benefit_discount_matching_percent``.
* Mark the first ``n_positions // condition_min_count * condition_min_count`` as "used" to hide them from further rules.
* Mark all positions as "used" to hide them from further rules.
* Else,
* Reduce the price of all positions by ``benefit_discount_matching_percent``.
* Mark all positions as "used" to hide them from further rules.
Case 3: Discount only for products of the same subevent
"""""""""""""""""""""""""""""""""""""""""""""""""""""""
* Split the cart into groups based on the subevent.
* Proceed with case 1 or 2 for every group.
Case 4: Discount only for products of distinct subevents
""""""""""""""""""""""""""""""""""""""""""""""""""""""""
* Let ``subevents`` be a list of distinct subevents in the cart.
* Let ``positions[subevent]`` be a list of positions for every subevent.
* Let ``current_group`` be the current group and ``groups`` the list of all groups.
* Repeat
* Order ``subevents`` by the length of their ``positions[subevent]`` list, starting with the longest list.
Do not count positions that are part of ``current_group`` already.
* Let ``candidates`` be the concatenation of all ``positions[subevent]`` lists with the same length as the
longest list.
* If ``candidates`` is empty, abort the repetition.
* Order ``candidates`` by their price, starting with the lowest price.
* Pick one entry from ``candidates`` and put it into ``current_group``. If ``current_group`` is shorter than
``benefit_only_apply_to_cheapest_n_matches``, we pick from the start (lowest price), otherwise we pick from
the end (highest price)
* If ``current_group`` is now ``condition_min_count``, remove all entries from ``current_group`` from
``positions[…]``, add ``current_group`` to ``groups``, and reset ``current_group`` to an empty group.
* For every position still left in a ``positions[…]`` list, try if there is any ``group`` in groups that it can
still be added to without violating the rule of distinct subevents
* For every group in ``groups``, proceed with case 1 or 2.
Flowchart
---------
.. image:: /images/cart_pricing.png

View File

@@ -20,31 +20,20 @@ Basically, three pre-defined flows are supported:
* Authentication mechanisms that rely on **redirection**, e.g. to an OAuth provider. These can be implemented by
supplying a ``authentication_url`` method and implementing a custom return view.
For security reasons, authentication backends are *not* automatically discovered through a signal. Instead, they must
explicitly be set through the ``auth_backends`` directive in the ``pretix.cfg`` :ref:`configuration file <config>`.
Authentication backends are *not* collected through a signal. Instead, they must explicitly be set through the
``auth_backends`` directive in the ``pretix.cfg`` :ref:`configuration file <config>`.
In each of these methods (``form_authenticate``, ``request_authenticate``, or your custom view) you are supposed to
use ``User.objects.get_or_create_for_backend`` to get a :py:class:`pretix.base.models.User` object from the database
or create a new one.
In each of these methods (``form_authenticate``, ``request_authenticate`` or your custom view) you are supposed to
either get an existing :py:class:`pretix.base.models.User` object from the database or create a new one. There are a
few rules you need to follow:
There are a few rules you need to follow:
* You **MUST** only return users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
* You **MUST** have some kind of identifier for a user that is globally unique and **SHOULD** never change, even if the
user's name or email address changes. This could e.g. be the ID of the user in an external database. The identifier
must not be longer than 190 characters. If you worry your backend might generated longer identifiers, consider
using a hash function to trim them to a constant length.
* You **SHOULD** not allow users created by other authentication backends to log in through your code, and you **MUST**
only create, modify or return users with ``auth_backend`` set to your backend.
* You **MUST** create new users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
* Every user object **MUST** have an email address. Email addresses are globally unique. If the email address is
already registered to a user who signs in through a different backend, you **SHOULD** refuse the login.
``User.objects.get_or_create_for_backend`` will follow these rules for you automatically. It works like this:
.. autoclass:: pretix.base.models.auth.UserManager
:members: get_or_create_for_backend
The backend interface
---------------------
@@ -70,7 +59,6 @@ The backend interface
.. automethod:: authentication_url
Logging users in
----------------
@@ -80,45 +68,3 @@ recommend that you use the following utility method to correctly set session val
authentication (if activated):
.. autofunction:: pretix.control.views.auth.process_login
A custom view that is called after a redirect from an external identity provider could look like this::
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse
from pretix.base.models import User
from pretix.base.models.auth import EmailAddressTakenError
from pretix.control.views.auth import process_login
def return_view(request):
# Verify validity of login with the external provider's API
api_response = my_verify_login_function(
code=request.GET.get('code')
)
try:
u = User.objects.get_or_create_for_backend(
'my_backend_name',
api_response['userid'],
api_response['email'],
set_always={
'fullname': '{} {}'.format(
api_response.get('given_name', ''),
api_response.get('family_name', ''),
),
},
set_on_creation={
'locale': api_response.get('locale').lower()[:2],
'timezone': api_response.get('zoneinfo', 'UTC'),
}
)
except EmailAddressTakenError:
messages.error(
request, _('We cannot create your user account as a user account in this system '
'already exists with the same email address.')
)
return redirect(reverse('control:auth.login'))
else:
return process_login(request, u, keep_logged_in=False)

View File

@@ -1,119 +0,0 @@
.. highlight:: python
:linenothreshold: 5
.. _`cookieconsent`:
Handling cookie consent
=======================
pretix includes an optional feature to handle cookie consent explicitly to comply with EU regulations.
If your plugin sets non-essential cookies or includes a third-party service that does so, you should
integrate with this feature.
Server-side integration
-----------------------
First, you need to declare that you are using non-essential cookies by responding to the following
signal:
.. automodule:: pretix.presale.signals
:members: register_cookie_providers
You are expected to return a list of ``CookieProvider`` objects instantiated from the following class:
.. class:: pretix.presale.cookies.CookieProvider
.. py:attribute:: CookieProvider.identifier
A short and unique identifier used to distinguish this cookie provider form others (required).
.. py:attribute:: CookieProvider.provider_name
A human-readable name of the entity of feature responsible for setting the cookie (required).
.. py:attribute:: CookieProvider.usage_classes
A list of enum values from the ``pretix.presale.cookies.UsageClass`` enumeration class, such as
``UsageClass.ANALYTICS``, ``UsageClass.MARKETING``, or ``UsageClass.SOCIAL`` (required).
.. py:attribute:: CookieProvider.privacy_url
A link to a privacy policy (optional).
Here is an example of such a receiver:
.. code-block:: python
@receiver(register_cookie_providers)
def recv_cookie_providers(sender, request, **kwargs):
return [
CookieProvider(
identifier='google_analytics',
provider_name='Google Analytics',
usage_classes=[UsageClass.ANALYTICS],
)
]
JavaScript-side integration
---------------------------
The server-side integration only causes the cookie provider to show up in the cookie dialog. You still
need to care about actually enforcing the consent state.
You can access the consent state through the ``window.pretix.cookie_consent`` variable. Whenever the
value changes, a ``pretix:cookie-consent:change`` event is fired on the ``document`` object.
The variable will generally have one of the following states:
.. rst-class:: rest-resource-table
================================================================ =====================================================
State Interpretation
================================================================ =====================================================
``pretix === undefined || pretix.cookie_consent === undefined`` Your JavaScript has loaded before the cookie consent
script. Wait for the event to be fired, then try again,
do not yet set a cookie.
``pretix.cookie_consent === null`` The cookie consent mechanism has not been enabled. This
usually means that you can set cookies however you like.
``pretix.cookie_consent[identifier] === undefined`` The cookie consent mechanism is loaded, but has no data
on your cookie yet, wait for the event to be fired, do not
yet set a cookie.
``pretix.cookie_consent[identifier] === true`` The user has consented to your cookie.
``pretix.cookie_consent[identifier] === false`` The user has actively rejected your cookie.
================================================================ =====================================================
If you are integrating e.g. a tracking provider with native cookie consent support such
as Facebook's Pixel, you can integrate it like this:
.. code-block:: javascript
var consent = (window.pretix || {}).cookie_consent;
if (consent !== null && !(consent || {}).facebook) {
fbq('consent', 'revoke');
}
fbq('init', ...);
document.addEventListener('pretix:cookie-consent:change', function (e) {
fbq('consent', (e.detail || {}).facebook ? 'grant' : 'revoke');
})
If you have a JavaScript function that you only want to load if consent for a specific ``identifier``
is given, you can wrap it like this:
.. code-block:: javascript
var consent_identifier = "youridentifier";
var consent = (window.pretix || {}).cookie_consent;
if (consent === null || (consent || {})[consent_identifier] === true) {
// Cookie consent tool is either disabled or consent is given
addScriptElement(src);
return;
}
// Either cookie consent tool has not loaded yet or consent is not given
document.addEventListener('pretix:cookie-consent:change', function onChange(e) {
var consent = e.detail || {};
if (consent === null || consent[consent_identifier] === true) {
addScriptElement(src);
document.removeEventListener('pretix:cookie-consent:change', onChange);
}
})

View File

@@ -17,7 +17,6 @@ Contents:
shredder
import
customview
cookieconsent
auth
general
quality

View File

@@ -62,8 +62,6 @@ The provider class
.. autoattribute:: public_name
.. autoattribute:: confirm_button_name
.. autoattribute:: is_enabled
.. autoattribute:: priority

View File

@@ -45,17 +45,13 @@ Attribute Type Description
name string The human-readable name of your plugin
author string Your name
version string A human-readable version code of your plugin
description string A more verbose description of what your plugin does. May contain HTML.
description string A more verbose description of what your plugin does.
category string Category of a plugin. Either one of ``"FEATURE"``, ``"PAYMENT"``,
``"INTEGRATION"``, ``"CUSTOMIZATION"``, ``"FORMAT"``, or ``"API"``,
or any other string.
picture string (optional) Path to a picture resolvable through the static file system.
featured boolean (optional) ``False`` by default, can promote a plugin if it's something many users will want, use carefully.
visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated.
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
for an event by system administrators / superusers.
experimental boolean (optional) ``False`` by default, marks a plugin as an experimental feature in the plugins list.
picture string (optional) Path to a picture resolvable through the static file system.
compatibility string Specifier for compatible pretix versions.
================== ==================== ===========================================================
@@ -78,10 +74,8 @@ A working example would be:
name = _("PayPal")
author = _("the pretix team")
version = '1.0.0'
category = 'PAYMENT'
picture = 'pretix_paypal/paypal_logo.svg'
category = 'PAYMENT
visible = True
featured = False
restricted = False
description = _("This plugin allows you to receive payments via PayPal")
compatibility = "pretix>=2.7.0"
@@ -98,7 +92,6 @@ those will be displayed but not block the plugin execution.
The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin
is available for a specific event. If not, it will not be shown in the plugin list of that event.
You should not define ``is_available`` and ``restricted`` on the same plugin.
Plugin registration
-------------------

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,28 +0,0 @@
@startuml
partition "For every cart position" {
(*) --> "Get default price from product"
--> if "Product has variations?" then
-->[yes] "Override with price from variation"
--> if "Event series?" then
-->[yes] "Override with price from subevent"
-down-> "Store as listed_price"
else
-down->[no] "Store as listed_price"
endif
else
-down->[no] "Store as listed_price"
endif
--> if "Voucher applied?" then
-->[yes] "Apply voucher pricing"
--> "Store as price_after_voucher"
else
-->[no] "Store as price_after_voucher"
endif
--> "Apply custom price if product allows\nApply tax rule\nSubtract bundled products"
--> "Store as line_price (gross), tax_rate"
}
--> "Apply discount engine"
--> "Store as price (gross)"
@enduml

View File

@@ -52,7 +52,6 @@ Variable Description
``order_email`` E-mail address of the ticket purchaser
``product_id`` Internal ID of the purchased product
``product_variation`` Internal ID of the purchased product variation (or empty)
``secret`` The secret ticket code, would be used as the QR code for physical tickets
``attendee_name`` Full name of the ticket holder (or empty)
``attendee_name_*`` Name parts of the ticket holder, depending on configuration, e.g. ``attendee_name_given_name`` or ``attendee_name_family_name``
``attendee_email`` E-mail address of the ticket holder (or empty)
@@ -62,7 +61,7 @@ Variable Description
``attendee_city`` City of the ticket holder's address (or empty)
``attendee_country`` Country code of the ticket holder's address (or empty)
``attendee_state`` State of the ticket holder's address (or empty)
``answers[XYZ]`` Answer to the custom question with identifier ``XYZ``
``answer[XYZ]`` Answer to the custom question with identifier ``XYZ``
``invoice_name`` Full name of the invoice address (or empty)
``invoice_name_*`` Name parts of the invoice address, depending on configuration, e.g. ``invoice_name_given_name`` or ``invoice_name_family_name``
``invoice_company`` Company of the invoice address (or empty)

View File

@@ -1,630 +0,0 @@
Exhibitors
==========
The exhibitors plugin allows to manage exhibitors at your trade show or conference. After signing up your exhibitors
in the system, you can assign vouchers to exhibitors and give them access to the data of these vouchers. The exhibitors
module is also the basis of the pretixLEAD lead scanning application.
.. note:: On pretix Hosted, using the lead scanning feature of the exhibitors plugin can add additional costs
depending on your contract.
The plugin exposes two APIs. One (REST API) is intended for bulk-data operations from the admin side, and one
(App API) that is used by the pretixLEAD app.
REST API
---------
The REST API for exhibitors requires the usual :ref:`rest-auth`.
Resources
"""""""""
The exhibitors plugin provides a HTTP API that allows you to create new exhibitors.
The exhibitors resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal exhibitor ID in pretix
name string Exhibitor name
internal_id string Can be used for the ID in your exhibition system, your customer ID, etc. Can be ``null``. Maximum 255 characters.
contact_name string Contact person (or ``null``)
contact_name_parts object of strings Decomposition of contact name (i.e. given name, family name)
contact_email string Contact person email address (or ``null``)
booth string Booth number (or ``null``). Maximum 100 characters.
locale string Locale for communication with the exhibitor (or ``null``).
access_code string Access code for the exhibitor to access their data or use the lead scanning app (read-only).
allow_lead_scanning boolean Enables lead scanning app
allow_lead_access boolean Enables access to data gathered by the lead scanning app
allow_voucher_access boolean Enables access to data gathered by exhibitor vouchers
comment string Internal comment, not shown to exhibitor
===================================== ========================== =======================================================
You can also access the scanned leads through the API which contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
attendee_order string Order code of the order the scanned attendee belongs to
attendee_positionid integer ``positionid`` if the attendee within the order specified by ``attendee_order``
rating integer A rating of 0 to 5 stars (or ``null``)
notes string A note taken by the exhibitor after scanning
tags list of strings Additional tags selected by the exhibitor
first_upload datetime Date and time of the first upload of this lead
data list of objects Attendee data set that may be shown to the exhibitor based o
the event's configuration. 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).
device_name string User-defined name for the device used for scanning (or ``null``).
===================================== ========================== =======================================================
Endpoints
"""""""""
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/
Returns a list of all exhibitors configured for an event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/exhibitors/ 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,
"name": "Aperture Science",
"internal_id": null,
"contact_name": "Dr Cave Johnson",
"contact_name_parts": {
"_scheme": "salutation_title_given_family",
"family_name": "Johnson",
"given_name": "Cave",
"salutation": "",
"title": "Dr"
},
"contact_email": "johnson@as.example.org",
"booth": "A2",
"locale": "de",
"access_code": "VKHZ2FU8",
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": ""
}
]
}
: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 or event does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/
Returns information on one exhibitor, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/exhibitors/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,
"name": "Aperture Science",
"internal_id": null,
"contact_name": "Dr Cave Johnson",
"contact_name_parts": {
"_scheme": "salutation_title_given_family",
"family_name": "Johnson",
"given_name": "Cave",
"salutation": "",
"title": "Dr"
},
"contact_email": "johnson@as.example.org",
"booth": "A2",
"locale": "de",
"access_code": "VKHZ2FU8",
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": ""
}
: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 exhibitor to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/exhibitor does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/leads/
Returns a list of all scanned leads of an exhibitor.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/leads/ 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": [
{
"attendee_order": "T0E7E",
"attendee_positionid": 1,
"rating": 1,
"notes": "",
"tags": [],
"first_upload": "2021-07-06T11:03:31.414491+01:00",
"data": [
{
"id": "attendee_name",
"label": "Attendee name",
"value": "Peter Miller",
"details": [
{"label": "Given name", "value": "Peter"},
{"label": "Family name", "value": "Miller"},
]
}
]
}
]
}
: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
:param id: The ``id`` field of the exhibitor to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event or exhibitor does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/
Create a new exhibitor.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/exhibitors/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 166
{
"name": "Aperture Science",
"internal_id": null,
"contact_name_parts": {
"_scheme": "salutation_title_given_family",
"family_name": "Johnson",
"given_name": "Cave",
"salutation": "",
"title": "Dr"
},
"contact_email": "johnson@as.example.org",
"booth": "A2",
"locale": "de",
"access_code": "VKHZ2FU8",
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": ""
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Aperture Science",
"internal_id": null,
"contact_name": "Dr Cave Johnson",
"contact_name_parts": {
"_scheme": "salutation_title_given_family",
"family_name": "Johnson",
"given_name": "Cave",
"salutation": "",
"title": "Dr"
},
"contact_email": "johnson@as.example.org",
"booth": "A2",
"locale": "de",
"access_code": "VKHZ2FU8",
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": ""
}
:param organizer: The ``slug`` field of the organizer to create new exhibitor for
:param event: The ``slug`` field of the event to create new exhibitor for
:statuscode 201: no error
:statuscode 400: The exhibitor 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 exhibitors.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/
Update an exhibitor. 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/digitalcontents/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 34
{
"internal_id": "ABC"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"id": 1,
"name": "Aperture Science",
"internal_id": "ABC",
"contact_name": "Dr Cave Johnson",
"contact_name_parts": {
"_scheme": "salutation_title_given_family",
"family_name": "Johnson",
"given_name": "Cave",
"salutation": "",
"title": "Dr"
},
"contact_email": "johnson@as.example.org",
"booth": "A2",
"locale": "de",
"access_code": "VKHZ2FU8",
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": ""
}
: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 exhibitor to modify
:statuscode 200: no error
:statuscode 400: The exhibitor could not be modified due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/exhibitor does not exist **or** you have no permission to change it.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/
Delete an exhibitor.
.. warning:: This deletes all lead scan data and removes all connections to vouchers (the vouchers are not deleted).
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/exhibitors/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 exhibitor to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/exhibitor does not exist **or** you have no permission to change it
App API
-------
The App API is used for communication between the pretixLEAD app and the pretix server.
.. warning:: We consider this an internal API, it is not intended for external use. You may still use it, but
our :ref:`compatibility commitment <rest-compat>` does not apply.
Authentication
""""""""""""""
Every exhibitor has an "access code", usually consisting of 8 alphanumeric uppercase characters.
This access code is communicated to event exhibitors by the event organizers, so this is also what
exhibitors should enter into a login screen.
All API requests need to contain this access code as a header like this::
Authorization: Exhibitor ABCDE123
Exhibitor profile
"""""""""""""""""
Upon login and in regular intervals after that, the API should fetch the exhibitors profile.
This serves two purposes:
* Checking if the authorization code is actually valid
* Obtaining information that can be shown in the app
The resource consists of the following fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
name string Exhibitor name
booth string Booth number (or ``null``)
event object Object describing the event
├ name multi-lingual string Event name
├ imprint_url string URL to legal notice page. If not ``null``, a button in the app should link to this page.
├ privacy_url string URL to privacy notice page. If not ``null``, a button in the app should link to this page.
├ help_url string URL to help page. If not ``null``, a button in the app should link to this page.
├ logo_url string URL to event logo. If not ``null``, this logo may be shown in the app.
├ slug string Event short form
└ organizer string Organizer short form
notes boolean Specifies whether the exhibitor is allowed to take notes on leads
tags list of strings List of tags the exhibitor can assign to their leads
scan_types list of objects Only used for a special case, fixed value that external API consumers should ignore
===================================== ========================== =======================================================
.. http:get:: /exhibitors/api/v1/profile
**Example request:**
.. sourcecode:: http
GET /exhibitors/api/v1/profile HTTP/1.1
Authorization: Exhibitor ABCDE123
Accept: application/json, text/javascript
**Example response:**
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"name": "Aperture Science",
"booth": "A2",
"event": {
"name": {"en": "Sample conference", "de": "Beispielkonferenz"},
"slug": "bigevents",
"imprint_url": null,
"privacy_url": null,
"help_url": null,
"logo_url": null,
"organizer": "sampleconf"
},
"notes": true,
"tags": ["foo", "bar"],
"scan_types": [
{
"key": "lead",
"label": "Lead Scanning"
}
]
}
:statuscode 200: no error
:statuscode 401: Invalid authentication code
Submitting a lead
"""""""""""""""""
After a ticket/badge is scanned, it should immediately be submitted to the server
so the scan is stored and information about the person can be shown in the app. The same
code can be submitted multiple times, so it's no problem to just submit it again after the
exhibitor set a note or a rating (0-5) inside the app.
On the request, you should set the following properties:
* ``code`` with the scanned barcode
* ``notes`` with the exhibitor's notes
* ``scanned`` with the date and time of the actual scan (not the time of the upload)
* ``scan_type`` set to ``lead`` statically
* ``tags`` with the list of selected tags
* ``rating`` with the rating assigned by the exhibitor
* ``device_name`` with a user-specified name of the device used for scanning (max. 190 characters), or ``null``
If you submit ``tags`` and ``rating`` to be ``null`` and ``notes`` to be ``""``, the server
responds with the previously saved information and will not delete that information. If you
supply other values, the information saved on the server will be overridden.
The response will also contain ``tags``, ``rating``, and ``notes``. Additionally,
it will include ``attendee`` with a list of ``fields`` that can be shown to the
user. Each field has an internal ``id``, a human-readable ``label``, and a ``value`` (all strings).
Note that the ``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.
The request for this looks like this:
.. http:post:: /exhibitors/api/v1/leads/
**Example request:**
.. sourcecode:: http
POST /exhibitors/api/v1/leads/ HTTP/1.1
Authorization: Exhibitor ABCDE123
Accept: application/json, text/javascript
Content-Type: application/json
{
"code": "qrcodecontent",
"notes": "Great customer, wants our newsletter",
"scanned": "2020-10-18T12:24:23.000+00:00",
"scan_type": "lead",
"tags": ["foo"],
"rating": 4,
"device_name": "DEV1"
}
**Example response:**
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"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": []
}
]
},
"rating": 4,
"tags": ["foo"],
"notes": "Great customer, wants our newsletter"
}
:statuscode 200: No error, leads was not scanned for the first time
:statuscode 201: No error, leads was scanned for the first time
:statuscode 400: Invalid data submitted
:statuscode 401: Invalid authentication code
You can also fetch existing leads (if you are authorized to do so):
.. http:get:: /exhibitors/api/v1/leads/
**Example request:**
.. sourcecode:: http
GET /exhibitors/api/v1/leads/ HTTP/1.1
Authorization: Exhibitor ABCDE123
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": [
{
"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": []
}
]
},
"rating": 4,
"tags": ["foo"],
"notes": "Great customer, wants our newsletter"
}
]
}
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data

View File

@@ -1,301 +0,0 @@
Secrets Import
==============
Usually, pretix generates ticket secrets (i.e. the QR code used for scanning) itself. You can read more about this
process at :ref:`secret_generators`.
With the "Secrets Import" plugin, you can upload your own list of secrets to be used instead. This is useful for
integrating with third-party check-in systems.
API Resource description
-------------------------
The secrets import plugin provides a HTTP API that allows you to create new secrets.
The imported secret resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the secret
secret string Actual string content of the secret (QR code content)
used boolean Whether the secret was already used for a ticket. If ``true``,
the secret can no longer be deleted. Secrets are never used
twice, even if an order is canceled or deleted.
item integer Internal ID of a product, or ``null``. If set, the secret
will only be used for tickets of this product.
variation integer Internal ID of a product variation, or ``null``. If set, the secret
will only be used for tickets of this product variation.
subevent integer Internal ID of an event series date, or ``null``. If set, the secret
will only be used for tickets of this event series date.
===================================== ========================== =======================================================
API Endpoints
-------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/
Returns a list of all secrets imported for an event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/ 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,
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
]
}
: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 or event does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/(id)/
Returns information on one secret, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/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,
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
: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 secret to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/
Create a new secret.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 166
{
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
:param organizer: The ``slug`` field of the organizer to a create new secret for
:param event: The ``slug`` field of the event to create a new secret for
:statuscode 201: no error
:statuscode 400: The secret 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 secrets.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/bulk_create/
Create new secrets in bulk (up to 500 per request). The request either succeeds or fails entirely.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/bulk_create/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 166
[
{
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
},
{
"secret": "baz",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
]
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
[
{
"id": 1,
"secret": "foobar",
"used": false,
"item": null,
"variation": null,
"subevent": null
},
{
"id": 2,
"secret": "baz",
"used": false,
"item": null,
"variation": null,
"subevent": null
}
]
:param organizer: The ``slug`` field of the organizer to create new secrets for
:param event: The ``slug`` field of the event to create new secrets for
:statuscode 201: no error
:statuscode 400: The secrets 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 secrets.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/(id)/
Update a secret. 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/imported_secrets/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 34
{
"item": 2
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"id": 1,
"secret": "foobar",
"used": false,
"item": 2,
"variation": null,
"subevent": null
}
: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 secret to modify
:statuscode 200: no error
:statuscode 400: The secret could not be modified due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to change it.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/(id)/
Delete a secret. You can only delete secrets that have not yet been used.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/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 secret to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to change it **or** the secret has already been used

View File

@@ -1,5 +1,3 @@
.. _`plugin-docs`:
Plugin documentation
====================
@@ -12,13 +10,11 @@ If you want to **create** a plugin, please go to the
:maxdepth: 2
list
pretixdroid
banktransfer
ticketoutputpdf
badges
campaigns
certificates
digital
exhibitors
imported_secrets
webinar
presale-saml

View File

@@ -5,6 +5,6 @@ List of plugins
===============
A detailed list of plugins that are available for pretix can be found on the
`pretix Marketplace`_.
`project website`_.
.. _pretix Marketplace: https://marketplace.pretix.eu
.. _project website: https://pretix.eu/about/en/plugins

View File

@@ -1,405 +0,0 @@
.. highlight:: ini
.. spelling::
IdP
skIDentity
ePA
NPA
Presale SAML Authentication
===========================
The Presale SAML Authentication plugin is an advanced plugin, which most event
organizers will not need to use. However, for the select few who do require
strong customer authentication that cannot be covered by the built-in customer
account functionality, this plugin allows pretix to connect to a SAML IdP and
perform authentication and retrieval of user information.
Usage of the plugin is governed by two separate sets of settings: The plugin
installation, the Service Provider (SP) configuration and the event
configuration.
Plugin installation and initial configuration
---------------------------------------------
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, you can
skip this section.
The plugin is installed as any other plugin in the pretix ecosystem. As a
pretix system administrator, please follow the instructions in the the
:ref:`Administrator documentation <admindocs>`.
Once installed, you will need to assess, if you want (or need) your pretix
instance to be a single SP for all organizers and events or if every event
organizer has to provide their own SP.
Take the example of a university which runs pretix under an pretix Enterprise
agreement. Since they only provide ticketing services to themselves (every
organizer is still just a different department of the same university), a
single SP should be enough.
On the other hand, a reseller such as `pretix.eu`_ who services a multitude
of clients would not work that way. Here, every organizer is a separate
legal entity and as such will also need to provide their own SP configuration:
Company A will expect their SP to reflect their company - and not a generalized
"pretix SP".
Once you have decided on the mode of operation, the :ref:`Configuration file
<config>` needs to be extended to reflect your choice.
Example::
[presale-saml]
level=global
``level``
``global`` to use only a single, system-wide SP, ``organizer`` for multiple
SPs, configured on the organizer-level. Defaults to ``organizer``.
Service Provider configuration
------------------------------
Global Level
^^^^^^^^^^^^
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, you can
skip this section and follow the instructions on the upcoming
Organizer Level settings.
As a user with administrative privileges, please activate them by clicking the
`Admin Mode` button in the top right hand corner.
You should now see a new menu-item titled `SAML` appear.
Organizer Level
^^^^^^^^^^^^^^^
Navigate to the organizer settings in the pretix backend. In the navigation
bar, you will find a menu-item titled `SAML` if your user has the `Can
change organizer settings` permission.
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, the menu
will only appear once one of our friendly customer service agents
has enabled the Presale SAML Authentication plugin for at least one
of your events. Feel free to get in touch with us!
Setting up the SP
^^^^^^^^^^^^^^^^^
No matter where your SP configuration lives, you will be greeted by a very
long list of fields of which almost all of them will need to be filled. Please
don't be discouraged - most of the settings don't need to be decided by yourself
and/or are already preset with a sensible default setting.
If you are not sure what setting you should choose for any of the fields, you
should reach out to your IdP operator as they can tell you exactly what the IdP
expects and - more importantly - supports.
``IdP Metadata URL``
Please provide the URL where your IdP outputs its metadata. For most IdPs,
this URL is static and the same for all SPs. If you are a member of the
DFN-AAI, you can find the meta-data for the `Test-, Basic- and
Advanced-Federation`_ on their website. Please do talk with your local
IdP operator though, as you might not even need to go through the DFN-AAI
and might just use your institutions local IdP which will also host their
metadata on a different URL.
The URL needs to be publicly accessible, as saving the settings form will
fail if the IdP metadata cannot be retrieved. pretix will also automatically
refresh the IdP metadata on a regular basis.
``SP Entity Id``
By default, we recommend that you use the system-proposed metadata-URL as
the Entity Id of your SP. However, if so desired or required by your IdP,
you can also set any other, arbitrary URL as the SP Entity Id.
``SP Name / SP Decription``
Most IdP will display the name and description of your SP to the users
during authentication. The description field can be used to explain to the
users how their data is being used.
``SP X.509 Certificate / SP X.509 Private Key``
Your SP needs a certificate and a private key for said certificate. Please
coordinate with your IdP, if you are supposed to generate these yourself or
if they are provided to you.
``SP X.509 New Certificate``
As certificates have an expiry date, they need to be renewed on a regular
basis. In order to facilitate the rollover from the expiring to the new
certificate, you can provide the new certificate already before the expiration
of the existing one. That way, the system will automatically use the correct
one. Once the old certificate has expired and is not used anymore at all,
you can move the new certificate into the slot of the normal certificate and
keep the new slot empty for your next renewal process.
``Requested Attributes``
An IdP can hold a variety of attributes of an authenticating user. While
your IdP will dictate which of the available attributes your SP can consume
in theory, you will still need to define exactly which attributes the SP
should request.
The notation is a JSON list of objects with 5 attributes each:
* ``attributeValue``: Can be defaulted to ``[]``.
* ``friendlyName``: String used in the upcoming event-level settings to
retrieve the attributes data.
* ``isRequired``: Boolean indicating whether the IdP must enforce the
transmission of this attribute. In most cases, ``true`` is the best
choice.
* ``name``: String of the internal, technical name of the requested
attribute. Often starting with ``urn:mace:dir:attribute-def:``,
``urn:oid:`` or ``http://``/``https://``.
* ``nameFormat``: String describing the type of ``name`` that has been
set in the previous section. Often starting with
``urn:mace:shibboleth:1.0:`` or ``urn:oasis:names:tc:SAML:2.0:``.
Your IdP can provide you with a list of available attributes. See below
for a sample configuration in an academic context.
Note, that you can have multiple attributes with the same ``friendlyName``
but different ``name``s. This is often used in systems, where the same
information (for example a persons name) is saved in different fields -
for example because one institution is returning SAML 1.0 and other
institutions are returning SAML 2.0 style attributes. Typically, this only
occurs in mix environments like the DFN-AAI with a large number of
participants. If you are only using your own institutions IdP and not
authenticating anyone outside of your realm, this should not be a common
sight.
``Encrypt/Sign/Require ...``
Does what is says on the box - please inquire with your IdP for the
necessary settings. Most settings can be turned on as they increase security,
however some IdPs might stumble over some of them.
``Signature / Digest Algorithm``
Please chose appropriate algorithms, that both pretix/your SP and the IdP
can communicate with. A common source of issues when connecting to a
Shibboleth-based IdP is the Digest Algorithm: pretix does not support
``http://www.w3.org/2009/xmlenc11#rsa-oaep`` and authentication will fail
if the IdP enforces this.
``Technical/Support Contacts``
Those contacts are encoded into the SPs public meta data and might be
displayed to users having trouble authenticating. It is recommended to
provide a dedicated point of contact for technical issues, as those will
be the ones to change the configuration for the SP.
Event / Authentication configuration
------------------------------------
Basic settings
^^^^^^^^^^^^^^
Once the plugin has been enabled for a pretix event using the Plugins-menu from
the event's settings, a new *SAML* menu item will show up.
On this page, the actual authentication can be configured.
``Checkout Explanation``
Since most users probably won't be familiar with why they have to authenticate
to buy a ticket, you can provide them a small blurb here. Markdown is supported.
``Attribute RegEx``
By default, any successful authentication with the IdP will allow the user to
proceed with their purchase. Should the allowed audience needed to be restricted
further, a set of regular Expressions can be used to do this.
An Attribute RegEx of ``{}`` will allow any authenticated user to pass.
A RegEx of ``{ "affiliation": "^(employee@pretix.eu|staff@pretix.eu)$" }`` will
only allow user to pass which have the ``affiliation`` attribute and whose
attribute either matches ``employee@pretix.eu`` or ``staff@pretix.eu``.
Please make sure that the attribute you are querying is also requested from the
IdP in the first place - for a quick check you can have a look at the top of
the page where all currently configured attributes are listed.
``RegEx Fail Explanation``
Only used in conjunction with the above Attribute RegEx. Should the user not
pass the restrictions imposed by the regular expression, the user is shown
this error-message.
If you are - for example in an university context - restricting access to
students only, you might want to explain here that Employees are not allowed
to book tickets.
``Ticket Secret SAML Attribute``
In very specific instances, it might be desirable that the ticket-secret is
not the randomly one generated by pretix but rather based on one of the
users attributes - for example their unique ID or access card number.
To achieve this, the name of a SAML-attribute can be specified here.
It is however necessary to note, that even with this setting in use,
ticket-secrets need to be unique. This is why when this setting is enabled,
the default, pretix-generated ticket-secret is prefixed with the attributes
value.
Example: A users ``cardid`` attribute has the value of ``01189998819991197253``.
The default random ticket secret would have been
``yczygpw9877akz2xwdhtdyvdqwkv7npj``. The resulting new secret will now be
``01189998819991197253_yczygpw9877akz2xwdhtdyvdqwkv7npj``.
That way, the ticket secret is still unique, but when checking into an event,
the user can easily be searched and found using their identifier.
IdP-provided E-Mail addresses, names
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
By default, pretix will only authenticate the user and not process the received
data any further.
However, there are a few exceptions to this rule.
There are a few `magic` attributes that pretix will use to automatically populate
the corresponding fields within the checkout process **and lock them out from
user editing**.
* ``givenName`` and ``sn``: If both of those attributes are present and pretix
is configured to collect the users name, these attributes' values are used
for the given and family name respectively.
* ``email``: If this attribute is present, the E-Mail-address of the users will
be set to the one transmitted through the attributes.
The latter might pose a problem, if the IdP is transmitting an ``email`` attribute
which does contain a system-level mail address which is only used as an internal
identifier but not as a real mailbox. In this case, please consider setting the
``friendlyName`` of the attribute to a different value than ``email`` or removing
this field from the list of requested attributes altogether.
Saving attributes to questions
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
By setting the ``internal identifier`` of a user-defined question to the same name
as a SAML attribute, pretix will save the value of said attribute into the question.
All the same as in the above section on E-Mail addresses, those fields become
non-editable by the user.
Please be aware that some specialty question types might not be compatible with
the SAML attributes due to specific format requirements. If in doubt (or if the
checkout fails/the information is not properly saved), try setting the question
type to a simple type like "Text (one line)".
Notes and configuration examples
--------------------------------
Requesting SAML 1.0 and 2.0 attributes from an academic IdP
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This requests the ``eduPersonPrincipalName`` (also sometimes called EPPN),
``email``, ``givenName`` and ``sn`` both in SAML 1.0 and SAML 2.0 attributes.
.. sourcecode:: json
[
{
"attributeValue": [],
"friendlyName": "eduPersonPrincipalName",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:eduPersonPrincipalName",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "eduPersonPrincipalName",
"isRequired": true,
"name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "email",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:mail",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "email",
"isRequired": true,
"name": "urn:oid:0.9.2342.19200300.100.1.3",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "givenName",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:givenName",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "givenName",
"isRequired": true,
"name": "urn:oid:2.5.4.42",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "sn",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:sn",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "sn",
"isRequired": true,
"name": "urn:oid:2.5.4.4",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
}
]
skIDentity IdP Metadata URL
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Since the IdP Metadata URL for `skIDentity`_ is not readily documented/visible
in their backend, we document it here:
``https://service.skidentity.de/fs/saml/metadata``
Requesting skIDentity attributes for electronic identity cards
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This requests the basic ``eIdentifier``, ``IDType``, ``IDIssuer``, and
``NameID`` from the `skIDentity`_ SAML service, which are available for
electronic ID cards such as the German ePA/NPA. (Other attributes such as
the name and address are available at additional cost from the IdP).
.. sourcecode:: json
[
{
"attributeValue": [],
"friendlyName": "eIdentifier",
"isRequired": true,
"name": "http://www.skidentity.de/att/eIdentifier",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "IDType",
"isRequired": true,
"name": "http://www.skidentity.de/att/IDType",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "IDIssuer",
"isRequired": true,
"name": "http://www.skidentity.de/att/IDIssuer",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "NameID",
"isRequired": true,
"name": "http://www.skidentity.de/att/NameID",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
}
]
.. _pretix.eu: https://pretix.eu
.. _Test-, Basic- and Advanced-Federation: https://doku.tid.dfn.de/en:metadata
.. _skIDentity: https://www.skidentity.de/

368
doc/plugins/pretixdroid.rst Normal file
View File

@@ -0,0 +1,368 @@
pretixdroid HTTP API
====================
The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_
uses to communicate with the pretix server.
.. warning:: This API is **DEPRECATED** and will probably go away soon. It is used **only** to serve the pretixdroid
Android app. There are no backwards compatibility guarantees on this API. We will not add features that
are not required for the Android App. There is a general-purpose :ref:`rest-api` that provides all
features that you need to check in.
.. versionchanged:: 1.12
Support for check-in-time questions has been added. The new API features are fully backwards-compatible and
negotiated live, so clients which do not need this feature can ignore the change. For this reason, the API version
has not been increased and is still set to 3.
.. versionchanged:: 1.13
Support for checking in unpaid tickets has been added.
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
Redeems a ticket, i.e. checks the user in.
**Example request**:
.. sourcecode:: http
POST /pretixdroid/api/demoorga/democon/redeem/?key=ABCDEF HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
Content-Type: application/x-www-form-urlencoded
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3&questions_supported=true
You **must** set the parameter secret.
You **must** set the parameter ``questions_supported`` to ``true`` **if** you support asking questions
back to the app operator. You **must not** set it if you do not support this feature. In that case, questions
will just be ignored.
You **may** set the additional parameter ``datetime`` in the body containing an ISO8601-encoded
datetime of the entry attempt. If you don"t, the current date and time will be used.
You **may** set the additional parameter ``force`` to indicate that the request should be logged
regardless of previous check-ins for the same ticket. This might be useful if you made the entry decision offline.
Questions will also always be ignored in this case (i.e. supplied answers will be saved, but no error will be
thrown if they are missing or invalid).
You **may** set the additional parameter ``nonce`` with a globally unique random value to identify this
check-in. This is meant to be used to prevent duplicate check-ins when you are just retrying after a connection
failure.
You **may** set the additional parameter ``ignore_unpaid`` to indicate that the check-in should be performed even
if the order is in pending state.
If questions are supported and required, you will receive a dictionary ``questions`` containing details on the
particular questions to ask. To answer them, just re-send your redemption request with additional parameters of
the form ``answer_<question>=<answer>``, e.g. ``answer_12=24``.
**Example successful response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "ok"
"version": 3,
"data": {
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCDE",
"item": "Standard ticket",
"item_id": 1,
"variation": null,
"variation_id": null,
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
}
}
**Example response with required questions**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "incomplete"
"version": 3
"data": {
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCDE",
"item": "Standard ticket",
"item_id": 1,
"variation": null,
"variation_id": null,
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
},
"questions": [
{
"id": 12,
"type": "C",
"question": "Choose a shirt size",
"required": true,
"position": 2,
"items": [1],
"options": [
{
"id": 24,
"answer": "M"
},
{
"id": 25,
"answer": "L"
}
]
}
]
}
**Example error response with data**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "error",
"reason": "already_redeemed",
"version": 3,
"data": {
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCDE",
"item": "Standard ticket",
"item_id": 1,
"variation": null,
"variation_id": null,
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
}
}
**Example error response without data**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "error",
"reason": "unkown_ticket",
"version": 3
}
Possible error reasons:
* ``unpaid`` - Ticket is not paid for or has been refunded
* ``already_redeemed`` - Ticket already has been redeemed
* ``product`` - Tickets with this product may not be scanned at this device
* ``unknown_ticket`` - Secret does not match a ticket in the database
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/search/
Searches for a ticket.
At most 25 results will be returned. **Queries with less than 4 characters will always return an empty result set.**
**Example request**:
.. sourcecode:: http
GET /pretixdroid/api/demoorga/democon/search/?key=ABCDEF&query=Peter HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"results": [
{
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCE6",
"item": "Standard ticket",
"variation": null,
"attendee_name": "Peter Higgs",
"redeemed": false,
"attention": false,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
},
...
],
"version": 3
}
:query query: Search query
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/download/
Download data for all tickets.
**Example request**:
.. sourcecode:: http
GET /pretixdroid/api/demoorga/democon/download/?key=ABCDEF HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"version": 3,
"results": [
{
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCE6",
"item": "Standard ticket",
"variation": null,
"attendee_name": "Peter Higgs",
"redeemed": false,
"attention": false,
"checkin_allowed": true,
"paid": true
},
...
],
"questions": [
{
"id": 12,
"type": "C",
"question": "Choose a shirt size",
"required": true,
"position": 2,
"items": [1],
"options": [
{
"id": 24,
"answer": "M"
},
{
"id": 25,
"answer": "L"
}
]
}
]
}
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/status/
Returns status information, such as the total number of tickets and the
number of performed check-ins.
**Example request**:
.. sourcecode:: http
GET /pretixdroid/api/demoorga/democon/status/?key=ABCDEF HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"checkins": 17,
"total": 42,
"version": 3,
"event": {
"name": "Demo Conference",
"slug": "democon",
"date_from": "2016-12-27T17:00:00Z",
"date_to": "2016-12-30T18:00:00Z",
"timezone": "UTC",
"url": "https://demo.pretix.eu/demoorga/democon/",
"organizer": {
"name": "Demo Organizer",
"slug": "demoorga"
},
},
"items": [
{
"name": "T-Shirt",
"id": 1,
"checkins": 1,
"admission": False,
"total": 1,
"variations": [
{
"name": "Red",
"id": 1,
"checkins": 1,
"total": 12
},
{
"name": "Blue",
"id": 2,
"checkins": 4,
"total": 8
}
]
},
{
"name": "Ticket",
"id": 2,
"checkins": 15,
"admission": True,
"total": 22,
"variations": []
}
]
}
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. _pretixdroid Android app: https://github.com/pretix/pretixdroid

View File

@@ -1,6 +1,5 @@
-e ../src/
sphinx==2.3.*
jinja2==3.0.*
sphinx-rtd-theme
sphinxcontrib-httpdomain
sphinxcontrib-images

View File

@@ -103,7 +103,6 @@ prepending
preprocessor
presale
pretix
pretixLEAD
pretixSCAN
pretixdroid
pretixPOS

View File

@@ -203,4 +203,4 @@ Then, please contact support@pretix.eu and we will enable DKIM for your domain o
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
.. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax
.. _SPF specification: http://www.openspf.org/SPF_Record_Syntax

View File

@@ -1,44 +1,20 @@
Use case: Group discounts
-------------------------
Often times, you want to give discounts for whole groups attending your event.
Often times, you want to give discounts for whole groups attending your event. pretix can't automatically discount based on volume, but there's still some ways you can set up group tickets.
Automatic discounts
"""""""""""""""""""
pretix can automatically grant discounts if a certain condition is met, such as a specific group size. To set this up,
head to **Products**, **Discounts** in the event navigation and **Create a new discount**. You can choose a name so you
can later find this again. You can also optionally restrict the discount to a specific time frame or a specific sales
channel.
Next, either select **Apply to all products** or create a selection of products that are eligible for the discount.
For a **percentual group discount** similar to "if you buy at least 5 tickets, you get 20 percent off", set
**Minimum number of matching products** to "5" and **Percentual discount on matching products** to "20.00".
For a **buy-X-get-Y discount**, e.g. "if you buy 5 tickets, you get one free", set
**Minimum number of matching products** to "5", **Percentual discount on matching products** to "100.00", and
**Apply discount only to this number of matching products** to "1".
Fixed group packages
Flexible group sizes
""""""""""""""""""""
If you want to sell group tickets in fixed sizes, e.g. a table of eight at your gala dinner, you can use product bundles.
Assuming you already set up a ticket for admission of single persons, you then set up a second product **Table (8 persons)**
with a discounted full price. Then, head to the **Bundled products** tab of that product and add one bundle configuration
to include the single admission product **eight times**. Next, create an unlimited quota mapped to the new product.
This way, the purchase of a table will automatically create eight tickets, leading to a correct calculation of your total
quota and, as expected, eight persons on your check-in list. You can even ask for the individual names of the persons
during checkout.
Minimum order amount
""""""""""""""""""""
If you want to promote discounted group tickets in your price list, you can also do so by creating a special
**Group ticket** at the reduced per-person price and set the **Minimum amount per order** option of the ticket to the minimal
group size.
If you want to give out discounted tickets to groups starting at a given size, but still billed per person, you can do so by creating a special **Group ticket** at the per-person price and set the **Minimum amount per order** option of the ticket to the minimal group size.
For more complex use cases, you can also use add-on products that can be chosen multiple times.
This way, your ticket can be bought an arbitrary number of times but no less than the given minimal amount per order.
Fixed group sizes
"""""""""""""""""
If you want to sell group tickets in fixed sizes, e.g. a table of eight at your gala dinner, you can use product bundles. Assuming you already set up a ticket for admission of single persons, you then set up a second product **Table (8 persons)** with a discounted full price. Then, head to the **Bundled products** tab of that product and add one bundle configuration to include the single admission product **eight times**. Next, create an unlimited quota mapped to the new product.
This way, the purchase of a table will automatically create eight tickets, leading to a correct calculation of your total quota and, as expected, eight persons on your check-in list. You can even ask for the individual names of the persons during checkout.

View File

@@ -1,5 +1,3 @@
.. _secret_generators:
Ticket secret generators
========================

View File

@@ -253,21 +253,18 @@ If you want, you can suppress us loading the widget and/or modify the user data
If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
Waiting for the widget to load or close
---------------------------------------
Waiting for the widget to load
------------------------------
If you want to run custom JavaScript once the widget is fully loaded or when it is closed, you can register callback
functions. Note that these function might be run multiple times, for example if you have multiple widgets on a page
or if the user switches e.g. from an event list to an event detail view::
If you want to run custom JavaScript once the widget is fully loaded, you can register a callback function. Note that
this function might be run multiple times, for example if you have multiple widgets on a page or if the user switches
e.g. from an event list to an event detail view::
<script type="text/javascript">
window.pretixWidgetCallback = function () {
window.PretixWidget.addLoadListener(function () {
console.log("Widget has loaded!");
});
window.PretixWidget.addCloseListener(function () {
console.log("Widget has been closed!");
});
}
</script>
@@ -312,10 +309,6 @@ Currently, the following attributes are understood by pretix itself:
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
on this for authentication.
* If ``data-consent="…"`` is given, the cookie consent mechanism will be initialized with consent for the given cookie
providers. All other providers will be disabled, no consent dialog will be shown. This is useful if you already
asked the user for consent and don't want them to be asked again. Example: ``data-consent="facebook,google_analytics"``
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
Hosted or pretix Enterprise are active, you can pass the following fields:

View File

@@ -84,9 +84,7 @@ going to develop around pretix, for example connect to pretix through our API, y
- A voucher is a code that can be used for multiple purposes: To grant a discount to specific customers, to only
show certain products to certain customers, or to keep a seat open for someone specific even though you are
sold out. If a voucher is used to apply a discount, the price of the purchased product is reduced by the
* - | |:gb:| **(Automatic) Discount**
| |:de:| (Automatischer) Rabatt
- Discounts can be used to automatically provide discounts to customers if their cart satisfies a certain condition.
discounted amount. Vouchers are connected to a specific event.
* - | |:gb:| **Gift card**
| |:de:| Wertgutschein
- A :ref:`gift card <giftcards>` is a coupon representing an exact amount of money that can be used for purchases

View File

@@ -13,7 +13,6 @@ recursive-include pretix/plugins/banktransfer/static *
recursive-include pretix/plugins/manualpayment/templates *
recursive-include pretix/plugins/manualpayment/static *
recursive-include pretix/plugins/paypal/templates *
recursive-include pretix/plugins/paypal/static *
recursive-include pretix/plugins/pretixdroid/templates *
recursive-include pretix/plugins/pretixdroid/static *
recursive-include pretix/plugins/sendmail/templates *

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__ = "4.10.0.dev0"
__version__ = "4.5.1"

View File

@@ -151,8 +151,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:ticketlayoutitem-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:voucher-list'),
('GET', 'api-v1:voucher-detail'),
('GET', 'api-v1:order-list'),
('POST', 'api-v1:order-list'),
('GET', 'api-v1:order-detail'),
@@ -169,8 +167,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:checkinlist-list'),
('POST', 'api-v1:checkinlistpos-redeem'),
('POST', 'plugins:pretix_posbackend:order.posprintlog'),
('POST', 'plugins:pretix_posbackend:order.poslock'),
('DELETE', 'plugins:pretix_posbackend:order.poslock'),
('DELETE', 'api-v1:cartposition-detail'),
('GET', 'api-v1:giftcard-list'),
('POST', 'api-v1:giftcard-transact'),
@@ -178,11 +174,8 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
('POST', 'plugins:pretix_posbackend:posclosing-list'),
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
('POST', 'plugins:pretix_posbackend:posdebuglogentry-list'),
('POST', 'plugins:pretix_posbackend:posdebuglogentry-bulk-create'),
('GET', 'plugins:pretix_posbackend:poscashier-list'),
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
('PUT', 'plugins:pretix_posbackend:file.upload'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:event.settings'),
('GET', 'plugins:pretix_seating:event.event'),

View File

@@ -37,15 +37,9 @@ from pretix.base.models import Quota, Seat
from pretix.base.models.orders import CartPosition
class TaxIncludedField(serializers.Field):
def to_representation(self, instance: CartPosition):
return not instance.custom_price_input_is_net
class CartPositionSerializer(I18nAwareModelSerializer):
answers = AnswerSerializer(many=True)
seat = InlineSeatSerializer()
includes_tax = TaxIncludedField(source='*')
class Meta:
model = CartPosition
@@ -60,7 +54,6 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
sales_channel = serializers.CharField(required=False, default='sales_channel')
includes_tax = serializers.BooleanField(required=False, allow_null=True)
class Meta:
model = CartPosition
@@ -134,12 +127,6 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError('The specified product requires to choose a seat.')
validated_data.pop('sales_channel')
# todo: does this make sense?
validated_data['custom_price_input'] = validated_data['price']
# todo: listed price, etc?
# currently does not matter because there is no way to transform an API cart position into an order that keeps
# prices, cart positions are just quota/voucher placeholders
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:

View File

@@ -60,7 +60,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
for item in full_data.get('limit_products', []):
for item in full_data.get('limit_products'):
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))

View File

@@ -1,49 +0,0 @@
#
# 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 <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Discount
class DiscountSerializer(I18nAwareModelSerializer):
class Meta:
model = Discount
fields = ('id', 'active', 'internal_name', 'position', 'sales_channels', 'available_from',
'available_until', 'subevent_mode', 'condition_all_products', 'condition_limit_products',
'condition_apply_to_addons', 'condition_min_count', 'condition_min_value',
'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches',
'condition_ignore_voucher_discounted')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
Discount.validate_config(full_data)
return data

View File

@@ -637,7 +637,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class Meta:
model = TaxRule
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes')
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
class EventSettingsSerializer(SettingsSerializer):
@@ -713,6 +713,7 @@ class EventSettingsSerializer(SettingsSerializer):
'ticket_download_require_validated_email',
'ticket_secret_length',
'mail_prefix',
'mail_from',
'mail_from_name',
'mail_attach_ical',
'mail_attach_tickets',

View File

@@ -58,9 +58,8 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'position', 'default_price', 'price', 'original_price',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
def __init__(self, *args, **kwargs):
@@ -75,9 +74,8 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'position', 'default_price', 'price', 'original_price',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
def __init__(self, *args, **kwargs):
@@ -251,12 +249,9 @@ class ItemSerializer(I18nAwareModelSerializer):
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None)
require_membership_types = validated_data.pop('require_membership_types', [])
item = Item.objects.create(**validated_data)
if picture:
item.picture.save(os.path.basename(picture.name), picture)
if require_membership_types:
item.require_membership_types.add(*require_membership_types)
for variation_data in variations_data:
require_membership_types = variation_data.pop('require_membership_types', [])

View File

@@ -56,9 +56,7 @@ from pretix.base.models.orders import (
from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
from pretix.base.services.locking import NoLockManager
from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, is_included_for_free,
)
from pretix.base.services.pricing import get_price
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.signals import register_ticket_outputs
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -426,7 +424,88 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
self.fields.pop('pdf_data', None)
def validate(self, data):
raise TypeError("this serializer is readonly")
if data.get('attendee_name') and data.get('attendee_name_parts'):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country').code):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data
def update(self, instance, validated_data):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = [
'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
'state', 'attendee_email',
]
answers_data = validated_data.pop('answers', None)
name = validated_data.pop('attendee_name', '')
if name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': name
}
for attr, value in validated_data.items():
if attr in update_fields:
setattr(instance, attr, value)
instance.save(update_fields=update_fields)
if answers_data is not None:
qs_seen = set()
answercache = {
a.question_id: a for a in instance.answers.all()
}
for answ_data in answers_data:
options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk]
if isinstance(answ_data['answer'], File):
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
pass # keep current file
else:
for attr, value in answ_data.items():
setattr(a, attr, value)
a.save()
else:
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='')
a.file.save(os.path.basename(an.name), an, save=False)
a.answer = 'file://' + a.file.name
a.save()
else:
a = instance.answers.create(**answ_data)
a.options.set(options)
qs_seen.add(a.question_id)
for qid, a in answercache.items():
if qid not in qs_seen:
a.delete()
return instance
class RequireAttentionField(serializers.Field):
@@ -514,7 +593,7 @@ class OrderPaymentDateField(serializers.DateField):
class OrderFeeSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderFee
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
class PaymentURLField(serializers.URLField):
@@ -855,8 +934,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
force = serializers.BooleanField(default=False, required=False)
payment_date = serializers.DateTimeField(required=False, allow_null=True)
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
require_approval = serializers.BooleanField(default=False, required=False)
send_email = serializers.BooleanField(default=False, required=False)
simulate = serializers.BooleanField(default=False, required=False)
customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False)
@@ -869,7 +947,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
model = Order
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', 'require_approval')
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at')
def validate_payment_provider(self, pp):
if pp is None:
@@ -963,8 +1041,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
force = validated_data.pop('force', False)
simulate = validated_data.pop('simulate', False)
self._send_mail = validated_data.pop('send_email', False)
if self._send_mail is None:
self._send_mail = validated_data.get('sales_channel') in self.context['event'].settings.mail_sales_channel_placed_paid
if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address')
@@ -1042,18 +1118,29 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if v.budget is not None:
price = pos_data.get('price')
listed_price = get_listed_price(pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent'))
if pos_data.get('voucher'):
price_after_voucher = pos_data.get('voucher').calculate_price(listed_price)
else:
price_after_voucher = listed_price
if price is None:
price = price_after_voucher
price = get_price(
item=pos_data.get('item'),
variation=pos_data.get('variation'),
voucher=v,
custom_price=None,
subevent=pos_data.get('subevent'),
addon_to=pos_data.get('addon_to'),
invoice_address=ia,
).gross
pbv = get_price(
item=pos_data['item'],
variation=pos_data.get('variation'),
voucher=None,
custom_price=None,
subevent=pos_data.get('subevent'),
addon_to=pos_data.get('addon_to'),
invoice_address=ia,
)
if v not in v_budget:
v_budget[v] = v.budget - v.budget_used()
disc = max(listed_price - price, 0)
disc = pbv.gross - price
if disc > v_budget[v]:
new_disc = v_budget[v]
v_budget[v] -= new_disc
@@ -1132,8 +1219,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
order.meta_info = "{}"
order.total = Decimal('0.00')
if validated_data.get('require_approval') is not None:
order.require_approval = validated_data['require_approval']
if simulate:
order = WrappedModel(order)
order.last_modified = now()
@@ -1149,85 +1234,52 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.invoice_address = ia
ia.last_modified = now()
# Generate position objects
pos_map = {}
for pos_data in positions_data:
answers_data = pos_data.pop('answers', [])
addon_to = pos_data.pop('addon_to', None)
attendee_name = pos_data.pop('attendee_name', '')
if attendee_name and not pos_data.get('attendee_name_parts'):
pos_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers'})
pos = OrderPosition(**pos_data)
if simulate:
pos.order = order._wrapped
else:
pos.order = order
if addon_to:
if simulate:
pos.addon_to = pos_map[addon_to]
pos.addon_to = pos_map[addon_to]._wrapped
else:
pos.addon_to = pos_map[addon_to]
pos_map[pos.positionid] = pos
pos_data['__instance'] = pos
# Calculate prices if not set
for pos_data in positions_data:
pos = pos_data['__instance']
if pos.addon_to_id and is_included_for_free(pos.item, pos.addon_to):
listed_price = Decimal('0.00')
else:
listed_price = get_listed_price(pos.item, pos.variation, pos.subevent)
if pos.price is None:
if pos.voucher:
price_after_voucher = pos.voucher.calculate_price(listed_price)
else:
price_after_voucher = listed_price
line_price = get_line_price(
price_after_voucher=price_after_voucher,
custom_price_input=None,
custom_price_input_is_net=False,
tax_rule=pos.item.tax_rule,
price = get_price(
item=pos.item,
variation=pos.variation,
voucher=pos.voucher,
custom_price=None,
subevent=pos.subevent,
addon_to=pos.addon_to,
invoice_address=ia,
bundled_sum=Decimal('0.00'),
)
pos.price = line_price.gross
pos._auto_generated_price = True
pos.price = price.gross
pos.tax_rate = price.rate
pos.tax_value = price.tax
pos.tax_rule = pos.item.tax_rule
else:
if pos.voucher:
if not pos.item.tax_rule or pos.item.tax_rule.price_includes_tax:
price_after_voucher = max(pos.price, pos.voucher.calculate_price(listed_price))
else:
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
else:
price_after_voucher = listed_price
pos._auto_generated_price = False
pos._voucher_discount = listed_price - price_after_voucher
if pos.voucher:
pos.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
pos._calculate_tax()
order_positions = [pos_data['__instance'] for pos_data in positions_data]
discount_results = apply_discounts(
self.context['event'],
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.price, bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
for cp in order_positions
]
)
for cp, (new_price, discount) in zip(order_positions, discount_results):
if new_price != pos.price and pos._auto_generated_price:
pos.price = new_price
pos.discount = discount
# Save instances
for pos_data in positions_data:
answers_data = pos_data.pop('answers', [])
pos = pos_data['__instance']
pos._calculate_tax()
pos.price_before_voucher = get_price(
item=pos.item,
variation=pos.variation,
voucher=None,
custom_price=None,
subevent=pos.subevent,
addon_to=pos.addon_to,
invoice_address=ia,
).gross
if simulate:
pos = WrappedModel(pos)
@@ -1240,7 +1292,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answers.append(answ)
pos.answers = answers
pos.pseudonymization_id = "PREVIEW"
pos_map[pos.positionid] = pos
else:
if pos.voucher:
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
@@ -1263,6 +1314,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else:
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
pos_map[pos.positionid] = pos
if not simulate:
for cp in delete_cps:
@@ -1304,18 +1356,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
f.order = order._wrapped if simulate else order
f._calculate_tax()
fees.append(f)
if simulate:
f.id = 0
else:
if not simulate:
f.save()
else:
f = OrderFee(**fee_data)
f.order = order._wrapped if simulate else order
f._calculate_tax()
fees.append(f)
if simulate:
f.id = 0
else:
if not simulate:
f.save()
order.total += sum([f.value for f in fees])
@@ -1378,7 +1426,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
class Meta:
model = InvoiceLine
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
fields = ('position', 'description', 'item', 'variation', 'attendee_name', 'event_date_from',
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
'fee_internal_type', 'event_location')

View File

@@ -1,424 +0,0 @@
#
# 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 <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
import os
import pycountry
from django.core.files import File
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField,
OrderPositionCreateSerializer,
)
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
from pretix.base.services.orders import OrderError
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
logger = logging.getLogger(__name__)
class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerializer):
order = serializers.SlugRelatedField(slug_field='code', queryset=Order.objects.none(), required=True, allow_null=False)
answers = AnswerCreateSerializer(many=True, required=False)
addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2,
max_digits=10)
country = CompatibleCountryField(source='*')
class Meta:
model = OrderPosition
fields = ('order', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'secret', 'addon_to', 'subevent', 'answers', 'seat')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['order'].queryset = self.context['event'].orders.all()
self.fields['item'].queryset = self.context['event'].items.all()
self.fields['subevent'].queryset = self.context['event'].subevents.all()
self.fields['seat'].queryset = self.context['event'].seats.all()
self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=self.context['event'])
if 'order' in self.context:
del self.fields['order']
def validate(self, data):
data = super().validate(data)
if data.get('addon_to'):
try:
data['addon_to'] = data['order'].positions.get(positionid=data['addon_to'])
except OrderPosition.DoesNotExist:
raise ValidationError({
'addon_to': ['addon_to refers to an unknown position ID for this order.']
})
return data
def create(self, validated_data):
ocm = self.context['ocm']
try:
ocm.add_position(
item=validated_data['item'],
variation=validated_data.get('variation'),
price=validated_data.get('price'),
addon_to=validated_data.get('addon_to'),
subevent=validated_data.get('subevent'),
seat=validated_data.get('seat'),
)
if self.context.get('commit', True):
ocm.commit()
return validated_data['order'].positions.order_by('-positionid').first()
else:
return OrderPosition() # fake to appease DRF
except OrderError as e:
raise ValidationError(str(e))
class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
answers = AnswerSerializer(many=True)
country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False)
class Meta:
model = OrderPosition
fields = (
'attendee_name', 'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
'state', 'attendee_email', 'answers',
)
def validate(self, data):
if data.get('attendee_name') and data.get('attendee_name_parts'):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country').code):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data
def update(self, instance, validated_data):
answers_data = validated_data.pop('answers', None)
name = validated_data.pop('attendee_name', '')
if name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': name
}
for attr, value in validated_data.items():
if attr in self.fields:
setattr(instance, attr, value)
instance.save(update_fields=list(validated_data.keys()))
if answers_data is not None:
qs_seen = set()
answercache = {
a.question_id: a for a in instance.answers.all()
}
for answ_data in answers_data:
options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk]
if isinstance(answ_data['answer'], File):
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
pass # keep current file
else:
for attr, value in answ_data.items():
setattr(a, attr, value)
a.save()
else:
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='')
a.file.save(os.path.basename(an.name), an, save=False)
a.answer = 'file://' + a.file.name
a.save()
else:
a = instance.answers.create(**answ_data)
a.options.set(options)
qs_seen.add(a.question_id)
for qid, a in answercache.items():
if qid not in qs_seen:
a.delete()
return instance
class OrderPositionChangeSerializer(serializers.ModelSerializer):
seat = serializers.CharField(source='seat.seat_guid', allow_null=True, required=False)
class Meta:
model = OrderPosition
fields = (
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['item'].queryset = self.context['event'].items.all()
self.fields['subevent'].queryset = self.context['event'].subevents.all()
self.fields['tax_rule'].queryset = self.context['event'].tax_rules.all()
if kwargs.get('partial'):
for k, v in self.fields.items():
self.fields[k].required = False
def validate_item(self, item):
if item.event != self.context['event']:
raise ValidationError(
'The specified item does not belong to this event.'
)
return item
def validate_subevent(self, subevent):
if self.context['event'].has_subevents:
if not subevent:
raise ValidationError(
'You need to set a subevent.'
)
if subevent.event != self.context['event']:
raise ValidationError(
'The specified subevent does not belong to this event.'
)
elif subevent:
raise ValidationError(
'You cannot set a subevent for this event.'
)
return subevent
def validate(self, data, instance=None):
instance = instance or self.instance
if instance is None:
return data # needs to be done later
if data.get('item', instance.item):
if data.get('item', instance.item).has_variations:
if not data.get('variation', instance.variation):
raise ValidationError({'variation': ['You should specify a variation for this item.']})
else:
if data.get('variation', instance.variation).item != data.get('item', instance.item):
raise ValidationError(
{'variation': ['The specified variation does not belong to the specified item.']}
)
elif data.get('variation', instance.variation):
raise ValidationError(
{'variation': ['You cannot specify a variation for this item.']}
)
return data
def update(self, instance, validated_data):
ocm = self.context['ocm']
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
item = validated_data.get('item', instance.item)
variation = validated_data.get('variation', instance.variation)
subevent = validated_data.get('subevent', instance.subevent)
price = validated_data.get('price', instance.price)
seat = validated_data.get('seat', current_seat)
tax_rule = validated_data.get('tax_rule', instance.tax_rule)
change_item = None
if item != instance.item or variation != instance.variation:
change_item = (item, variation)
change_subevent = None
if self.context['event'].has_subevents and subevent != instance.subevent:
change_subevent = (subevent,)
try:
if change_item is not None and change_subevent is not None:
ocm.change_item_and_subevent(instance, *change_item, *change_subevent)
elif change_item is not None:
ocm.change_item(instance, *change_item)
elif change_subevent is not None:
ocm.change_subevent(instance, *change_subevent)
if seat != current_seat or change_subevent:
ocm.change_seat(instance, seat['seat_guid'] if seat else None)
if price != instance.price:
ocm.change_price(instance, price)
if tax_rule != instance.tax_rule:
ocm.change_tax_rule(instance, tax_rule)
if self.context.get('commit', True):
ocm.commit()
instance.refresh_from_db()
except OrderError as e:
raise ValidationError(str(e))
return instance
class PatchPositionSerializer(serializers.Serializer):
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none())
def validate_position(self, value):
self.fields['body'].instance = value # hack around DRFs validation order
return value
def validate(self, data):
OrderPositionChangeSerializer(context=self.context, partial=True).validate(data['body'], data['position'])
return data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['position'].queryset = self.context['order'].positions.all()
self.fields['body'] = OrderPositionChangeSerializer(context=self.context, partial=True)
class SelectPositionSerializer(serializers.Serializer):
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['position'].queryset = self.context['order'].positions.all()
class OrderFeeChangeSerializer(serializers.ModelSerializer):
class Meta:
model = OrderFee
fields = (
'value',
)
def update(self, instance, validated_data):
ocm = self.context['ocm']
value = validated_data.get('value', instance.value)
try:
if value != instance.value:
ocm.change_fee(instance, value)
if self.context.get('commit', True):
ocm.commit()
instance.refresh_from_db()
except OrderError as e:
raise ValidationError(str(e))
return instance
class PatchFeeSerializer(serializers.Serializer):
fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['fee'].queryset = self.context['order'].fees.all()
self.fields['body'] = OrderFeeChangeSerializer(context=self.context)
class SelectFeeSerializer(serializers.Serializer):
fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['fee'].queryset = self.context['order'].fees.all()
class OrderChangeOperationSerializer(serializers.Serializer):
send_email = serializers.BooleanField(default=False, required=False)
reissue_invoice = serializers.BooleanField(default=True, required=False)
recalculate_taxes = serializers.ChoiceField(default=None, allow_null=True, required=False, choices=[
('keep_net', 'keep_net'),
('keep_gross', 'keep_gross'),
])
def __init__(self, *args, **kwargs):
super().__init__(self, *args, **kwargs)
self.fields['patch_positions'] = PatchPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['cancel_positions'] = SelectPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['create_positions'] = OrderPositionCreateForExistingOrderSerializer(
many=True, required=False, context=self.context
)
self.fields['split_positions'] = SelectPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['patch_fees'] = PatchFeeSerializer(
many=True, required=False, context=self.context
)
self.fields['cancel_fees'] = SelectFeeSerializer(
many=True, required=False, context=self.context
)
def validate(self, data):
seen_positions = set()
for d in data.get('patch_positions', []):
print(d, seen_positions)
if d['position'] in seen_positions:
raise ValidationError({'patch_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_positions = set()
for d in data.get('cancel_positions', []):
if d['position'] in seen_positions:
raise ValidationError({'cancel_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_positions = set()
for d in data.get('split_positions', []):
if d['position'] in seen_positions:
raise ValidationError({'split_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_fees = set()
for d in data.get('patch_fees', []):
if d['fee'] in seen_fees:
raise ValidationError({'patch_fees': ['You have specified the same object twice.']})
seen_positions.add(d['fee'])
seen_fees = set()
for d in data.get('cancel_fees', []):
if d['fee'] in seen_fees:
raise ValidationError({'cancel_fees': ['You have specified the same object twice.']})
seen_positions.add(d['fee'])
return data

View File

@@ -71,8 +71,8 @@ class CustomerSerializer(I18nAwareModelSerializer):
class Meta:
model = Customer
fields = ('identifier', 'external_identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
'locale', 'last_modified', 'notes')
fields = ('identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
'locale', 'last_modified')
class MembershipTypeSerializer(I18nAwareModelSerializer):
@@ -296,14 +296,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'theme_round_borders',
'primary_font',
'organizer_logo_image_inherit',
'organizer_logo_image',
'privacy_url',
'cookie_consent',
'cookie_consent_dialog_title',
'cookie_consent_dialog_text',
'cookie_consent_dialog_text_secondary',
'cookie_consent_dialog_button_yes',
'cookie_consent_dialog_button_no',
'organizer_logo_image'
]
def __init__(self, *args, **kwargs):

View File

@@ -41,8 +41,8 @@ from rest_framework import routers
from pretix.api.views import cart
from .views import (
checkin, device, discount, event, exporters, item, oauth, order, organizer,
upload, user, version, voucher, waitinglist, webhooks,
checkin, device, event, exporters, item, oauth, order, organizer, upload,
user, version, voucher, waitinglist, webhooks,
)
router = routers.DefaultRouter()
@@ -72,7 +72,6 @@ event_router.register(r'clone', event.CloneEventViewSet)
event_router.register(r'items', item.ItemViewSet)
event_router.register(r'categories', item.ItemCategoryViewSet)
event_router.register(r'questions', item.QuestionViewSet)
event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.OrderViewSet)

View File

@@ -424,7 +424,6 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
forced=force,
)
raw_barcode_for_checkin = None
from_revoked_secret = False
try:
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
@@ -500,7 +499,6 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
elif revoked_matches and force:
op = revoked_matches[0].position
raw_barcode_for_checkin = self.kwargs['pk']
from_revoked_secret = True
else:
op = revoked_matches[0].position
op.order.log_action('pretix.event.checkin.revoked', data={
@@ -552,7 +550,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
auth=self.request.auth,
type=type,
raw_barcode=raw_barcode_for_checkin,
from_revoked_secret=from_revoked_secret,
from_revoked_secret=True,
)
except RequiredQuestionsError as e:
return Response({

View File

@@ -42,7 +42,6 @@ class InitializationRequestSerializer(serializers.Serializer):
hardware_model = serializers.CharField(max_length=190)
software_brand = serializers.CharField(max_length=190)
software_version = serializers.CharField(max_length=190)
info = serializers.JSONField(required=False, allow_null=True)
class UpdateRequestSerializer(serializers.Serializer):
@@ -50,7 +49,6 @@ class UpdateRequestSerializer(serializers.Serializer):
hardware_model = serializers.CharField(max_length=190)
software_brand = serializers.CharField(max_length=190)
software_version = serializers.CharField(max_length=190)
info = serializers.JSONField(required=False, allow_null=True)
class GateSerializer(serializers.ModelSerializer):
@@ -96,7 +94,6 @@ class InitializeView(APIView):
device.hardware_model = serializer.validated_data.get('hardware_model')
device.software_brand = serializer.validated_data.get('software_brand')
device.software_version = serializer.validated_data.get('software_version')
device.info = serializer.validated_data.get('info')
device.api_token = generate_api_token()
device.save()
@@ -117,7 +114,6 @@ class UpdateView(APIView):
device.hardware_model = serializer.validated_data.get('hardware_model')
device.software_brand = serializer.validated_data.get('software_brand')
device.software_version = serializer.validated_data.get('software_version')
device.info = serializer.validated_data.get('info')
device.save()
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)

View File

@@ -1,99 +0,0 @@
#
# 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 <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This 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>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Ture Gjørup
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import OrderingFilter
from pretix.api.serializers.discount import DiscountSerializer
from pretix.api.views import ConditionalListView
from pretix.base.models import CartPosition, Discount
with scopes_disabled():
class DiscountFilter(FilterSet):
class Meta:
model = Discount
fields = ['active']
class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = DiscountSerializer
queryset = Discount.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
filterset_class = DiscountFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
return self.request.event.discounts.all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.discount.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.discount.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('You cannot delete this discount because it already has '
'been used as part of an order.')
instance.log_action(
'pretix.event.discount.deleted',
user=self.request.user,
auth=self.request.auth,
)
CartPosition.objects.filter(discount=instance).update(discount=None)
super().perform_destroy(instance)

View File

@@ -132,7 +132,7 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
def exporters(self):
exporters = []
responses = register_data_exporters.send(self.request.event)
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
return exporters
@@ -147,11 +147,7 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
@cached_property
def exporters(self):
exporters = []
if isinstance(self.request.auth, (Device, TeamAPIToken)):
perm_holder = self.request.auth
else:
perm_holder = self.request.user
events = perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter(
events = (self.request.auth or self.request.user).get_events_with_permission('can_view_orders', request=self.request).filter(
organizer=self.request.organizer
)
responses = register_multievent_data_exporters.send(self.request.organizer)
@@ -161,12 +157,8 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
return exporters
def get_serializer_kwargs(self):
if isinstance(self.request.auth, (Device, TeamAPIToken)):
perm_holder = self.request.auth
else:
perm_holder = self.request.user
return {
'events': perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter(
'events': self.request.auth.get_events_with_permission('can_view_orders', request=self.request).filter(
organizer=self.request.organizer
)
}

View File

@@ -36,7 +36,7 @@ from django.utils.translation import gettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from PIL import Image
from rest_framework import serializers, status, viewsets
from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError,
@@ -53,12 +53,6 @@ from pretix.api.serializers.order import (
PriceCalcSerializer, RevokedTicketSecretSerializer,
SimulatedOrderSerializer,
)
from pretix.api.serializers.orderchange import (
OrderChangeOperationSerializer, OrderFeeChangeSerializer,
OrderPositionChangeSerializer,
OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer,
)
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Checkin, Device, Event, Invoice,
@@ -100,7 +94,6 @@ with scopes_disabled():
search = django_filters.CharFilter(method='search_qs')
item = django_filters.CharFilter(field_name='all_positions', lookup_expr='item_id')
variation = django_filters.CharFilter(field_name='all_positions', lookup_expr='variation_id')
subevent = django_filters.CharFilter(field_name='all_positions', lookup_expr='subevent_id')
class Meta:
model = Order
@@ -150,8 +143,7 @@ with scopes_disabled():
matching_positions = OrderPosition.objects.filter(
Q(order=OuterRef('pk')) & Q(
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
| Q(secret__istartswith=u)
# | Q(voucher__code__icontains=u) # temporarily removed since it caused bad query performance on postgres
| Q(secret__istartswith=u) | Q(voucher__code__icontains=u)
)
).values('id')
@@ -345,7 +337,6 @@ class OrderViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['POST'])
def mark_canceled(self, request, **kwargs):
send_mail = request.data.get('send_email', True)
comment = request.data.get('comment', None)
cancellation_fee = request.data.get('cancellation_fee', None)
if cancellation_fee:
try:
@@ -368,7 +359,6 @@ class OrderViewSet(viewsets.ModelViewSet):
device=request.auth if isinstance(request.auth, Device) else None,
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
send_mail=send_mail,
email_comment=comment,
cancellation_fee=cancellation_fee
)
except OrderError as e:
@@ -653,13 +643,9 @@ class OrderViewSet(viewsets.ModelViewSet):
if send_mail:
free_flow = (
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
not order.require_approval and payment.provider in ("free", "boxoffice")
not order.require_approval and payment.provider == "free"
)
if order.require_approval:
email_template = request.event.settings.mail_text_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval'
email_attendees = False
elif free_flow:
if free_flow:
email_template = request.event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free'
email_attendees = request.event.settings.mail_send_order_free_attendee
@@ -672,13 +658,12 @@ class OrderViewSet(viewsets.ModelViewSet):
_order_placed_email(
request.event, order, payment.payment_provider if payment else None, email_template,
log_entry, invoice, payment, is_free=free_flow
log_entry, invoice, payment
)
if email_attendees:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry,
is_free=free_flow)
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry)
if not free_flow and order.status == Order.STATUS_PAID and payment:
payment._send_paid_mail(invoice, None, '')
@@ -791,79 +776,6 @@ class OrderViewSet(viewsets.ModelViewSet):
with transaction.atomic():
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
@action(detail=True, methods=['POST'])
def change(self, request, **kwargs):
order = self.get_object()
serializer = OrderChangeOperationSerializer(
context={'order': order, **self.get_serializer_context()},
data=request.data,
)
serializer.is_valid(raise_exception=True)
try:
ocm = OrderChangeManager(
order=order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=serializer.validated_data.get('send_email', False),
reissue_invoice=serializer.validated_data.get('reissue_invoice', True),
)
canceled_positions = set()
for r in serializer.validated_data.get('cancel_positions', []):
ocm.cancel(r['position'])
canceled_positions.add(r['position'])
for r in serializer.validated_data.get('patch_positions', []):
if r['position'] in canceled_positions:
continue
pos_serializer = OrderPositionChangeSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
partial=True,
)
pos_serializer.update(r['position'], r['body'])
for r in serializer.validated_data.get('split_positions', []):
if r['position'] in canceled_positions:
continue
ocm.split(r['position'])
for r in serializer.validated_data.get('create_positions', []):
pos_serializer = OrderPositionCreateForExistingOrderSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
)
pos_serializer.create(r)
canceled_fees = set()
for r in serializer.validated_data.get('cancel_fees', []):
ocm.cancel_fee(r['fee'])
canceled_fees.add(r['fee'])
for r in serializer.validated_data.get('patch_fees', []):
if r['fee'] in canceled_fees:
continue
pos_serializer = OrderFeeChangeSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
)
pos_serializer.update(r['fee'], r['body'])
if serializer.validated_data.get('recalculate_taxes') == 'keep_net':
ocm.recalculate_taxes(keep='net')
elif serializer.validated_data.get('recalculate_taxes') == 'keep_gross':
ocm.recalculate_taxes(keep='gross')
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
order.refresh_from_db()
serializer = OrderSerializer(
instance=order,
context=self.get_serializer_context(),
)
return Response(serializer.data)
with scopes_disabled():
class OrderPositionFilter(FilterSet):
@@ -905,7 +817,7 @@ with scopes_disabled():
}
class OrderPositionViewSet(viewsets.ModelViewSet):
class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
@@ -1142,25 +1054,6 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
)
return resp
@action(detail=True, methods=['POST'])
def regenerate_secrets(self, request, **kwargs):
instance = self.get_object()
try:
ocm = OrderChangeManager(
instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
notify=False,
reissue_invoice=False,
)
ocm.regenerate_secret(instance)
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
return self.retrieve(request, [], **kwargs)
def perform_destroy(self, instance):
try:
ocm = OrderChangeManager(
@@ -1176,63 +1069,6 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
def create(self, request, *args, **kwargs):
with transaction.atomic():
serializer = OrderPositionCreateForExistingOrderSerializer(
data=request.data,
context=self.get_serializer_context(),
)
serializer.is_valid(raise_exception=True)
order = serializer.validated_data['order']
ocm = OrderChangeManager(
order=order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=False,
reissue_invoice=False,
)
serializer.context['ocm'] = ocm
serializer.save()
# Fields that can be easily patched after the position was added
old_data = OrderPositionInfoPatchSerializer(instance=serializer.instance, context=self.get_serializer_context()).data
serializer = OrderPositionInfoPatchSerializer(
instance=serializer.instance,
context=self.get_serializer_context(),
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
new_data = serializer.data
if old_data != new_data:
log_data = self.request.data
if 'answers' in log_data:
for a in new_data['answers']:
log_data[f'question_{a["question"]}'] = a["answer"]
log_data.pop('answers', None)
serializer.instance.order.log_action(
'pretix.event.order.modified',
user=self.request.user,
auth=self.request.auth,
data={
'data': [
dict(
position=serializer.instance.pk,
**log_data
)
]
}
)
tickets.invalidate_cache.apply_async(
kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
return Response(
OrderPositionSerializer(serializer.instance, context=self.get_serializer_context()).data,
status=status.HTTP_201_CREATED,
)
def update(self, request, *args, **kwargs):
partial = kwargs.get('partial', False)
if not partial:
@@ -1240,36 +1076,11 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
{"detail": "Method \"PUT\" not allowed."},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
return super().update(request, *args, **kwargs)
def perform_update(self, serializer):
with transaction.atomic():
instance = self.get_object()
ocm = OrderChangeManager(
order=instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=False,
reissue_invoice=False,
)
# Field that need to go through OrderChangeManager
serializer = OrderPositionChangeSerializer(
instance=instance,
context={'ocm': ocm, **self.get_serializer_context()},
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
# Fields that can be easily patched
old_data = OrderPositionInfoPatchSerializer(instance=instance, context=self.get_serializer_context()).data
serializer = OrderPositionInfoPatchSerializer(
instance=instance,
context=self.get_serializer_context(),
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
old_data = self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data
serializer.save()
new_data = serializer.data
@@ -1292,10 +1103,9 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
]
}
)
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
return Response(self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data)
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):

View File

@@ -94,9 +94,6 @@ class BaseAuthBackend:
This method will be called after the user filled in the login form. ``request`` will contain
the current request and ``form_data`` the input for the form fields defined in ``login_form_fields``.
You are expected to either return a ``User`` object (if login was successful) or ``None``.
You are expected to either return a ``User`` object (if login was successful) or ``None``. You should
obtain this user object using ``User.objects.get_or_create_for_backend``.
"""
return
@@ -107,9 +104,7 @@ class BaseAuthBackend:
reverse proxy, you can directly return a ``User`` object that will be logged in.
``request`` will contain the current request.
You are expected to either return a ``User`` object (if login was successful) or ``None``. You should
obtain this user object using ``User.objects.get_or_create_for_backend``.
You are expected to either return a ``User`` object (if login was successful) or ``None``.
"""
return
@@ -151,8 +146,7 @@ class NativeAuthBackend(BaseAuthBackend):
d = OrderedDict([
('email', forms.EmailField(label=_("E-mail"), max_length=254,
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput,
max_length=4096)),
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput)),
])
return d

View File

@@ -89,13 +89,6 @@ class SalesChannel:
"""
return True
@property
def discounts_supported(self) -> bool:
"""
If this property is ``True``, this sales channel can be selected for automatic discounts.
"""
return True
def get_all_sales_channels():
global _ALL_CHANNELS

View File

@@ -25,7 +25,6 @@ from datetime import timedelta
from decimal import Decimal
from itertools import groupby
from smtplib import SMTPResponseException
from typing import TypeVar
import css_inline
from django.conf import settings
@@ -33,7 +32,6 @@ from django.core.mail.backends.smtp import EmailBackend
from django.db.models import Count
from django.dispatch import receiver
from django.template.loader import get_template
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import (
get_language, gettext_lazy as _, pgettext_lazy,
@@ -51,23 +49,23 @@ from pretix.base.templatetags.rich_text import markdown_compile_email
logger = logging.getLogger('pretix.base.email')
T = TypeVar("T", bound=EmailBackend)
class CustomSMTPBackend(EmailBackend):
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
try:
backend.open()
backend.connection.ehlo_or_helo_if_needed()
(code, resp) = backend.connection.mail(from_addr, [])
if code != 250:
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPResponseException(code, resp)
(code, resp) = backend.connection.rcpt('testdummy@pretix.eu')
if (code != 250) and (code != 251):
logger.warning('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPResponseException(code, resp)
finally:
backend.close()
def test(self, from_addr):
try:
self.open()
self.connection.ehlo_or_helo_if_needed()
(code, resp) = self.connection.mail(from_addr, [])
if code != 250:
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPResponseException(code, resp)
(code, resp) = self.connection.rcpt('testdummy@pretix.eu')
if (code != 250) and (code != 251):
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
raise SMTPResponseException(code, resp)
finally:
self.close()
class BaseHTMLMailRenderer:
@@ -165,20 +163,9 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
has_addons=Count('addons')
))
htmlctx['cart'] = [(k, list(v)) for k, v in groupby(
sorted(
positions,
key=lambda op: (
(op.addon_to.positionid if op.addon_to_id else op.positionid),
op.positionid
)
),
key=lambda op: (
op.item,
op.variation,
op.subevent,
op.attendee_name,
op.addon_to_id,
(op.pk if op.has_addons else None)
positions, key=lambda op: (
op.item, op.variation, op.subevent, op.attendee_name,
(op.pk if op.addon_to_id else None), (op.pk if op.has_addons else None)
)
)]
@@ -310,11 +297,7 @@ def get_email_context(**kwargs):
val = [val]
for v in val:
if all(rp in kwargs for rp in v.required_context):
try:
ctx[v.identifier] = v.render(kwargs)
except:
ctx[v.identifier] = '(error)'
logger.exception(f'Failed to process email placeholder {v.identifier}.')
ctx[v.identifier] = v.render(kwargs)
return ctx
@@ -469,15 +452,6 @@ def base_placeholders(sender, **kwargs):
}
),
),
SimpleFunctionalMailTextPlaceholder(
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
lambda event: str(event.location or ''),
),
SimpleFunctionalMailTextPlaceholder(
'event_admission_time', ['event_or_subevent'],
lambda event_or_subevent: date_format(event_or_subevent.date_admission, 'TIME_FORMAT') if event_or_subevent.date_admission else '',
lambda event: date_format(event.date_admission, 'TIME_FORMAT') if event.date_admission else '',
),
SimpleFunctionalMailTextPlaceholder(
'subevent', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event),
@@ -647,10 +621,6 @@ def base_placeholders(sender, **kwargs):
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
v
))
ph.append(SimpleFunctionalMailTextPlaceholder(
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
v
))
return ph

View File

@@ -33,6 +33,7 @@
# License for the specific language governing permissions and limitations under the License.
import io
import re
import tempfile
from collections import OrderedDict, namedtuple
from decimal import Decimal
@@ -45,13 +46,26 @@ from django.conf import settings
from django.db.models import QuerySet
from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _
from openpyxl import Workbook
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES, Cell
from pretix.base.models import Event
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
)
__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter
def excel_safe(val):
if isinstance(val, Cell):
return val
if not isinstance(val, KNOWN_TYPES):
val = str(val)
if isinstance(val, bytes):
val = val.decode("utf-8", errors="ignore")
if isinstance(val, str):
val = re.sub(ILLEGAL_CHARACTERS_RE, '', val)
return val
class BaseExporter:
@@ -214,7 +228,7 @@ class ListExporter(BaseExporter):
pass
def _render_xlsx(self, form_data, output_file=None):
wb = SafeWorkbook(write_only=True)
wb = Workbook(write_only=True)
ws = wb.create_sheet()
self.prepare_xlsx_sheet(ws)
try:
@@ -228,7 +242,7 @@ class ListExporter(BaseExporter):
total = line.total
continue
ws.append([
val for val in line
excel_safe(val) for val in line
])
if total:
counter += 1
@@ -333,7 +347,7 @@ class MultiSheetListExporter(ListExporter):
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def _render_xlsx(self, form_data, output_file=None):
wb = SafeWorkbook(write_only=True)
wb = Workbook(write_only=True)
n_sheets = len(self.sheets)
for i_sheet, (s, l) in enumerate(self.sheets):
ws = wb.create_sheet(str(l))
@@ -347,7 +361,8 @@ class MultiSheetListExporter(ListExporter):
total = line.total
continue
ws.append([
val for val in line
excel_safe(val)
for val in line
])
if total:
counter += 1

View File

@@ -21,7 +21,6 @@
#
from .answers import * # noqa
from .dekodi import * # noqa
from .events import * # noqa
from .invoices import * # noqa
from .json import * # noqa
from .mail import * # noqa

View File

@@ -1,100 +0,0 @@
#
# 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 <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This 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>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from ...control.forms.filter import get_all_payment_providers
from ..exporter import ListExporter
from ..signals import register_multievent_data_exporters
class EventDataExporter(ListExporter):
identifier = 'eventdata'
verbose_name = _('Event data')
@cached_property
def providers(self):
return dict(get_all_payment_providers())
def iterate_list(self, form_data):
header = [
_("Event name"),
_("Short form"),
_("Shop is live"),
_("Event currency"),
_("Event start time"),
_("Event end time"),
_("Admission time"),
_("Start of presale"),
_("End of presale"),
_("Location"),
_("Latitude"),
_("Longitude"),
_("Internal comment"),
]
props = list(self.organizer.meta_properties.all())
for p in props:
header.append(p.name)
yield header
for e in self.events.all():
m = e.meta_data
yield [
str(e.name),
e.slug,
_('Yes') if e.live else _('No'),
e.currency,
date_format(e.date_from, 'SHORT_DATETIME_FORMAT'),
date_format(e.date_to, 'SHORT_DATETIME_FORMAT') if e.date_to else '',
date_format(e.date_admission, 'SHORT_DATETIME_FORMAT') if e.date_admission else '',
date_format(e.presale_start, 'SHORT_DATETIME_FORMAT') if e.presale_start else '',
date_format(e.presale_end, 'SHORT_DATETIME_FORMAT') if e.presale_end else '',
str(e.location),
e.geo_lat or '',
e.geo_lon or '',
e.comment,
] + [
m.get(p.name, '') for p in props
]
def get_filename(self):
return '{}_events'.format(self.events.first().organizer.slug)
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_eventdata")
def register_multievent_eventdata_exporter(sender, **kwargs):
return EventDataExporter

View File

@@ -259,7 +259,7 @@ class OrderListExporter(MultiSheetListExporter):
payment_providers=Subquery(p_providers, output_field=CharField()),
invoice_numbers=Subquery(i_numbers, output_field=CharField()),
pcnt=Subquery(s, output_field=IntegerField())
).select_related('invoice_address', 'customer')
).select_related('invoice_address')
qs = self._date_filter(qs, form_data, rel='')
@@ -268,8 +268,8 @@ class OrderListExporter(MultiSheetListExporter):
tax_rates = self._get_all_tax_rates(qs)
headers = [
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Phone number'),
_('Order date'), _('Order time'), _('Company'), _('Name'),
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Phone number'), _('Order date'),
_('Order time'), _('Company'), _('Name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
if name_scheme and len(name_scheme['fields']) > 1:
@@ -294,7 +294,6 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Follow-up date'))
headers.append(_('Positions'))
headers.append(_('E-mail address verified'))
headers.append(_('External customer ID'))
headers.append(_('Payment providers'))
if form_data.get('include_payment_amounts'):
payment_methods = self._get_all_payment_methods(qs)
@@ -401,7 +400,6 @@ class OrderListExporter(MultiSheetListExporter):
row.append(order.custom_followup_at.strftime("%Y-%m-%d") if order.custom_followup_at else "")
row.append(order.pcnt)
row.append(_('Yes') if order.email_known_to_work else _('No'))
row.append(str(order.customer.external_identifier) if order.customer and order.customer.external_identifier else '')
row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((order.payment_providers or '').split(',')))
if p and p != 'free'
@@ -426,13 +424,13 @@ class OrderListExporter(MultiSheetListExporter):
).values(
'm'
).order_by()
qs = OrderFee.all.filter(
qs = OrderFee.objects.filter(
order__event__in=self.events,
).annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
).select_related('order', 'order__invoice_address', 'order__customer', 'tax_rule')
).select_related('order', 'order__invoice_address', 'tax_rule')
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
qs = qs.filter(order__status=Order.STATUS_PAID)
qs = self._date_filter(qs, form_data, rel='order__')
@@ -461,7 +459,6 @@ class OrderListExporter(MultiSheetListExporter):
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
]
headers.append(_('External customer ID'))
headers.append(_('Payment providers'))
yield headers
@@ -472,7 +469,7 @@ class OrderListExporter(MultiSheetListExporter):
row = [
self.event_object_cache[order.event_id].slug,
order.code,
_("canceled") if op.canceled else order.get_status_display(),
order.get_status_display(),
order.email,
str(order.phone) if order.phone else '',
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
@@ -505,7 +502,6 @@ class OrderListExporter(MultiSheetListExporter):
]
except InvoiceAddress.DoesNotExist:
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
row.append(str(order.customer.external_identifier) if order.customer and order.customer.external_identifier else '')
row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
if p and p != 'free'
@@ -522,19 +518,19 @@ class OrderListExporter(MultiSheetListExporter):
).values(
'm'
).order_by()
base_qs = OrderPosition.all.filter(
base_qs = OrderPosition.objects.filter(
order__event__in=self.events,
)
qs = base_qs.annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
).select_related(
'order', 'order__invoice_address', 'order__customer', 'item', 'variation',
'order', 'order__invoice_address', 'item', 'variation',
'voucher', 'tax_rule'
).prefetch_related(
'answers', 'answers__question', 'answers__options'
)
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
qs = qs.filter(order__status=Order.STATUS_PAID)
qs = self._date_filter(qs, form_data, rel='order__')
@@ -577,7 +573,6 @@ class OrderListExporter(MultiSheetListExporter):
pgettext('address', 'State'),
_('Voucher'),
_('Pseudonymization ID'),
_('Ticket secret'),
_('Seat ID'),
_('Seat name'),
_('Seat zone'),
@@ -615,7 +610,6 @@ class OrderListExporter(MultiSheetListExporter):
headers += [
_('Sales channel'), _('Order locale'),
_('E-mail address verified'),
_('External customer ID'),
_('Payment providers'),
]
@@ -633,7 +627,7 @@ class OrderListExporter(MultiSheetListExporter):
self.event_object_cache[order.event_id].slug,
order.code,
op.positionid,
_("canceled") if op.canceled else order.get_status_display(),
order.get_status_display(),
order.email,
str(order.phone) if order.phone else '',
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
@@ -675,7 +669,6 @@ class OrderListExporter(MultiSheetListExporter):
op.state or '',
op.voucher.code if op.voucher else '',
op.pseudonymization_id,
op.secret,
]
if op.seat:
@@ -735,8 +728,7 @@ class OrderListExporter(MultiSheetListExporter):
row += [
order.sales_channel,
order.locale,
_('Yes') if order.email_known_to_work else _('No'),
str(order.customer.external_identifier) if order.customer and order.customer.external_identifier else '',
_('Yes') if order.email_known_to_work else _('No')
]
row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))

View File

@@ -38,7 +38,6 @@ import i18nfield.forms
from django import forms
from django.forms.models import ModelFormMetaclass
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
from formtools.wizard.views import SessionWizardView
from hierarkey.forms import HierarkeyForm
@@ -113,42 +112,12 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
f.set_event(self.obj)
def _unmask_secret_fields(self):
def save(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.initial[k]
def save(self):
self._unmask_secret_fields()
return super().save()
def clean(self):
d = super().clean()
# There is logic in HierarkeyForm.save() to only persist fields that changed. HierarkeyForm determines if
# something changed by comparing `self._s.get(name)` to `value`. This leaves an edge case open for multi-lingual
# text fields. On the very first load, the initial value in `self._s.get(name)` will be a LazyGettextProxy-based
# string. However, only some of the languages are usually visible, so even if the user does not change anything
# at all, it will be considered a changed value and stored. We do not want that, as it makes it very hard to add
# languages to an organizer/event later on. So we trick it and make sure nothing gets changed in that situation.
for name, field in self.fields.items():
if isinstance(field, SecretKeySettingsField) and d.get(name) == SECRET_REDACTED and not self.initial.get(name):
self.add_error(
name,
_('Due to technical reasons you cannot set inputs, that need to be masked (e.g. passwords), to %(value)s.') % {'value': SECRET_REDACTED}
)
if isinstance(field, i18nfield.forms.I18nFormField):
value = d.get(name)
if not value:
continue
current = self._s.get(name, as_type=type(value))
if name not in self.changed_data:
d[name] = current
return d
def get_new_filename(self, name: str) -> str:
from pretix.base.models import Event

View File

@@ -154,7 +154,6 @@ class RegistrationForm(forms.Form):
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
}),
max_length=4096,
required=True
)
password_repeat = forms.CharField(
@@ -162,7 +161,6 @@ class RegistrationForm(forms.Form):
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
}),
max_length=4096,
required=True
)
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
@@ -206,13 +204,11 @@ class PasswordRecoverForm(forms.Form):
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput,
max_length=4096,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput,
max_length=4096,
widget=forms.PasswordInput
)
def __init__(self, user_id=None, *args, **kwargs):

View File

@@ -41,16 +41,16 @@ from io import BytesIO
import dateutil.parser
import pycountry
import pytz
from babel import Locale
from django import forms
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, RegexValidator,
)
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.utils import translation
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -85,9 +85,7 @@ from pretix.base.templatetags.rich_text import rich_text
from pretix.control.forms import (
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
)
from pretix.helpers.countries import (
CachedCountries, get_phone_prefixes_sorted_and_localized,
)
from pretix.helpers.countries import CachedCountries
from pretix.helpers.escapejson import escapejson_attr
from pretix.helpers.i18n import get_format_without_seconds
from pretix.presale.signals import question_form_fields
@@ -189,15 +187,6 @@ 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.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles')
@@ -218,7 +207,6 @@ 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]]
@@ -227,7 +215,6 @@ class NamePartsFormField(forms.MultiValueField):
elif fname == 'salutation':
d = dict(defaults)
d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField(
**d,
choices=[('', '---')] + PERSON_NAME_SALUTATIONS
@@ -264,14 +251,17 @@ class WrappedPhonePrefixSelect(Select):
def __init__(self, initial=None):
choices = [("", "---------")]
if initial:
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
if initial in values:
self.initial = "+%d" % prefix
break
choices += get_phone_prefixes_sorted_and_localized()
super().__init__(choices=choices, attrs={'aria-label': pgettext_lazy('phonenumber', 'International area code')})
language = get_babel_locale() # changed from default implementation that used the django locale
locale = Locale(translation.to_locale(language))
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
prefix = "+%d" % prefix
if initial and initial in values:
self.initial = prefix
for country_code in values:
country_name = locale.territories.get(country_code)
if country_name:
choices.append((prefix, "{} {}".format(country_name, prefix)))
super().__init__(choices=sorted(choices, key=lambda item: item[1]), attrs={'aria-label': pgettext_lazy('phonenumber', 'International area code')})
def render(self, name, value, *args, **kwargs):
return super().render(name, value or self.initial, *args, **kwargs)
@@ -315,12 +305,7 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
silently deleting data.
"""
if value:
if isinstance(value, str):
try:
value = PhoneNumber.from_string(value)
except:
pass
if isinstance(value, PhoneNumber):
if type(value) == PhoneNumber:
if value.country_code and value.national_number:
return [
"+%d" % value.country_code,
@@ -348,41 +333,23 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def guess_country(event):
# Try to guess the initial country from either the country of the merchant
# or the locale. This will hopefully save at least some users some scrolling :)
locale = get_language_without_region()
country = event.settings.region or event.settings.invoice_address_from_country
if not country:
country = get_country_by_locale(get_language_without_region())
valid_countries = countries.countries
if '-' in locale:
parts = locale.split('-')
# TODO: does this actually work?
if parts[1].upper() in valid_countries:
country = Country(parts[1].upper())
elif parts[0].upper() in valid_countries:
country = Country(parts[0].upper())
else:
if locale.upper() in valid_countries:
country = Country(locale.upper())
return country
def get_country_by_locale(locale):
country = None
valid_countries = countries.countries
if '-' in locale:
parts = locale.split('-')
# TODO: does this actually work?
if parts[1].upper() in valid_countries:
country = Country(parts[1].upper())
elif parts[0].upper() in valid_countries:
country = Country(parts[0].upper())
else:
if locale.upper() in valid_countries:
country = Country(locale.upper())
return country
def guess_phone_prefix(event):
with language(get_babel_locale()):
country = str(guess_country(event))
return get_phone_prefix(country)
def get_phone_prefix(country):
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
if country in values:
return prefix
return None
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
@@ -429,7 +396,7 @@ class PortraitImageWidget(UploadedFileWidget):
def value_from_datadict(self, data, files, name):
d = super().value_from_datadict(data, files, name)
if d is not None and d is not False:
if d is not None:
d._cropdata = json.loads(data.get(name + '_cropdata', '{}') or '{}')
return d
@@ -707,7 +674,7 @@ class BaseQuestionsForm(forms.Form):
label=label, required=required,
min_value=q.valid_number_min or Decimal('0.00'),
max_value=q.valid_number_max,
help_text=help_text,
help_text=q.help_text,
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_STRING:
@@ -813,26 +780,25 @@ class BaseQuestionsForm(forms.Form):
if q.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
elif q.type == Question.TYPE_PHONENUMBER:
if initial:
with language(get_babel_locale()):
default_country = guess_country(event)
default_prefix = None
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
if str(default_country) in values:
default_prefix = prefix
try:
initial = PhoneNumber().from_string(initial.answer)
initial = PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix)
except NumberParseException:
initial = None
if not initial:
phone_prefix = guess_phone_prefix(event)
if phone_prefix:
initial = "+{}.".format(phone_prefix)
field = PhoneNumberField(
label=label, required=required,
help_text=help_text,
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
field = PhoneNumberField(
label=label, required=required,
help_text=help_text,
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
field.question = q
if answers:
# Cache the answer object for later use
@@ -903,12 +869,6 @@ class BaseQuestionsForm(forms.Form):
if question_is_required(q) and not answer and answer != 0 and not field.errors:
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
# Strip invisible question from cleaned_data so they don't end up in the database
for q in question_cache.values():
answer = d.get('question_%d' % q.pk)
if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None:
d['question_%d' % q.pk] = None
return d
@@ -1084,11 +1044,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.instance.vat_id_validated = True
self.instance.vat_id = normalized_id
except VATIDFinalError as e:
if self.all_optional:
self.instance.vat_id_validated = False
messages.warning(self.request, e.message)
else:
raise ValidationError(e.message)
raise ValidationError(e.message)
except VATIDTemporaryError as e:
self.instance.vat_id_validated = False
if self.request and self.vat_warning:

View File

@@ -42,24 +42,6 @@ from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _
def replace_arabic_numbers(inp):
if not isinstance(inp, str):
return inp
table = {
1632: 48, # 0
1633: 49, # 1
1634: 50, # 2
1635: 51, # 3
1636: 52, # 4
1637: 53, # 5
1638: 54, # 6
1639: 55, # 7
1640: 56, # 8
1641: 57, # 9
}
return inp.translate(table)
class DatePickerWidget(forms.DateInput):
def __init__(self, attrs=None, date_format=None):
attrs = attrs or {}
@@ -80,10 +62,6 @@ class DatePickerWidget(forms.DateInput):
forms.DateInput.__init__(self, date_attrs, date_format)
def value_from_datadict(self, data, files, name):
v = super().value_from_datadict(data, files, name)
return replace_arabic_numbers(v)
class TimePickerWidget(forms.TimeInput):
def __init__(self, attrs=None, time_format=None):
@@ -105,13 +83,17 @@ class TimePickerWidget(forms.TimeInput):
forms.TimeInput.__init__(self, time_attrs, time_format)
def value_from_datadict(self, data, files, name):
v = super().value_from_datadict(data, files, name)
return replace_arabic_numbers(v)
class UploadedFileWidget(forms.ClearableFileInput):
def __init__(self, *args, **kwargs):
# Browsers can't recognize that the server already has a file uploaded
# Don't mark this input as being required if we already have an answer
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
attrs = kwargs.get('attrs', {})
if kwargs.get('required') and kwargs.get('initial'):
attrs.update({'required': None})
kwargs.update({'attrs': attrs})
self.position = kwargs.pop('position')
self.event = kwargs.pop('event')
self.answer = kwargs.pop('answer')
@@ -143,15 +125,6 @@ class UploadedFileWidget(forms.ClearableFileInput):
'answer': self.answer.pk,
})
def get_context(self, name, value, attrs):
# Browsers can't recognize that the server already has a file uploaded
# Don't mark this input as being required if we already have an answer
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
ctx = super().get_context(name, value, attrs)
if ctx['widget']['is_initial']:
ctx['widget']['attrs']['required'] = False
return ctx
def format_value(self, value):
if self.is_initial(value):
return self.FakeFile(value, self.position, self.event, self.answer)
@@ -205,10 +178,6 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
# Skip one hierarchy level
forms.MultiWidget.__init__(self, widgets, attrs)
def value_from_datadict(self, data, files, name):
v = super().value_from_datadict(data, files, name)
return [replace_arabic_numbers(i) for i in v]
class BusinessBooleanRadio(forms.RadioSelect):
def __init__(self, require_business=False, attrs=None):

View File

@@ -510,7 +510,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
return story
def _get_story(self, doc):
has_taxes = any(il.tax_value for il in self.invoice.lines.all()) or self.invoice.reverse_charge
has_taxes = any(il.tax_value for il in self.invoice.lines.all())
story = [
NextPageTemplate('FirstPage'),

View File

@@ -23,9 +23,7 @@ from decimal import Decimal
from django.core.management.base import BaseCommand
from django.db import models
from django.db.models import (
Case, Count, F, OuterRef, Q, Subquery, Sum, Value, When,
)
from django.db.models import Case, F, OuterRef, Q, Subquery, Sum, Value, When
from django.db.models.functions import Coalesce
from django_scopes import scopes_disabled
@@ -47,18 +45,6 @@ class Command(BaseCommand):
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
),
position_cnt=Case(
When(Q(status__in=('e', 'c')) | Q(require_approval=True), then=Value(0)),
default=Coalesce(
Subquery(
OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(p=Count('*')).values('p'),
output_field=models.IntegerField()
), Value(0), output_field=models.IntegerField()
),
output_field=models.IntegerField()
),
fee_total=Coalesce(
Subquery(
OrderFee.objects.filter(
@@ -75,15 +61,6 @@ class Command(BaseCommand):
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
),
tx_cnt=Coalesce(
Subquery(
Transaction.objects.filter(
order=OuterRef('pk'),
item__isnull=False,
).order_by().values('order').annotate(p=Sum(F('count'))).values('p'),
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
),
).annotate(
correct_total=Case(
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
@@ -93,15 +70,13 @@ class Command(BaseCommand):
),
).exclude(
total=F('position_total') + F('fee_total'),
tx_total=F('correct_total'),
tx_cnt=F('position_cnt')
tx_total=F('correct_total')
).select_related('event')
for o in qs:
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001') \
and o.tx_cnt == o.position_cnt:
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001'):
# Ignore SQLite which treats Decimals like floats…
continue
print(f"Error in order {o.full_code}: status={o.status}, sum(positions)+sum(fees)={o.position_total + o.fee_total}, "
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}, pos_cnt={o.position_cnt}, tx_pos_cnt={o.tx_cnt}")
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}")
self.stderr.write(self.style.SUCCESS('Check completed.'))
self.stderr.write(self.style.SUCCESS(f'Check completed.'))

View File

@@ -19,13 +19,11 @@
# 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 sys
from django.apps import apps
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import connection
from django_scopes import scope, scopes_disabled
@@ -35,13 +33,6 @@ class Command(BaseCommand):
parser.parse_args = lambda x: parser.parse_known_args(x)[0]
return parser
def add_arguments(self, parser):
parser.add_argument(
'--print-sql',
action='store_true',
help='Print all SQL queries.',
)
def handle(self, *args, **options):
try:
from django_extensions.management.commands import shell_plus # noqa
@@ -50,11 +41,6 @@ class Command(BaseCommand):
cmd = 'shell'
del options['skip_checks']
if options['print_sql']:
connection.force_debug_cursor = True
logger = logging.getLogger("django.db.backends")
logger.setLevel(logging.DEBUG)
parser = self.create_parser(sys.argv[0], sys.argv[1])
flags = parser.parse_known_args(sys.argv[2:])[1]
if "--override" in flags:

View File

@@ -76,10 +76,6 @@ class LocaleMiddleware(MiddlewareMixin):
if lang.startswith(firstpart + '-'):
language = lang
break
if language not in settings_holder.settings.locales:
# This seems redundant, but can happen in the rare edge case that settings.locale is (wrongfully)
# not part of settings.locales
language = settings_holder.settings.locales[0]
if '-' not in language and settings_holder.settings.region:
language += '-' + settings_holder.settings.region
else:

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.9 on 2021-12-13 14:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0204_orderposition_backfill_is_bundled'),
]
operations = [
migrations.AddField(
model_name='itemvariation',
name='require_approval',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 3.2.9 on 2022-01-12 10:59
import phonenumber_field.modelfields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0205_itemvariation_require_approval'),
]
operations = [
migrations.AddField(
model_name='customer',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 3.2.4 on 2022-01-19 14:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0206_customer_phone'),
]
operations = [
migrations.AddField(
model_name='taxrule',
name='internal_name',
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name='taxrule',
name='keep_gross_if_rate_changes',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 3.2.4 on 2022-02-14 16:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0207_auto_20220119_1427'),
]
operations = [
migrations.AddField(
model_name='user',
name='auth_backend_identifier',
field=models.CharField(db_index=True, max_length=190, null=True),
),
migrations.AlterUniqueTogether(
name='user',
unique_together={('auth_backend', 'auth_backend_identifier')},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.12 on 2022-03-22 11:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0208_auto_20220214_1632'),
]
operations = [
migrations.AddField(
model_name='device',
name='info',
field=models.JSONField(null=True),
),
]

View File

@@ -1,89 +0,0 @@
# Generated by Django 3.2.2 on 2022-03-03 20:17
from decimal import Decimal
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0209_device_info'),
]
operations = [
migrations.AddField(
model_name='cartposition',
name='custom_price_input',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='custom_price_input_is_net',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='cartposition',
name='line_price_gross',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='listed_price',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='price_after_voucher',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='tax_rate',
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=7),
),
migrations.CreateModel(
name='Discount',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('active', models.BooleanField(default=True)),
('internal_name', models.CharField(max_length=255)),
('position', models.PositiveIntegerField(default=0)),
('sales_channels', pretix.base.models.fields.MultiStringField(default=['web'])),
('available_from', models.DateTimeField(blank=True, null=True)),
('available_until', models.DateTimeField(blank=True, null=True)),
('subevent_mode', models.CharField(max_length=50, default='mixed')),
('condition_all_products', models.BooleanField(default=True)),
('condition_min_count', models.PositiveIntegerField(default=0)),
('condition_min_value', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('benefit_discount_matching_percent', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('benefit_only_apply_to_cheapest_n_matches', models.PositiveIntegerField(null=True)),
('condition_limit_products', models.ManyToManyField(to='pretixbase.Item')),
('condition_apply_to_addons', models.BooleanField(default=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='discounts', to='pretixbase.event')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.AddField(
model_name='cartposition',
name='discount',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, to='pretixbase.discount'),
),
migrations.AddField(
model_name='orderposition',
name='discount',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, to='pretixbase.discount'),
),
migrations.AddField(
model_name='orderposition',
name='voucher_budget_use',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 3.2.2 on 2022-03-14 20:01
from decimal import Decimal
from django.db import migrations
from django.db.models import F
from django.db.models.functions import Greatest
def migrate_voucher_budget_use(apps, schema_editor):
OrderPosition = apps.get_model('pretixbase', 'OrderPosition') # noqa
OrderPosition.all.filter(
price_before_voucher__isnull=False
).exclude(price=F('price_before_voucher')).update(
voucher_budget_use=Greatest(F('price_before_voucher') - F('price'), Decimal('0.00'))
)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0210_auto_20220303_2017'),
]
operations = [
migrations.RunPython(
migrate_voucher_budget_use,
migrations.RunPython.noop,
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 3.2.12 on 2022-03-18 14:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0211_auto_20220314_2001'),
]
operations = [
migrations.RemoveField(
model_name='cartposition',
name='includes_tax',
),
migrations.RemoveField(
model_name='cartposition',
name='override_tax_rate',
),
migrations.RemoveField(
model_name='cartposition',
name='price_before_voucher',
),
migrations.RemoveField(
model_name='orderposition',
name='price_before_voucher',
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.2 on 2022-04-13 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0212_auto_20220318_1408'),
]
operations = [
migrations.AddField(
model_name='discount',
name='condition_ignore_voucher_discounted',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 3.2.12 on 2022-04-28 08:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0213_discount_condition_ignore_voucher_discounted'),
]
operations = [
migrations.AddField(
model_name='customer',
name='external_identifier',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='customer',
name='notes',
field=models.TextField(null=True),
),
]

View File

@@ -25,7 +25,6 @@ from .base import CachedFile, LoggedModel, cachedfile_name
from .checkin import Checkin, CheckinList
from .customers import Customer
from .devices import Device, Gate
from .discount import Discount
from .event import (
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
SubEvent, SubEventMetaValue, generate_invite_token,

View File

@@ -44,7 +44,7 @@ from django.contrib.auth.models import (
)
from django.contrib.auth.tokens import default_token_generator
from django.contrib.contenttypes.models import ContentType
from django.db import IntegrityError, models, transaction
from django.db import models
from django.db.models import Q
from django.utils.crypto import get_random_string, salted_hmac
from django.utils.timezone import now
@@ -61,10 +61,6 @@ from pretix.helpers.urls import build_absolute_uri
from .base import LoggingMixin
class EmailAddressTakenError(IntegrityError):
pass
class UserManager(BaseUserManager):
"""
This is the user manager for our custom user model. See the User
@@ -87,116 +83,6 @@ class UserManager(BaseUserManager):
user.save()
return user
def get_or_create_for_backend(self, backend, identifier, email, set_always, set_on_creation):
"""
This method should be used by third-party authentication backends to log in a user.
It either returns an already existing user or creates a new user.
In pretix 4.7 and earlier, email addresses were the only property to identify a user with.
Starting with pretix 4.8, backends SHOULD instead use a unique, immutable identifier
based on their backend data store to allow for changing email addresses.
This method transparently handles the conversion of old user accounts and adds the
backend identifier to their database record.
This method will never return users managed by a different authentication backend.
If you try to create an account with an email address already blocked by a different
authentication backend, :py:class:`EmailAddressTakenError` will be raised. In this case,
you should display a message to the user.
:param backend: The `identifier` attribute of the authentication backend
:param identifier: The unique, immutable identifier of this user, max. 190 characters
:param email: The user's email address
:param set_always: A dictionary of fields to update on the user model on every login
:param set_on_creation: A dictionary of fields to set on the user model if it's newly created
:return: A `User` instance.
"""
if identifier is None:
raise ValueError('You need to supply a custom, unique identifier for this user.')
if email is None:
raise ValueError('You need to supply an email address for this user.')
if 'auth_backend_identifier' in set_always or 'auth_backend_identifier' in set_on_creation or \
'auth_backend' in set_always or 'auth_backend' in set_on_creation:
raise ValueError('You may not update auth_backend/auth_backend_identifier.')
if len(identifier) > 190:
raise ValueError('The user identifier must not be more than 190 characters.')
# Always update the email address
set_always.update({'email': email})
# First, check if we find the user based on it's backend-specific authenticator
try:
u = self.get(
auth_backend=backend,
auth_backend_identifier=identifier,
)
dirty = False
for k, v in set_always.items():
if getattr(u, k) != v:
setattr(u, k, v)
dirty = True
if dirty:
try:
with transaction.atomic():
u.save(update_fields=set_always.keys())
except IntegrityError:
# This might only raise IntegrityError if the email address is used
# by someone else
raise EmailAddressTakenError()
return u
except self.model.DoesNotExist:
pass
# Second, check if we find the user based on their email address and this backend
try:
u = self.get(
auth_backend=backend,
auth_backend_identifier__isnull=True,
email=email,
)
u.auth_backend_identifier = identifier
for k, v in set_always.items():
setattr(u, k, v)
try:
with transaction.atomic():
u.save(update_fields=['auth_backend_identifier'] + list(set_always.keys()))
return u
except IntegrityError:
# This might only raise IntegrityError if this code is being executed twice
# and runs into a race condition, this mechanism is taken from Django's
# get_or_create
try:
return self.get(
auth_backend=backend,
auth_backend_identifier=identifier,
)
except self.model.DoesNotExist:
pass
raise
except self.model.DoesNotExist:
pass
# Third, create a new user
u = User(
auth_backend=backend,
auth_backend_identifier=identifier,
**set_on_creation,
**set_always,
)
try:
u.save(force_insert=True)
return u
except IntegrityError:
# This might either be a race condition or the email address is taken
# by a different backend
try:
return self.get(
auth_backend=backend,
auth_backend_identifier=identifier,
)
except self.model.DoesNotExist:
raise EmailAddressTakenError()
def generate_notifications_token():
return get_random_string(length=32)
@@ -231,10 +117,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:type needs_password_change: bool
:param timezone: The user's preferred timezone.
:type timezone: str
:param auth_backend: The identifier of the authentication backend plugin responsible for managing this user.
:type auth_backend: str
:param auth_backend_identifier: The native identifier of the user provided by a non-native authentication backend.
:type auth_backend_identifier: str
"""
USERNAME_FIELD = 'email'
@@ -270,7 +152,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
)
notifications_token = models.CharField(max_length=255, default=generate_notifications_token)
auth_backend = models.CharField(max_length=255, default='native')
auth_backend_identifier = models.CharField(max_length=190, db_index=True, null=True, blank=True)
session_token = models.CharField(max_length=32, default=generate_session_token)
objects = UserManager()
@@ -283,7 +164,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
verbose_name = _("User")
verbose_name_plural = _("Users")
ordering = ('email',)
unique_together = (('auth_backend', 'auth_backend_identifier'),)
def save(self, *args, **kwargs):
self.email = self.email.lower()
@@ -498,23 +378,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
| Q(id__in=self.teams.filter(**kwargs).values_list('limit_events__id', flat=True))
)
@scopes_disabled()
def get_organizers_with_any_permission(self, request=None):
"""
Returns a queryset of organizers the user has any permissions to.
:param request: The current request (optional). Required to detect staff sessions properly.
:return: Iterable of Organizers
"""
from .event import Organizer
if request and self.has_active_staff_session(request.session.session_key):
return Organizer.objects.all()
return Organizer.objects.filter(
id__in=self.teams.values_list('organizer', flat=True)
)
@scopes_disabled()
def get_organizers_with_permission(self, permission, request=None):
"""

View File

@@ -188,7 +188,6 @@ class CheckinList(LoggedModel):
# * in pretix.helpers.jsonlogic_boolalg
# * in checkinrules.js
# * in libpretixsync
# * in pretixscan-ios (in the future)
top_level_operators = {
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
}
@@ -196,8 +195,7 @@ class CheckinList(LoggedModel):
'buildTime', 'objectList', 'lookup', 'var',
}
allowed_vars = {
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
'minutes_since_last_entry', 'minutes_since_first_entry',
'product', 'variation', 'now', 'entries_number', 'entries_today', 'entries_days'
}
if not rules or not isinstance(rules, dict):
return rules
@@ -223,7 +221,7 @@ class CheckinList(LoggedModel):
return rules
if operator in ('or', 'and') and seen_nonbool:
raise ValidationError('You cannot use OR/AND logic on a level below a comparison operator.')
raise ValidationError(f'You cannot use OR/AND logic on a level below a comparison operator.')
for v in values:
cls.validate_rules(v, seen_nonbool=seen_nonbool or operator not in ('or', 'and'), depth=depth + 1)

View File

@@ -29,7 +29,6 @@ from django.db.models import F, Q
from django.utils.crypto import get_random_string, salted_hmac
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled
from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned
from pretix.base.models.base import LoggedModel
@@ -46,7 +45,6 @@ class Customer(LoggedModel):
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
identifier = models.CharField(max_length=190, db_index=True, unique=True)
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
password = models.CharField(verbose_name=_('Password'), max_length=128)
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
name_parts = models.JSONField(default=dict)
@@ -59,8 +57,6 @@ class Customer(LoggedModel):
default=settings.LANGUAGE_CODE,
verbose_name=_('Language'))
last_modified = models.DateTimeField(auto_now=True)
external_identifier = models.CharField(max_length=255, verbose_name=_('External identifier'), null=True, blank=True)
notes = models.TextField(verbose_name=_('Notes'), null=True, blank=True)
objects = ScopedManager(organizer='organizer')
@@ -91,9 +87,6 @@ class Customer(LoggedModel):
self.name_parts = {}
self.name_cached = ''
self.email = None
self.phone = None
self.external_identifier = None
self.notes = None
self.save()
self.all_logentries().update(data={}, shredded=True)
self.orders.all().update(customer=None)
@@ -176,7 +169,6 @@ class Customer(LoggedModel):
return salted_hmac(key_salt, payload).hexdigest()
def get_email_context(self):
from pretix.base.email import get_name_parts_localized
ctx = {
'name': self.name,
'organizer': self.organizer.name,
@@ -185,13 +177,7 @@ class Customer(LoggedModel):
for f, l, w in name_scheme['fields']:
if f == 'full_name':
continue
ctx['name_%s' % f] = get_name_parts_localized(self.name_parts, f)
if "concatenation_for_salutation" in name_scheme:
ctx['name_for_salutation'] = name_scheme["concatenation_for_salutation"](self.name_parts)
else:
ctx['name_for_salutation'] = name_scheme["concatenation"](self.name_parts)
ctx['name_%s' % f] = self.name_parts.get(f, '')
return ctx
@property

View File

@@ -156,9 +156,6 @@ class Device(LoggedModel):
null=True,
blank=False
)
info = models.JSONField(
null=True, blank=True,
)
objects = ScopedManager(organizer='organizer')
@@ -179,7 +176,6 @@ class Device(LoggedModel):
return {
'can_view_orders',
'can_change_orders',
'can_view_vouchers',
'can_manage_gift_cards'
}

View File

@@ -1,368 +0,0 @@
#
# 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 <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from collections import defaultdict
from decimal import Decimal
from itertools import groupby
from typing import Dict, Optional, Tuple
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models import fields
from pretix.base.models.base import LoggedModel
class Discount(LoggedModel):
SUBEVENT_MODE_MIXED = 'mixed'
SUBEVENT_MODE_SAME = 'same'
SUBEVENT_MODE_DISTINCT = 'distinct'
SUBEVENT_MODE_CHOICES = (
(SUBEVENT_MODE_MIXED, pgettext_lazy('subevent', 'Dates can be mixed without limitation')),
(SUBEVENT_MODE_SAME, pgettext_lazy('subevent', 'All matching products must be for the same date')),
(SUBEVENT_MODE_DISTINCT, pgettext_lazy('subevent', 'Each matching product must be for a different date')),
)
event = models.ForeignKey(
'Event',
on_delete=models.CASCADE,
related_name='discounts',
)
active = models.BooleanField(
verbose_name=_("Active"),
default=True,
)
internal_name = models.CharField(
verbose_name=_("Internal name"),
max_length=255
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
sales_channels = fields.MultiStringField(
verbose_name=_('Sales channels'),
default=['web'],
blank=False,
)
available_from = models.DateTimeField(
verbose_name=_("Available from"),
null=True,
blank=True,
)
available_until = models.DateTimeField(
verbose_name=_("Available until"),
null=True,
blank=True,
)
subevent_mode = models.CharField(
verbose_name=_('Event series handling'),
max_length=50,
default=SUBEVENT_MODE_MIXED,
choices=SUBEVENT_MODE_CHOICES,
)
condition_all_products = models.BooleanField(
default=True,
verbose_name=_("Apply to all products (including newly created ones)")
)
condition_limit_products = models.ManyToManyField(
'Item',
verbose_name=_("Apply to specific products"),
blank=True
)
condition_apply_to_addons = models.BooleanField(
default=True,
verbose_name=_("Apply to add-on products"),
help_text=_("Discounts never apply to bundled products"),
)
condition_ignore_voucher_discounted = models.BooleanField(
default=False,
verbose_name=_("Ignore products discounted by a voucher"),
help_text=_("If this option is checked, products that already received a discount through a voucher will not "
"be considered for this discount. However, products that use a voucher only to e.g. unlock a "
"hidden product or gain access to sold-out quota will still receive the discount."),
)
condition_min_count = models.PositiveIntegerField(
verbose_name=_('Minimum number of matching products'),
default=0,
)
condition_min_value = models.DecimalField(
verbose_name=_('Minimum gross value of matching products'),
decimal_places=2,
max_digits=10,
default=Decimal('0.00'),
)
benefit_discount_matching_percent = models.DecimalField(
verbose_name=_('Percentual discount on matching products'),
decimal_places=2,
max_digits=10,
default=Decimal('0.00'),
validators=[MinValueValidator(Decimal('0.00'))],
)
benefit_only_apply_to_cheapest_n_matches = models.PositiveIntegerField(
verbose_name=_('Apply discount only to this number of matching products'),
help_text=_(
'This option allows you to create discounts of the type "buy X get Y reduced/for free". For example, if '
'you set "Minimum number of matching products" to four and this value to two, the customer\'s cart will be '
'split into groups of four tickets and the cheapest two tickets within every group will be discounted. If '
'you want to grant the discount on all matching products, keep this field empty.'
),
null=True,
blank=True,
validators=[MinValueValidator(1)],
)
# more feature ideas:
# - max_usages_per_order
# - promote_to_user_if_almost_satisfied
# - require_customer_account
objects = ScopedManager(organizer='event__organizer')
class Meta:
ordering = ('position', 'id')
def __str__(self):
return self.internal_name
@property
def sortkey(self):
return self.position, self.id
def __lt__(self, other) -> bool:
return self.sortkey < other.sortkey
@classmethod
def validate_config(cls, data):
# We forbid a few combinations of settings, because we don't think they are neccessary and at the same
# time they introduce edge cases, in which it becomes almost impossible to compute the discount optimally
# and also very hard to understand for the user what is going on.
if data.get('condition_min_count') and data.get('condition_min_value'):
raise ValidationError(
_('You can either set a minimum number of matching products or a minimum value, not both.')
)
if not data.get('condition_min_count') and not data.get('condition_min_value'):
raise ValidationError(
_('You need to either set a minimum number of matching products or a minimum value.')
)
if data.get('condition_min_value') and data.get('benefit_only_apply_to_cheapest_n_matches'):
raise ValidationError(
_('You cannot apply the discount only to some of the matched products if you are matching '
'on a minimum value.')
)
if data.get('subevent_mode') == cls.SUBEVENT_MODE_DISTINCT and data.get('condition_min_value'):
raise ValidationError(
_('You cannot apply the discount only to bookings of different dates if you are matching '
'on a minimum value.')
)
def allow_delete(self):
return not self.orderposition_set.exists()
def clean(self):
super().clean()
Discount.validate_config({
'condition_min_count': self.condition_min_count,
'condition_min_value': self.condition_min_value,
'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches,
'subevent_mode': self.subevent_mode,
})
def _apply_min_value(self, positions, idx_group, result):
if self.condition_min_value and sum(positions[idx][2] for idx in idx_group) < self.condition_min_value:
return
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
raise ValueError('Validation invariant violated.')
for idx in idx_group:
previous_price = positions[idx][2]
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
)
result[idx] = new_price
def _apply_min_count(self, positions, idx_group, result):
if len(idx_group) < self.condition_min_count:
return
if not self.condition_min_count or self.condition_min_value:
raise ValueError('Validation invariant violated.')
if self.benefit_only_apply_to_cheapest_n_matches:
if not self.condition_min_count:
raise ValueError('Validation invariant violated.')
idx_group = sorted(idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3
consume_idx = idx_group[:len(idx_group) // self.condition_min_count * self.condition_min_count]
benefit_idx = idx_group[:len(idx_group) // self.condition_min_count * self.benefit_only_apply_to_cheapest_n_matches]
else:
consume_idx = idx_group
benefit_idx = idx_group
for idx in benefit_idx:
previous_price = positions[idx][2]
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
)
result[idx] = new_price
for idx in consume_idx:
result.setdefault(idx, positions[idx][2])
def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]]) -> Dict[int, Decimal]:
"""
Tries to apply this discount to a cart
:param positions: Dictionary mapping IDs to tuples of the form
``(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)``.
Bundled positions may not be included.
:return: A dictionary mapping keys from the input dictionary to new prices. All positions
contained in this dictionary are considered "consumed" and should not be considered
by other discounts.
"""
result = {}
if not self.active:
return result
limit_products = set()
if not self.condition_all_products:
limit_products = {p.pk for p in self.condition_limit_products.all()}
# First, filter out everything not even covered by our product scope
initial_candidates = [
idx
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if (
(self.condition_all_products or item_id in limit_products) and
(self.condition_apply_to_addons or not is_addon_to) and
(not self.condition_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
)
]
if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events
if self.condition_min_count:
self._apply_min_count(positions, initial_candidates, result)
else:
self._apply_min_value(positions, initial_candidates, result)
elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx):
return positions[idx][1] # subevent_id
# Build groups of candidates with the same subevent, then apply our regular algorithm
# to each group
_groups = groupby(sorted(initial_candidates, key=key), key=key)
candidate_groups = [list(g) for k, g in _groups]
for g in candidate_groups:
if self.condition_min_count:
self._apply_min_count(positions, g, result)
else:
self._apply_min_value(positions, g, result)
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
if self.condition_min_value:
raise ValueError('Validation invariant violated.')
# Build optimal groups of candidates with distinct subevents, then apply our regular algorithm
# to each group. Optimal, in this case, means:
# - First try to build as many groups of size condition_min_count as possible while trying to
# balance out the cheapest products so that they are not all in the same group
# - Then add remaining positions to existing groups if possible
candidate_groups = []
# Build a list of subevent IDs in descending order of frequency
subevent_to_idx = defaultdict(list)
for idx, p in positions.items():
subevent_to_idx[p[1]].append(idx)
for v in subevent_to_idx.values():
v.sort(key=lambda idx: positions[idx][2])
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
# Build groups of exactly condition_min_count distinct subevents
current_group = []
while True:
# Build a list of candidates, which is a list of all positions belonging to a subevent of the
# maximum cardinality, where the cardinality of a subevent is defined as the number of tickets
# for that subevent that are not yet part of any group
candidates = []
cardinality = None
for se, l in subevent_to_idx.items():
l = [ll for ll in l if ll not in current_group]
if cardinality and len(l) != cardinality:
continue
if se not in {positions[idx][1] for idx in current_group}:
candidates += l
cardinality = len(l)
if not candidates:
break
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
# and 2 from the end" scheme to optimize price distribution among groups
candidates = sorted(candidates, key=lambda idx: positions[idx][2])
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0]
else:
candidate = candidates[-1]
current_group.append(candidate)
# Only add full groups to the list of groups
if len(current_group) >= max(self.condition_min_count, 1):
candidate_groups.append(current_group)
for c in current_group:
subevent_to_idx[positions[c][1]].remove(c)
current_group = []
# Distribute "leftovers"
for se in subevent_order:
if subevent_to_idx[se]:
for group in candidate_groups:
if se not in {positions[idx][1] for idx in group}:
group.append(subevent_to_idx[se].pop())
if not subevent_to_idx[se]:
break
for g in candidate_groups:
self._apply_min_count(positions, g, result)
return result

View File

@@ -665,22 +665,21 @@ class Event(EventMixin, LoggedModel):
return locking.LockManager(self)
def get_mail_backend(self, timeout=None):
def get_mail_backend(self, timeout=None, force_custom=False):
"""
Returns an email server connection, either by using the system-wide connection
or by returning a custom one based on the event's settings.
"""
from pretix.base.email import CustomSMTPBackend
if self.settings.smtp_use_custom:
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
host=self.settings.smtp_host,
port=self.settings.smtp_port,
username=self.settings.smtp_username,
password=self.settings.smtp_password,
use_tls=self.settings.smtp_use_tls,
use_ssl=self.settings.smtp_use_ssl,
fail_silently=False,
timeout=timeout)
if self.settings.smtp_use_custom or force_custom:
return CustomSMTPBackend(host=self.settings.smtp_host,
port=self.settings.smtp_port,
username=self.settings.smtp_username,
password=self.settings.smtp_password,
use_tls=self.settings.smtp_use_tls,
use_ssl=self.settings.smtp_use_ssl,
fail_silently=False, timeout=timeout)
else:
return get_connection(fail_silently=False)
@@ -700,8 +699,8 @@ class Event(EventMixin, LoggedModel):
from ..signals import event_copy_data
from . import (
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
Question, Quota,
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, Question,
Quota,
)
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
@@ -723,7 +722,7 @@ class Event(EventMixin, LoggedModel):
tax_map[t.pk] = t
t.pk = None
t.event = self
t.save(force_insert=True)
t.save()
t.log_action('pretix.object.cloned')
category_map = {}
@@ -731,7 +730,7 @@ class Event(EventMixin, LoggedModel):
category_map[c.pk] = c
c.pk = None
c.event = self
c.save(force_insert=True)
c.save()
c.log_action('pretix.object.cloned')
item_meta_properties_map = {}
@@ -739,7 +738,7 @@ class Event(EventMixin, LoggedModel):
item_meta_properties_map[imp.pk] = imp
imp.pk = None
imp.event = self
imp.save(force_insert=True)
imp.save()
imp.log_action('pretix.object.cloned')
item_map = {}
@@ -760,7 +759,7 @@ class Event(EventMixin, LoggedModel):
if i.grant_membership_type and other.organizer_id != self.organizer_id:
i.grant_membership_type = None
i.save() # no force_insert since i.picture.save could have already inserted
i.save()
i.log_action('pretix.object.cloned')
if require_membership_types and other.organizer_id == self.organizer_id:
@@ -770,19 +769,19 @@ class Event(EventMixin, LoggedModel):
variation_map[v.pk] = v
v.pk = None
v.item = i
v.save(force_insert=True)
v.save()
for imv in ItemMetaValue.objects.filter(item__event=other).prefetch_related('item', 'property'):
imv.pk = None
imv.property = item_meta_properties_map[imv.property.pk]
imv.item = item_map[imv.item.pk]
imv.save(force_insert=True)
imv.save()
for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'):
ia.pk = None
ia.base_item = item_map[ia.base_item.pk]
ia.addon_category = category_map[ia.addon_category.pk]
ia.save(force_insert=True)
ia.save()
for ia in ItemBundle.objects.filter(base_item__event=other).prefetch_related('base_item', 'bundled_item', 'bundled_variation'):
ia.pk = None
@@ -790,7 +789,7 @@ class Event(EventMixin, LoggedModel):
ia.bundled_item = item_map[ia.bundled_item.pk]
if ia.bundled_variation:
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
ia.save(force_insert=True)
ia.save()
quota_map = {}
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
@@ -801,7 +800,7 @@ class Event(EventMixin, LoggedModel):
q.pk = None
q.event = self
q.closed = False
q.save(force_insert=True)
q.save()
q.log_action('pretix.object.cloned')
for i in items:
if i.pk in item_map:
@@ -810,16 +809,6 @@ class Event(EventMixin, LoggedModel):
q.variations.add(variation_map[v.pk])
self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q)
for d in Discount.objects.filter(event=other).prefetch_related('condition_limit_products'):
items = list(d.condition_limit_products.all())
d.pk = None
d.event = self
d.save(force_insert=True)
d.log_action('pretix.object.cloned')
for i in items:
if i.pk in item_map:
d.condition_limit_products.add(item_map[i.pk])
question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
items = list(q.items.all())
@@ -827,7 +816,7 @@ class Event(EventMixin, LoggedModel):
question_map[q.pk] = q
q.pk = None
q.event = self
q.save(force_insert=True)
q.save()
q.log_action('pretix.object.cloned')
for i in items:
@@ -835,7 +824,7 @@ class Event(EventMixin, LoggedModel):
for o in opts:
o.pk = None
o.question = q
o.save(force_insert=True)
o.save()
for q in self.questions.filter(dependency_question__isnull=False):
q.dependency_question = question_map[q.dependency_question_id]
@@ -845,10 +834,10 @@ class Event(EventMixin, LoggedModel):
if isinstance(rules, dict):
for k, v in rules.items():
if k == 'lookup':
if rules[k][0] == 'product':
rules[k][1] = str(item_map.get(int(v[1]), 0).pk) if int(v[1]) in item_map else "0"
elif rules[k][0] == 'variation':
rules[k][1] = str(variation_map.get(int(v[1]), 0).pk) if int(v[1]) in variation_map else "0"
if v[0] == 'product':
v[1] = str(item_map.get(int(v[1]), 0).pk) if int(v[1]) in item_map else "0"
elif v[0] == 'variation':
v[1] = str(variation_map.get(int(v[1]), 0).pk) if int(v[1]) in variation_map else "0"
else:
_walk_rules(v)
elif isinstance(rules, list):
@@ -864,7 +853,7 @@ class Event(EventMixin, LoggedModel):
rules = cl.rules
_walk_rules(rules)
cl.rules = rules
cl.save(force_insert=True)
cl.save()
cl.log_action('pretix.object.cloned')
for i in items:
cl.limit_products.add(item_map[i.pk])
@@ -873,25 +862,21 @@ class Event(EventMixin, LoggedModel):
if other.seating_plan.organizer_id == self.organizer_id:
self.seating_plan = other.seating_plan
else:
sp = other.seating_plan
sp.pk = None
sp.organizer = self.organizer
sp.save(force_insert=True)
self.seating_plan = sp
self.organizer.seating_plans.create(name=other.seating_plan.name, layout=other.seating_plan.layout)
self.save()
for m in other.seat_category_mappings.filter(subevent__isnull=True):
m.pk = None
m.event = self
m.product = item_map[m.product_id]
m.save(force_insert=True)
m.save()
for s in other.seats.filter(subevent__isnull=True):
s.pk = None
s.event = self
if s.product_id:
s.product = item_map[s.product_id]
s.save(force_insert=True)
s.save()
has_custom_style = other.settings.presale_css_file or other.settings.presale_widget_css_file
skip_settings = (
@@ -1193,21 +1178,21 @@ class Event(EventMixin, LoggedModel):
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
def set_active_plugins(self, modules, allow_restricted=frozenset()):
def set_active_plugins(self, modules, allow_restricted=False):
plugins_active = self.get_plugins()
plugins_available = self.get_available_plugins()
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
for module in enable:
if getattr(plugins_available[module].app, 'restricted', False) and module not in allow_restricted:
if getattr(plugins_available[module].app, 'restricted', False) and not allow_restricted:
modules.remove(module)
elif hasattr(plugins_available[module].app, 'installed'):
getattr(plugins_available[module].app, 'installed')(self)
self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=frozenset()):
def enable_plugin(self, module, allow_restricted=False):
plugins_active = self.get_plugins()
from pretix.presale.style import regenerate_css

View File

@@ -52,10 +52,7 @@ class MultiStringField(TextField):
if isinstance(value, (list, tuple)):
return DELIMITER + DELIMITER.join(value) + DELIMITER
elif value is None:
if self.null:
return None
else:
return ""
return ""
raise TypeError("Invalid data type passed.")
def get_prep_lookup(self, lookup_type, value): # NOQA
@@ -81,8 +78,6 @@ class MultiStringField(TextField):
return MultiStringContains
elif lookup_name == 'icontains':
return MultiStringIContains
elif lookup_name == 'isnull':
return builtin_lookups.IsNull
raise NotImplementedError(
"Lookup '{}' doesn't work with MultiStringField".format(lookup_name),
)

View File

@@ -44,7 +44,7 @@ import dateutil.parser
import pytz
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, RegexValidator
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import Q
from django.utils import formats
@@ -479,14 +479,12 @@ class Item(LoggedModel):
min_per_order = models.IntegerField(
verbose_name=_('Minimum amount per order'),
null=True, blank=True,
validators=[MinValueValidator(0)],
help_text=_('This product can only be bought if it is added to the cart at least this many times. If you keep '
'the field empty or set it to 0, there is no special limit for this product.')
)
max_per_order = models.IntegerField(
verbose_name=_('Maximum amount per order'),
null=True, blank=True,
validators=[MinValueValidator(0)],
help_text=_('This product can only be bought at most this many times within one order. If you keep the field '
'empty or set it to 0, there is no special limit for this product. The limit for the maximum '
'number of items in the whole order applies regardless.')
@@ -766,9 +764,6 @@ class ItemVariation(models.Model):
:type default_price: decimal.Decimal
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
:type original_price: decimal.Decimal
:param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after
approval by an administrator
:type require_approval: bool
"""
item = models.ForeignKey(
Item,
@@ -804,13 +799,6 @@ class ItemVariation(models.Model):
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
)
require_approval = models.BooleanField(
verbose_name=_('Require approval'),
default=False,
help_text=_('If this variation is part of an order, the order will be put into an "approval" state and '
'will need to be confirmed by you before it can be paid and completed. You can use this e.g. for '
'discounted tickets that are only available to specific groups.'),
)
require_membership = models.BooleanField(
verbose_name=_('Require a valid membership'),
default=False,
@@ -844,7 +832,7 @@ class ItemVariation(models.Model):
blank=True,
)
hide_without_voucher = models.BooleanField(
verbose_name=_('Show only if a matching voucher is redeemed.'),
verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'),
default=False,
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
'that unlocks this variation.')
@@ -1699,7 +1687,7 @@ class Quota(LoggedModel):
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))
if item.has_variations:
if not variations or not any(var.item == item for var in variations):
if not any(var.item == item for var in variations):
raise ValidationError(_('One or more items has variations but none of these are in the variations list.'))
@staticmethod

View File

@@ -138,8 +138,8 @@ class LogEntry(models.Model):
@cached_property
def display_object(self):
from . import (
Discount, Event, Item, ItemCategory, Order, Question, Quota,
SubEvent, TaxRule, Voucher,
Event, Item, ItemCategory, Order, Question, Quota, SubEvent,
TaxRule, Voucher,
)
try:
@@ -202,16 +202,6 @@ class LogEntry(models.Model):
}),
'val': escape(co.name),
}
elif isinstance(co, Discount):
a_text = _('Discount {val}')
a_map = {
'href': reverse('control:event.items.discounts.edit', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'discount': co.id
}),
'val': escape(co.internal_name),
}
elif isinstance(co, ItemCategory):
a_text = _('Category {val}')
a_map = {

View File

@@ -638,13 +638,12 @@ class Order(LockModel, LoggedModel):
return False
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
return False
if self.status == Order.STATUS_PAID or self.payment_refund_sum > Decimal('0.00'):
if self.status == Order.STATUS_PENDING:
return self.event.settings.cancel_allow_user
elif self.status == Order.STATUS_PAID:
if self.total == Decimal('0.00'):
return self.event.settings.cancel_allow_user
return self.event.settings.cancel_allow_user_paid
elif self.status == Order.STATUS_PENDING:
return self.event.settings.cancel_allow_user
return False
def propose_auto_refunds(self, amount: Decimal, payments: list=None):
@@ -906,10 +905,10 @@ class Order(LockModel, LoggedModel):
if force:
continue
if op.voucher and op.voucher.budget is not None and op.voucher_budget_use:
if op.voucher and op.voucher.budget is not None and op.price_before_voucher is not None:
if op.voucher not in v_budget:
v_budget[op.voucher] = op.voucher.budget - op.voucher.budget_used()
disc = op.voucher_budget_use
disc = op.price_before_voucher - op.price
if disc > v_budget[op.voucher]:
raise Quota.QuotaExceededException(error_messages['voucher_budget'].format(
voucher=op.voucher.code
@@ -951,7 +950,7 @@ class Order(LockModel, LoggedModel):
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
attach_ical=False, attach_other_files: list=None):
attach_ical=False):
"""
Sends an email to the user that placed this order. Basically, this method does two things:
@@ -977,7 +976,7 @@ class Order(LockModel, LoggedModel):
SendMailException, TolerantDict, mail, render_mail,
)
if not self.email and not (position and position.attendee_email):
if not self.email:
return
for k, v in self.event.meta_data.items():
@@ -995,8 +994,7 @@ class Order(LockModel, LoggedModel):
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets,
position=position, auto_email=auto_email, attach_ical=attach_ical,
attach_other_files=attach_other_files,
position=position, auto_email=auto_email, attach_ical=attach_ical
)
except SendMailException:
raise
@@ -1275,6 +1273,9 @@ class AbstractPosition(models.Model):
verbose_name=_("Variation"),
on_delete=models.PROTECT
)
price_before_voucher = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
price = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Price")
@@ -1311,10 +1312,6 @@ class AbstractPosition(models.Model):
)
is_bundled = models.BooleanField(default=False)
discount = models.ForeignKey(
'Discount', null=True, blank=True, on_delete=models.RESTRICT
)
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
@@ -1332,10 +1329,6 @@ class AbstractPosition(models.Model):
else:
return {}
@property
def item_and_variation(self):
return self.item, self.variation
@meta_info_data.setter
def meta_info_data(self, d):
self.meta_info = json.dumps(d)
@@ -1448,15 +1441,6 @@ class AbstractPosition(models.Model):
lines = [r.strip() for r in lines if r]
return '\n'.join(lines).strip()
def requires_approval(self, invoice_address=None):
if self.item.require_approval:
return True
if self.variation and self.variation.require_approval:
return True
if self.item.tax_rule and self.item.tax_rule._require_approval(invoice_address):
return True
return False
class OrderPayment(models.Model):
"""
@@ -1730,10 +1714,10 @@ class OrderPayment(models.Model):
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
try:
position.send_mail(
self.order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[],
invoices=[], position=position,
attach_tickets=True,
attach_ical=self.order.event.settings.mail_attach_ical
)
@@ -2161,9 +2145,6 @@ class OrderPosition(AbstractPosition):
related_name='all_positions',
on_delete=models.PROTECT
)
voucher_budget_use = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True,
)
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
@@ -2236,8 +2217,6 @@ class OrderPosition(AbstractPosition):
else:
setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax()
if cartpos.voucher:
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher
op.positionid = i + 1
op.save()
ops.append(op)
@@ -2337,7 +2316,7 @@ class OrderPosition(AbstractPosition):
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, attach_ical=False, attach_other_files: list=None):
auth=None, attach_tickets=False, attach_ical=False):
"""
Sends an email to the attendee. Basically, this method does two things:
@@ -2378,7 +2357,6 @@ class OrderPosition(AbstractPosition):
invoices=invoices,
attach_tickets=attach_tickets,
attach_ical=attach_ical,
attach_other_files=attach_other_files,
)
except SendMailException:
raise
@@ -2586,25 +2564,12 @@ class CartPosition(AbstractPosition):
verbose_name=_("Expiration date"),
db_index=True
)
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2, default=Decimal('0.00'),
verbose_name=_('Tax rate')
includes_tax = models.BooleanField(
default=True
)
listed_price = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
price_after_voucher = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
custom_price_input = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
custom_price_input_is_net = models.BooleanField(
default=False,
)
line_price_gross = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
override_tax_rate = models.DecimalField(
max_digits=10, decimal_places=2,
null=True, blank=True
)
objects = ScopedManager(organizer='event__organizer')
@@ -2618,66 +2583,21 @@ class CartPosition(AbstractPosition):
self.item.id, self.variation.id if self.variation else 0, self.cart_id
)
@property
def tax_rate(self):
if self.includes_tax:
if self.override_tax_rate is not None:
return self.override_tax_rate
return self.item.tax(self.price, base_price_is='gross').rate
else:
return Decimal('0.00')
@property
def tax_value(self):
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
self.event.currency)
return self.price - net
def update_listed_price_and_voucher(self, voucher_only=False, max_discount=None):
from pretix.base.services.pricing import (
get_listed_price, is_included_for_free,
)
if voucher_only:
listed_price = self.listed_price
if self.includes_tax:
return self.item.tax(self.price, override_tax_rate=self.override_tax_rate, base_price_is='gross').tax
else:
if self.addon_to_id and is_included_for_free(self.item, self.addon_to):
listed_price = Decimal('0.00')
else:
listed_price = get_listed_price(self.item, self.variation, self.subevent)
if self.voucher:
price_after_voucher = self.voucher.calculate_price(listed_price, max_discount)
else:
price_after_voucher = listed_price
if self.is_bundled:
bundle = self.addon_to.item.bundles.filter(bundled_item=self.item, bundled_variation=self.variation).first()
if bundle:
listed_price = bundle.designated_price
price_after_voucher = bundle.designated_price
if listed_price != self.listed_price or price_after_voucher != self.price_after_voucher:
self.listed_price = listed_price
self.price_after_voucher = price_after_voucher
self.save(update_fields=['listed_price', 'price_after_voucher'])
def migrate_free_price_if_necessary(self):
# Migrate from pre-discounts position
if self.item.free_price and self.custom_price_input is None:
custom_price = self.price
if custom_price > 100000000:
raise ValueError('price_too_high')
self.custom_price_input = custom_price
self.custom_price_input_is_net = not False
self.save(update_fields=['custom_price_input', 'custom_price_input_is_net'])
def update_line_price(self, invoice_address, bundled_positions):
from pretix.base.services.pricing import get_line_price
line_price = get_line_price(
price_after_voucher=self.price_after_voucher,
custom_price_input=self.custom_price_input,
custom_price_input_is_net=self.custom_price_input_is_net,
tax_rule=self.item.tax_rule,
invoice_address=invoice_address,
bundled_sum=sum([b.price_after_voucher for b in bundled_positions]),
)
if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate:
self.line_price_gross = line_price.gross
self.tax_rate = line_price.rate
self.save(update_fields=['line_price_gross', 'tax_rate'])
return Decimal('0.00')
class InvoiceAddress(models.Model):

View File

@@ -36,7 +36,6 @@ import string
from datetime import date, datetime, time
import pytz
from django.conf import settings
from django.core.mail import get_connection
from django.core.validators import MinLengthValidator, RegexValidator
from django.db import models
@@ -98,21 +97,10 @@ class Organizer(LoggedModel):
return self.name
def save(self, *args, **kwargs):
is_new = not self.pk
obj = super().save(*args, **kwargs)
if is_new:
self.set_defaults()
else:
self.get_cache().clear()
self.get_cache().clear()
return obj
def set_defaults(self):
"""
This will be called after organizer creation.
This way, we can use this to introduce new default settings to pretix that do not affect existing organizers.
"""
self.settings.cookie_consent = True
def get_cache(self):
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
@@ -191,20 +179,21 @@ class Organizer(LoggedModel):
e.delete()
self.teams.all().delete()
def get_mail_backend(self, timeout=None):
def get_mail_backend(self, timeout=None, force_custom=False):
"""
Returns an email server connection, either by using the system-wide connection
or by returning a custom one based on the organizer's settings.
"""
if self.settings.smtp_use_custom:
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
host=self.settings.smtp_host,
port=self.settings.smtp_port,
username=self.settings.smtp_username,
password=self.settings.smtp_password,
use_tls=self.settings.smtp_use_tls,
use_ssl=self.settings.smtp_use_ssl,
fail_silently=False, timeout=timeout)
from pretix.base.email import CustomSMTPBackend
if self.settings.smtp_use_custom or force_custom:
return CustomSMTPBackend(host=self.settings.smtp_host,
port=self.settings.smtp_port,
username=self.settings.smtp_username,
password=self.settings.smtp_password,
use_tls=self.settings.smtp_use_tls,
use_ssl=self.settings.smtp_use_ssl,
fail_silently=False, timeout=timeout)
else:
return get_connection(fail_silently=False)

View File

@@ -26,7 +26,7 @@ import jsonschema
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Exists, F, OuterRef, Q, Subquery, Value
from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models.functions import Power
from django.utils.deconstruct import deconstructible
from django.utils.timezone import now
@@ -281,26 +281,10 @@ class Seat(models.Model):
q = Q(has_order=True) | Q(has_voucher=True)
if ignore_cart is not True:
q |= Q(has_cart=True)
# The following looks like it makes no sense. Why wouldn't we just use ``Value(self.x)``, we already now
# the value? The reason is that x and y are floating point values generated from our JSON files. As it turns
# out, PostgreSQL MIGHT store floating point values with a different precision based on the underlying system
# architecture. So if we generate e.g. 670.247128887222289 from the JSON file and store it to the database,
# PostgreSQL will store it as 670.247128887222289 internally. However if we query it again, we only get
# 670.247128887222 back. But if we do calculations with a field in PostgreSQL itself, it uses the full
# precision for the calculation.
# We don't actually care about the results with this precision, but we care that the results from this
# function are exactly the same as from event.free_seats(), so we do this subquery trick to deal with
# PostgreSQL's internal values in both cases.
# In the long run, we probably just want to round the numbers on insert...
# See also https://www.postgresql.org/docs/11/runtime-config-client.html#GUC-EXTRA-FLOAT-DIGITS
self_x = Subquery(Seat.objects.filter(pk=self.pk).values('x'))
self_y = Subquery(Seat.objects.filter(pk=self.pk).values('y'))
qs_closeby_taken = qs_annotated.annotate(
distance=(
Power(F('x') - self_x, Value(2), output_field=models.FloatField()) +
Power(F('y') - self_y, Value(2), output_field=models.FloatField())
Power(F('x') - Value(self.x), Value(2), output_field=models.FloatField()) +
Power(F('y') - Value(self.y), Value(2), output_field=models.FloatField())
)
).exclude(pk=self.pk).filter(
q,

View File

@@ -81,15 +81,6 @@ class TaxedPrice:
name=self.name,
)
def __eq__(self, other):
return (
self.gross == other.gross and
self.net == other.net and
self.tax == other.tax and
self.rate == other.rate and
self.name == other.name
)
TAXED_ZERO = TaxedPrice(
gross=Decimal('0.00'),
@@ -136,13 +127,8 @@ def cc_to_vat_prefix(country_code):
class TaxRule(LoggedModel):
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
internal_name = models.CharField(
verbose_name=_('Internal name'),
max_length=190,
null=True, blank=True,
)
name = I18nCharField(
verbose_name=_('Official name'),
verbose_name=_('Name'),
help_text=_('Should be short, e.g. "VAT"'),
max_length=190,
)
@@ -155,10 +141,6 @@ class TaxRule(LoggedModel):
verbose_name=_("The configured product prices include the tax amount"),
default=True,
)
keep_gross_if_rate_changes = models.BooleanField(
verbose_name=_("Keep gross amount constant if the tax rate changes based on the invoice address"),
default=False,
)
eu_reverse_charge = models.BooleanField(
verbose_name=_("Use EU reverse charge taxation rules"),
default=False,
@@ -216,8 +198,6 @@ class TaxRule(LoggedModel):
s = _('plus {rate}% {name}').format(rate=self.rate, name=self.name)
if self.eu_reverse_charge:
s += ' ({})'.format(_('reverse charge enabled'))
if self.internal_name:
return f'{self.internal_name} ({s})'
return str(s)
@property
@@ -231,7 +211,7 @@ class TaxRule(LoggedModel):
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'block':
raise self.SaleNotAllowed()
if rule.get('action', 'vat') in ('vat', 'require_approval') and rule.get('rate') is not None:
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
return Decimal(rule.get('rate'))
return Decimal(self.rate)
@@ -248,19 +228,13 @@ class TaxRule(LoggedModel):
rate = override_tax_rate
elif invoice_address:
adjust_rate = self.tax_rate_for(invoice_address)
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price or self.keep_gross_if_rate_changes) and base_price_is == 'gross':
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price) and base_price_is == 'gross':
rate = adjust_rate
elif adjust_rate != rate:
if self.keep_gross_if_rate_changes:
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
base_price = normal_price.gross
base_price_is = 'gross'
subtract_from_gross = Decimal('0.00')
else:
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
base_price = normal_price.net
base_price_is = 'net'
subtract_from_gross = Decimal('0.00')
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
base_price = normal_price.net
base_price_is = 'net'
subtract_from_gross = Decimal('0.00')
rate = adjust_rate
if rate == Decimal('0.00'):
@@ -363,19 +337,12 @@ class TaxRule(LoggedModel):
return False
def _require_approval(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'require_approval':
return True
return False
def _tax_applicable(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'block':
raise self.SaleNotAllowed()
return rule.get('action', 'vat') in ('vat', 'require_approval')
return rule.get('action', 'vat') == 'vat'
if not self.eu_reverse_charge:
# No reverse charge rules? Always apply VAT!

View File

@@ -39,7 +39,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator
from django.db import connection, models
from django.db.models import OuterRef, Q, Subquery, Sum
from django.db.models import F, OuterRef, Q, Subquery, Sum
from django.db.models.functions import Coalesce
from django.utils.crypto import get_random_string
from django.utils.timezone import now
@@ -530,8 +530,6 @@ class Voucher(LoggedModel):
original price will be returned.
"""
if self.value is not None:
if not isinstance(self.value, Decimal):
self.value = Decimal(self.value)
if self.price_mode == 'set':
p = self.value
elif self.price_mode == 'subtract':
@@ -571,21 +569,21 @@ class Voucher(LoggedModel):
def annotate_budget_used_orders(cls, qs):
opq = OrderPosition.objects.filter(
voucher_id=OuterRef('pk'),
voucher_budget_use__isnull=False,
price_before_voucher__isnull=False,
order__status__in=[
Order.STATUS_PAID,
Order.STATUS_PENDING
]
).order_by().values('voucher_id').annotate(s=Sum('voucher_budget_use')).values('s')
).order_by().values('voucher_id').annotate(s=Sum(F('price_before_voucher') - F('price'))).values('s')
return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=10, decimal_places=2)), Decimal('0.00')))
def budget_used(self):
ops = OrderPosition.objects.filter(
voucher=self,
voucher_budget_use__isnull=False,
price_before_voucher__isnull=False,
order__status__in=[
Order.STATUS_PAID,
Order.STATUS_PENDING
]
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
).aggregate(s=Sum(F('price_before_voucher') - F('price')))['s'] or Decimal('0.00')
return ops

View File

@@ -191,15 +191,6 @@ class BasePaymentProvider:
"""
return self.verbose_name
@property
def confirm_button_name(self) -> str:
"""
A label for the "confirm" button on the last page before a payment is started. This
is **not** used in the regular checkout flow, but only if the payment method is selected
for an existing order later on.
"""
return _("Pay now")
@property
def identifier(self) -> str:
"""
@@ -955,8 +946,6 @@ class BoxOfficeProvider(BasePaymentProvider):
return {
"pos_id": payment.info_data.get('pos_id', None),
"receipt_id": payment.info_data.get('receipt_id', None),
"payment_type": payment.info_data.get('payment_type', None),
"payment_data": payment.info_data.get('payment_data', {}),
}
def payment_control_render(self, request, payment) -> str:

View File

@@ -37,7 +37,6 @@ import hashlib
import itertools
import logging
import os
import re
import subprocess
import tempfile
import uuid
@@ -49,14 +48,12 @@ from arabic_reshaper import ArabicReshaper
from bidi.algorithm import get_display
from django.conf import settings
from django.contrib.staticfiles import finders
from django.db.models import Max, Min
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import SimpleLazyObject
from django.utils.html import conditional_escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext
from i18nfield.strings import LazyI18nString
from django.utils.translation import gettext_lazy as _
from PyPDF2 import PdfFileReader
from pytz import timezone
from reportlab.graphics import renderPDF
@@ -205,11 +202,6 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": 'foo@bar.com',
"evaluate": lambda op, order, ev: op.attendee_email or (op.addon_to.attendee_email if op.addon_to else '')
}),
("pseudonymization_id", {
"label": _("Pseudonymization ID (lead scanning)"),
"editor_sample": "GG89JUJDTA",
"evaluate": lambda orderposition, order, event: orderposition.pseudonymization_id,
}),
("event_name", {
"label": _("Event name"),
"editor_sample": _("Sample event name"),
@@ -395,41 +387,30 @@ DEFAULT_VARIABLES = OrderedDict((
("seat", {
"label": _("Seat: Full name"),
"editor_sample": _("Ground floor, Row 3, Seat 4"),
"evaluate": lambda op, order, ev: str(get_seat(op) if get_seat(op) else
"evaluate": lambda op, order, ev: str(op.seat if op.seat else
_('General admission') if ev.seating_plan_id is not None else "")
}),
("seat_zone", {
"label": _("Seat: zone"),
"editor_sample": _("Ground floor"),
"evaluate": lambda op, order, ev: str(get_seat(op).zone_name if get_seat(op) else
"evaluate": lambda op, order, ev: str(op.seat.zone_name if op.seat else
_('General admission') if ev.seating_plan_id is not None else "")
}),
("seat_row", {
"label": _("Seat: row"),
"editor_sample": "3",
"evaluate": lambda op, order, ev: str(get_seat(op).row_name if get_seat(op) else "")
"evaluate": lambda op, order, ev: str(op.seat.row_name if op.seat else "")
}),
("seat_number", {
"label": _("Seat: seat number"),
"editor_sample": 4,
"evaluate": lambda op, order, ev: str(get_seat(op).seat_number if get_seat(op) else "")
"evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "")
}),
("first_scan", {
"label": _("Date and time of first scan"),
"editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: get_first_scan(op)
}),
("giftcard_issuance_date", {
"label": _("Gift card: Issuance date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: get_giftcard_issuance(op, ev)
}),
("giftcard_expiry_date", {
"label": _("Gift card: Expiration date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: get_giftcard_expiry(op, ev)
}),
))
DEFAULT_IMAGES = OrderedDict([])
@@ -504,29 +485,18 @@ def variables_from_questions(sender, *args, **kwargs):
for q in sender.questions.all():
if q.type == Question.TYPE_FILE:
continue
d['question_{}'.format(q.identifier)] = {
'label': _('Question: {question}').format(question=q.question),
'editor_sample': _('<Answer: {question}>').format(question=q.question),
'evaluate': partial(get_answer, question_id=q.pk),
'migrate_from': 'question_{}'.format(q.pk)
}
d['question_{}'.format(q.pk)] = {
'label': _('Question: {question}').format(question=q.question),
'editor_sample': _('<Answer: {question}>').format(question=q.question),
'evaluate': partial(get_answer, question_id=q.pk),
'hidden': True,
'evaluate': partial(get_answer, question_id=q.pk)
}
return d
def _get_attendee_name_part(key, op, order, ev):
if isinstance(key, tuple):
parts = [_get_attendee_name_part(c[0], op, order, ev) for c in key if not (c[0] == 'salutation' and op.attendee_name_parts.get(c[0], '') == "Mx")]
return ' '.join(p for p in parts if p)
value = op.attendee_name_parts.get(key, '')
if key == 'salutation':
return pgettext('person_name_salutation', value)
return value
return ' '.join(p for p in [_get_attendee_name_part(c[0], op, order, ev) for c in key] if p)
return op.attendee_name_parts.get(key, '')
def _get_ia_name_part(key, op, order, ev):
@@ -546,14 +516,6 @@ def get_variables(event):
v = copy.copy(DEFAULT_VARIABLES)
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
concatenation_for_salutation = scheme.get("concatenation_for_salutation", scheme["concatenation"])
v['attendee_name_for_salutation'] = {
'label': _("Attendee name for salutation"),
'editor_sample': _("Mr Doe"),
'evaluate': lambda op, order, ev: concatenation_for_salutation(op.attendee_name_parts or {})
}
for key, label, weight in scheme['fields']:
v['attendee_name_%s' % key] = {
'label': _("Attendee name: {part}").format(part=label),
@@ -571,12 +533,6 @@ def get_variables(event):
v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
v['attendee_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
v['invoice_name_for_salutation'] = {
'label': _("Invoice address name for salutation"),
'editor_sample': _("Mr Doe"),
'evaluate': lambda op, order, ev: concatenation_for_salutation(order.invoice_address.name_parts if getattr(order, 'invoice_address', None) else {})
}
for key, label, weight in scheme['fields']:
v['invoice_name_%s' % key] = {
'label': _("Invoice address name: {part}").format(part=label),
@@ -590,24 +546,6 @@ def get_variables(event):
return v
def get_giftcard_expiry(op: OrderPosition, ev):
if not op.item.issue_giftcard:
return "" # performance optimization
m = op.issued_gift_cards.aggregate(m=Min('expires'))['m']
if not m:
return ""
return date_format(m.astimezone(ev.timezone), "SHORT_DATE_FORMAT")
def get_giftcard_issuance(op: OrderPosition, ev):
if not op.item.issue_giftcard:
return "" # performance optimization
m = op.issued_gift_cards.aggregate(m=Max('issuance'))['m']
if not m:
return ""
return date_format(m.astimezone(ev.timezone), "SHORT_DATE_FORMAT")
def get_first_scan(op: OrderPosition):
scans = list(op.checkins.all())
@@ -619,14 +557,6 @@ def get_first_scan(op: OrderPosition):
return ""
def get_seat(op: OrderPosition):
if op.seat_id:
return op.seat
if op.addon_to_id:
return op.addon_to.seat
return None
reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={
'delete_harakat': True,
'support_ligatures': False,
@@ -686,14 +616,12 @@ class Renderer:
preserveAspectRatio=True, anchor='n',
mask='auto')
def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, o: dict):
content = o.get('content', 'secret')
if content == 'secret':
# do not use get_text_content because it uses a shortened version of secret
# and does not deal with our default value here properly
content = op.secret
else:
content = self._get_text_content(op, order, o)
elif content == 'pseudonymization_id':
content = op.pseudonymization_id
level = 'H'
if len(content) > 32:
@@ -720,51 +648,20 @@ class Renderer:
return self._get_text_content(op, order, o, True)
ev = self._get_ev(op, order)
if not o['content']:
return '(error)'
if o['content'] == 'other' or o['content'] == 'other_i18n':
if o['content'] == 'other_i18n':
text = str(LazyI18nString(o['text_i18n']))
else:
text = o['text']
def replace(x):
print(x.group(1))
if x.group(1).startswith('itemmeta:'):
return op.item.meta_data.get(x.group(1)[9:]) or ''
elif x.group(1).startswith('meta:'):
return ev.meta_data.get(x.group(1)[5:]) or ''
elif x.group(1) not in self.variables:
return x.group(0)
if x.group(1) == 'secret':
# Do not use shortened version
return op.secret
try:
return self.variables[x.group(1)]['evaluate'](op, order, ev)
except:
logger.exception('Failed to process variable.')
return '(error)'
# 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)
if o['content'] == 'other':
return o['text']
elif o['content'].startswith('itemmeta:'):
return op.item.meta_data.get(o['content'][9:]) or ''
elif o['content'].startswith('meta:'):
return ev.meta_data.get(o['content'][5:]) or ''
elif o['content'] in self.variables:
try:
return self.variables[o['content']]['evaluate'](op, order, ev)
except:
logger.exception('Failed to process variable.')
return '(error)'
return ''
def _draw_imagearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
@@ -857,30 +754,20 @@ class Renderer:
p.drawOn(canvas, 0, -h - ad[1])
canvas.restoreState()
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True, only_page=None):
page_count = self.bg_pdf.getNumPages()
if not only_page and not show_page:
raise ValueError("only_page=None and show_page=False cannot be combined")
for page in range(page_count):
if only_page and only_page != page + 1:
continue
for o in self.layout:
if o.get('page', 1) != page + 1:
continue
if o['type'] == "barcodearea":
self._draw_barcodearea(canvas, op, order, o)
elif o['type'] == "imagearea":
self._draw_imagearea(canvas, op, order, o)
elif o['type'] == "textarea":
self._draw_textarea(canvas, op, order, o)
elif o['type'] == "poweredby":
self._draw_poweredby(canvas, op, o)
if self.bg_pdf:
canvas.setPageSize((self.bg_pdf.getPage(page).mediaBox[2], self.bg_pdf.getPage(page).mediaBox[3]))
if show_page:
canvas.showPage()
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True):
for o in self.layout:
if o['type'] == "barcodearea":
self._draw_barcodearea(canvas, op, o)
elif o['type'] == "imagearea":
self._draw_imagearea(canvas, op, order, o)
elif o['type'] == "textarea":
self._draw_textarea(canvas, op, order, o)
elif o['type'] == "poweredby":
self._draw_poweredby(canvas, op, o)
if self.bg_pdf:
canvas.setPageSize((self.bg_pdf.getPage(0).mediaBox[2], self.bg_pdf.getPage(0).mediaBox[3]))
if show_page:
canvas.showPage()
def render_background(self, buffer, title=_('Ticket')):
if settings.PDFTK:
@@ -893,7 +780,7 @@ class Renderer:
subprocess.run([
settings.PDFTK,
os.path.join(d, 'front.pdf'),
'multibackground',
'background',
os.path.join(d, 'back.pdf'),
'output',
os.path.join(d, 'out.pdf'),
@@ -907,8 +794,8 @@ class Renderer:
new_pdf = PdfFileReader(buffer)
output = PdfFileWriter()
for i, page in enumerate(new_pdf.pages):
bg_page = copy.copy(self.bg_pdf.getPage(i))
for page in new_pdf.pages:
bg_page = copy.copy(self.bg_pdf.getPage(0))
bg_page.mergePage(page)
output.addPage(bg_page)

View File

@@ -273,11 +273,6 @@ class RelativeDateTimeField(forms.MultiValueField):
minutes_before=None
))
def has_changed(self, initial, data):
if initial is None:
initial = self.widget.decompress(initial)
return super().has_changed(initial, data)
def clean(self, value):
if value[0] == 'absolute' and not value[1]:
raise ValidationError(self.error_messages['incomplete'])

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