mirror of
https://github.com/pretix/pretix.git
synced 2025-12-07 22:42:26 +00:00
Compare commits
1 Commits
fix-pdf-ed
...
aws_ses_45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c4173a2ab |
@@ -31,7 +31,7 @@ RUN apt-get update && \
|
||||
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
|
||||
mkdir /static && \
|
||||
mkdir /etc/supervisord && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash - && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_15.x | sudo -E bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
curl -qL https://www.npmjs.com/install.sh | sh
|
||||
|
||||
|
||||
2
doc/_themes/pretix_theme/layout.html
vendored
2
doc/_themes/pretix_theme/layout.html
vendored
@@ -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">
|
||||
|
||||
@@ -3155,14 +3155,14 @@ a .fa, a .wy-menu-vertical li span.toctree-expand, .wy-menu-vertical li a span.t
|
||||
vertical-align: -15%
|
||||
}
|
||||
|
||||
.wy-alert, .rst-content .note, .rst-content .attention, .rst-content .caution, .rst-content .danger, .rst-content .error, .rst-content .hint, .rst-content .important, .rst-content .tip, .rst-content .warning, .rst-content .seealso, .rst-content .admonition-todo, .rst-content div.deprecated {
|
||||
.wy-alert, .rst-content .note, .rst-content .attention, .rst-content .caution, .rst-content .danger, .rst-content .error, .rst-content .hint, .rst-content .important, .rst-content .tip, .rst-content .warning, .rst-content .seealso, .rst-content .admonition-todo {
|
||||
padding: 12px;
|
||||
line-height: 24px;
|
||||
margin-bottom: 24px;
|
||||
background: #e7f2fa
|
||||
}
|
||||
|
||||
.wy-alert-title, .rst-content .admonition-title, .rst-content .deprecated .versionmodified {
|
||||
.wy-alert-title, .rst-content .admonition-title {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
|
||||
@@ -240,9 +240,6 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
listen 80 default_server;
|
||||
listen [::]:80 ipv6only=on default_server;
|
||||
server_name pretix.mydomain.com;
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 443 default_server;
|
||||
|
||||
@@ -225,9 +225,6 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
listen 80 default_server;
|
||||
listen [::]:80 ipv6only=on default_server;
|
||||
server_name pretix.mydomain.com;
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 443 default_server;
|
||||
|
||||
@@ -172,6 +172,8 @@ Cart position endpoints
|
||||
|
||||
* does not check or calculate prices but believes any prices you send
|
||||
|
||||
* does not support the redemption of vouchers
|
||||
|
||||
* does not prevent you from buying items that can only be bought with a voucher
|
||||
|
||||
* does not support file upload questions
|
||||
@@ -187,9 +189,8 @@ 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)
|
||||
* ``voucher`` (optional, expect a voucher code)
|
||||
* ``answers``
|
||||
|
||||
* ``question``
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
.. spelling:: checkin
|
||||
|
||||
.. _rest-checkin:
|
||||
|
||||
Check-in
|
||||
========
|
||||
|
||||
This page describes special APIs built for ticket scanning apps. For managing check-in configuration or other operations,
|
||||
please also see :ref:`rest-checkinlists`. The check-in list API also contains endpoints to obtain statistics or log
|
||||
failed scans.
|
||||
|
||||
.. versionchanged:: 4.12
|
||||
|
||||
The endpoints listed on this page have been added.
|
||||
|
||||
.. _`rest-checkin-redeem`:
|
||||
|
||||
Checking a ticket in
|
||||
--------------------
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/checkinrpc/redeem/
|
||||
|
||||
Tries to redeem an order position, i.e. checks the attendee in (or out). This is the recommended endpoint to use
|
||||
if you build any kind of scanning app that performs check-ins for scanned barcodes. It is safe to use with untrusted
|
||||
inputs in the ``secret`` field.
|
||||
|
||||
This endpoint supports passing multiple check-in lists to perform a multi-event scan. However, each check-in list
|
||||
passed needs to be from a distinct event.
|
||||
|
||||
:<json string secret: Scanned QR code corresponding to the ``secret`` attribute of a ticket.
|
||||
:<json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event.
|
||||
:<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
|
||||
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
|
||||
:<json boolean force: Specifies that the check-in should succeed regardless of revoked barcode, previous check-ins or required
|
||||
questions that have not been filled. This is usually used to upload offline scans that already happened,
|
||||
because there's no point in validating them since they happened whether they are valid or not. Defaults to ``false``.
|
||||
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||
you do not implement question handling in your user interface, you **must**
|
||||
set this to ``false``. In that case, questions will just be ignored. Defaults
|
||||
to ``true``.
|
||||
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
|
||||
Defaults to ``false`` and only works when ``include_pending`` is set on the check-in
|
||||
list.
|
||||
:<json object answers: If questions are supported/required, you may/must supply a mapping of question IDs to their
|
||||
respective answers. The answers should always be strings. In case of (multiple-)choice-type
|
||||
answers, the string should contain the (comma-separated) IDs of the selected options.
|
||||
:<json string nonce: You can set this parameter to a unique random value to identify this check-in. If you're sending
|
||||
this request twice with the same nonce, the second request will also succeed but will always
|
||||
create only one check-in object even when the previous request was successful as well. This
|
||||
allows for a certain level of idempotency and enables you to re-try after a connection failure.
|
||||
:>json string status: ``"ok"``, ``"incomplete"``, or ``"error"``
|
||||
:>json string reason: Reason code, only set on status ``"error"``, see below for possible values.
|
||||
:>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null.
|
||||
:>json object position: Copy of the matching order position (if any was found). The contents are the same as the
|
||||
:ref:`order-position-resource`, with the following differences: (1) The ``checkins`` value
|
||||
will only include check-ins for the selected list. (2) An additional boolean property
|
||||
``require_attention`` will inform you whether either the order or the item have the
|
||||
``checkin_attention`` flag set. (3) If ``attendee_name`` is empty, it may automatically fall
|
||||
back to values from a parent product or from invoice addresses.
|
||||
:>json boolean require_attention: Whether or not the ``require_attention`` flag is set on the item or order.
|
||||
:>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found),
|
||||
including the attributes ``id``, ``name``, ``event``, ``subevent``, and ``include_pending``.
|
||||
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/checkinrpc/redeem/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"secret": "M5BO19XmFwAjLd4nDYUAL9ISjhti0e9q",
|
||||
"lists": [1],
|
||||
"force": false,
|
||||
"ignore_unpaid": false,
|
||||
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
|
||||
"datetime": null,
|
||||
"questions_supported": true,
|
||||
"answers": {
|
||||
"4": "XS"
|
||||
}
|
||||
}
|
||||
|
||||
**Example successful response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "ok",
|
||||
"position": {
|
||||
…
|
||||
},
|
||||
"require_attention": false,
|
||||
"list": {
|
||||
"id": 1,
|
||||
"name": "Default check-in list",
|
||||
"event": "sampleconf",
|
||||
"subevent": null,
|
||||
"include_pending": false
|
||||
}
|
||||
}
|
||||
|
||||
**Example response with required questions**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 400 Bad Request
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "incomplete",
|
||||
"position": {
|
||||
…
|
||||
},
|
||||
"require_attention": false,
|
||||
"list": {
|
||||
"id": 1,
|
||||
"name": "Default check-in list",
|
||||
"event": "sampleconf",
|
||||
"subevent": null,
|
||||
"include_pending": false
|
||||
},
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"question": {"en": "T-Shirt size"},
|
||||
"type": "C",
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
"position": 1,
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": true,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 0,
|
||||
"answer": {"en": "S"}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"identifier": "DFEMJWMJ",
|
||||
"position": 1,
|
||||
"answer": {"en": "M"}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"identifier": "W9AH7RDE",
|
||||
"position": 2,
|
||||
"answer": {"en": "L"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Example error response (invalid ticket)**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 404 Not Found
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"detail": "Not found.",
|
||||
"status": "error",
|
||||
"reason": "invalid",
|
||||
"reason_explanation": null,
|
||||
"require_attention": false
|
||||
}
|
||||
|
||||
**Example error response (known, but invalid ticket)**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "unpaid",
|
||||
"reason_explanation": null,
|
||||
"require_attention": false,
|
||||
"list": {
|
||||
"id": 1,
|
||||
"name": "Default check-in list",
|
||||
"event": "sampleconf",
|
||||
"subevent": null,
|
||||
"include_pending": false
|
||||
},
|
||||
"position": {
|
||||
…
|
||||
}
|
||||
}
|
||||
|
||||
Possible error reasons:
|
||||
|
||||
* ``invalid`` - Ticket is not known.
|
||||
* ``unpaid`` - Ticket is not paid for.
|
||||
* ``canceled`` – Ticket is canceled or expired.
|
||||
* ``already_redeemed`` - Ticket already has been redeemed.
|
||||
* ``product`` - Tickets with this product may not be scanned at this device.
|
||||
* ``rules`` - Check-in prevented by a user-defined rule.
|
||||
* ``ambiguous`` - Multiple tickets match scan, rejected.
|
||||
* ``revoked`` - Ticket code has been revoked.
|
||||
* ``error`` - Internal error.
|
||||
|
||||
In case of reason ``rules``, there might be an additional response field ``reason_explanation`` with a human-readable
|
||||
description of the violated rules. However, that field can also be missing or be ``null``.
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: Invalid or incomplete request, see above
|
||||
:statuscode 401: Authentication failure
|
||||
: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.
|
||||
|
||||
Performing a ticket search
|
||||
--------------------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/checkinrpc/search/
|
||||
|
||||
Returns a list of all order positions matching a given search request. The result is the same as
|
||||
the :ref:`order-position-resource`, with the following differences:
|
||||
|
||||
* The ``checkins`` value will only include check-ins for the selected list.
|
||||
|
||||
* An additional boolean property ``require_attention`` will inform you whether either the order or the item
|
||||
have the ``checkin_attention`` flag set.
|
||||
|
||||
* If ``attendee_name`` is empty, it will automatically fall back to values from a parent product or from invoice
|
||||
addresses.
|
||||
|
||||
This endpoint supports passing multiple check-in lists to perform a multi-event search. However, each check-in list
|
||||
passed needs to be from a distinct event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/checkinrpc/search/?list=1&search=Peter 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": 23442,
|
||||
"order": "ABC12",
|
||||
"positionid": 1,
|
||||
"item": 1345,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": null,
|
||||
"tax_value": "0.00",
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 1,
|
||||
"type": "entry",
|
||||
"gate": null,
|
||||
"device": 2,
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": true
|
||||
}
|
||||
],
|
||||
"answers": [
|
||||
{
|
||||
"question": 12,
|
||||
"answer": "Foo",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"downloads": [
|
||||
{
|
||||
"output": "pdf",
|
||||
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query string search: Fuzzy search matching the attendee name, order code, invoice address name as well as to the beginning of the secret.
|
||||
:query integer list: The check-in list to search on, can be passed multiple times.
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ignore_status: If set to ``true``, results will be returned regardless of the state of
|
||||
the order they belong to and you will need to do your own filtering by order status.
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``order__code``,
|
||||
``order__datetime``, ``positionid``, ``attendee_name``, ``last_checked_in`` and ``order__email``. Default:
|
||||
``attendee_name,positionid``
|
||||
:query string order: Only return positions of the order with the given order code
|
||||
:query string search: Fuzzy search matching the attendee name, order code, invoice address name as well as to the beginning of the secret.
|
||||
:query string expand: Expand a field into a full object. Currently only ``subevent``, ``item``, and ``variation`` are supported. Can be passed multiple times.
|
||||
:query integer item: Only return positions with the purchased item matching the given ID.
|
||||
:query integer item__in: Only return positions with the purchased item matching one of the given comma-separated IDs.
|
||||
:query integer variation: Only return positions with the purchased item variation matching the given ID.
|
||||
:query integer variation__in: Only return positions with one of the purchased item variation matching the given
|
||||
comma-separated IDs.
|
||||
:query string attendee_name: Only return positions with the given value in the attendee_name field. Also, add-on
|
||||
products positions are shown if they refer to an attendee with the given name.
|
||||
:query string secret: Only return positions with the given ticket secret.
|
||||
:query string order__status: Only return positions with the given order status.
|
||||
:query string order__status__in: Only return positions with one the given comma-separated order status.
|
||||
:query boolean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
|
||||
checked in already.
|
||||
:query integer subevent: Only return positions of the sub-event with the given ID
|
||||
:query integer subevent__in: Only return positions of one of the sub-events with the given comma-separated IDs
|
||||
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
|
||||
:query integer addon_to__in: Only return positions that are add-ons to one of the positions with the given
|
||||
comma-separated IDs.
|
||||
:query string voucher: Only return positions with a specific voucher.
|
||||
:query string voucher__code: Only return positions with a specific voucher code.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer or check-in list does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested check-in list does not exist.
|
||||
@@ -1,7 +1,5 @@
|
||||
.. spelling:: checkin
|
||||
|
||||
.. _rest-checkinlists:
|
||||
|
||||
Check-in lists
|
||||
==============
|
||||
|
||||
@@ -36,7 +34,6 @@ allow_multiple_entries boolean If ``true``, su
|
||||
allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan.
|
||||
rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged.
|
||||
exit_all_at datetime Automatically check out (i.e. perform an exit scan) at this point in time. After this happened, this property will automatically be set exactly one day into the future. Note that this field is considered "internal configuration" and if you pull the list with ``If-Modified-Since``, the daily change in this field will not trigger a response.
|
||||
addon_match boolean If ``true``, tickets on this list can be redeemed by scanning their parent ticket if this still leads to an unambiguous match.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 3.9
|
||||
@@ -56,10 +53,6 @@ addon_match boolean If ``true``, ti
|
||||
|
||||
The ``ends_after`` and ``expand`` query parameters have been added.
|
||||
|
||||
.. versionchanged:: 4.12
|
||||
|
||||
The ``addon_match`` attribute has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -101,7 +94,6 @@ Endpoints
|
||||
"allow_entry_after_exit": true,
|
||||
"exit_all_at": null,
|
||||
"rules": {},
|
||||
"addon_match": false,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -154,7 +146,6 @@ Endpoints
|
||||
"allow_entry_after_exit": true,
|
||||
"exit_all_at": null,
|
||||
"rules": {},
|
||||
"addon_match": false,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -254,7 +245,6 @@ Endpoints
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"addon_match": false,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -279,7 +269,6 @@ Endpoints
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"addon_match": false,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -334,7 +323,6 @@ Endpoints
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"addon_match": false,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -427,9 +415,6 @@ Order position endpoints
|
||||
* If ``attendee_name`` is empty, it will automatically fall back to values from a parent product or from invoice
|
||||
addresses.
|
||||
|
||||
You can use this endpoint to implement a ticket search. We also provide a dedicated search input as part of our
|
||||
:ref:`check-in API <rest-checkin>` that supports search across multiple events.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -619,23 +604,15 @@ Order position endpoints
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order position or check-in list does not exist.
|
||||
|
||||
.. _`rest-checkin-redeem`:
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/redeem/
|
||||
|
||||
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
||||
accepts a number of optional requests in the body.
|
||||
|
||||
**Tip:** Instead of an ID, you can also use the ``secret`` field as the lookup parameter. In this case, you should
|
||||
always set ``untrusted_input=true`` as a query parameter to avoid security issues.
|
||||
**Tip:** Instead of an ID, you can also use the ``secret`` field as the lookup parameter.
|
||||
|
||||
.. note::
|
||||
|
||||
We no longer recommend using this API if you're building a ticket scanning application, as it has a few design
|
||||
flaws that can lead to `security issues`_ or compatibility issues due to barcode content characters that are not
|
||||
URL-safe. We recommend to use our new :ref:`check-in API <rest-checkin>` instead.
|
||||
|
||||
:query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never
|
||||
as an ``id``. This should be always set if you are passing through untrusted, scanned
|
||||
data to avoid guessing of ticket IDs.
|
||||
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||
you do not implement question handling in your user interface, you **must**
|
||||
set this to ``false``. In that case, questions will just be ignored. Defaults
|
||||
@@ -756,15 +733,12 @@ Order position endpoints
|
||||
|
||||
Possible error reasons:
|
||||
|
||||
* ``invalid`` - Ticket code not known.
|
||||
* ``unpaid`` - Ticket is not paid for.
|
||||
* ``canceled`` – Ticket is canceled or expired. This reason is only sent when your request sets.
|
||||
* ``unpaid`` - Ticket is not paid for
|
||||
* ``canceled`` – Ticket is canceled or expired. This reason is only sent when your request sets
|
||||
``canceled_supported`` to ``true``, otherwise these orders return ``unpaid``.
|
||||
* ``already_redeemed`` - Ticket already has been redeemed.
|
||||
* ``product`` - Tickets with this product may not be scanned at this device.
|
||||
* ``rules`` - Check-in prevented by a user-defined rule.
|
||||
* ``ambiguous`` - Multiple tickets match scan, rejected.
|
||||
* ``revoked`` - Ticket code has been revoked.
|
||||
* ``already_redeemed`` - Ticket already has been redeemed
|
||||
* ``product`` - Tickets with this product may not be scanned at this device
|
||||
* ``rules`` - Check-in prevented by a user-defined rule
|
||||
|
||||
In case of reason ``rules``, there might be an additional response field ``reason_explanation`` with a human-readable
|
||||
description of the violated rules. However, that field can also be missing or be ``null``.
|
||||
@@ -778,6 +752,3 @@ Order position endpoints
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order position or check-in list does not exist.
|
||||
|
||||
|
||||
.. _security issues: https://pretix.eu/about/de/blog/20220705-release-4111/
|
||||
@@ -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
|
||||
@@ -131,9 +125,7 @@ Endpoints
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/customers/
|
||||
|
||||
Creates a new customer. In addition to the fields defined on the resource, you can pass the field ``send_email``
|
||||
to control whether the system should send an account activation email with a password reset link (defaults to
|
||||
``false``).
|
||||
Creates a new customer
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -145,8 +137,7 @@ Endpoints
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "test@example.org",
|
||||
"send_email": true
|
||||
"email": "test@example.org"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -159,7 +150,6 @@ Endpoints
|
||||
|
||||
{
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "test@example.org",
|
||||
...
|
||||
}
|
||||
@@ -176,8 +166,8 @@ Endpoints
|
||||
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 ``identifier``, ``last_login``, ``date_joined``,
|
||||
``name`` (which is auto-generated from ``name_parts``), and ``last_modified`` fields.
|
||||
You can change all fields of the resource except the ``identifier``, ``last_login``, ``date_joined``, ``name``,
|
||||
and ``last_modified`` fields.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -203,7 +193,6 @@ Endpoints
|
||||
|
||||
{
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "test@example.org",
|
||||
…
|
||||
}
|
||||
@@ -237,7 +226,6 @@ Endpoints
|
||||
|
||||
{
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": null,
|
||||
…
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -182,7 +182,7 @@ endpoints:
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/exporters/orderlist/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/"
|
||||
"download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderlist/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
|
||||
@@ -24,8 +24,6 @@ at :ref:`plugin-docs`.
|
||||
orders
|
||||
invoices
|
||||
vouchers
|
||||
discounts
|
||||
checkin
|
||||
checkinlists
|
||||
waitinglist
|
||||
customers
|
||||
@@ -38,7 +36,6 @@ at :ref:`plugin-docs`.
|
||||
webhooks
|
||||
seatingplans
|
||||
exporters
|
||||
shredders
|
||||
sendmail_rules
|
||||
billing_invoices
|
||||
billing_var
|
||||
@@ -60,7 +60,6 @@ invoice_address object Invoice address
|
||||
├ state string Customer state (ISO 3166-2 code). Only supported in
|
||||
AU, BR, CA, CN, MY, MX, and US.
|
||||
├ internal_reference string Customer's internal reference to be printed on the invoice
|
||||
├ custom_field string Custom invoice address field
|
||||
├ vat_id string Customer VAT ID
|
||||
└ vat_id_validated string ``true``, if the VAT ID has been validated against the
|
||||
EU VAT service and validation was successful. This only
|
||||
@@ -428,7 +427,7 @@ List of all orders
|
||||
``last_modified``, and ``status``. Default: ``datetime``
|
||||
:query string code: Only return orders that match the given order code
|
||||
:query string status: Only return orders in the given order status (see above)
|
||||
:query string search: Only return orders matching a given search query (matching for names, email addresses, and company names)
|
||||
:query string search: Only return orders matching a given search query
|
||||
:query integer item: Only return orders with a position that contains this item ID. *Warning:* Result will also include orders if they contain mixed items, and it will even return orders where the item is only contained in a canceled position.
|
||||
:query integer variation: Only return orders with a position that contains this variation ID. *Warning:* Result will also include orders if they contain mixed items and variations, and it will even return orders where the variation is only contained in a canceled position.
|
||||
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
|
||||
@@ -610,17 +609,13 @@ Fetching individual orders
|
||||
Order ticket download
|
||||
---------------------
|
||||
|
||||
.. versionchanged:: 4.10
|
||||
|
||||
The API now supports ticket downloads for pending orders if allowed by the event settings.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/download/(output)/
|
||||
|
||||
Download tickets for an order, identified by its order code. Depending on the chosen output, the response might
|
||||
be a ZIP file, PDF file or something else. The order details response contains a list of output options for this
|
||||
particular order.
|
||||
|
||||
Tickets can only be downloaded if ticket downloads are active and – depending on event settings – the order is either paid or pending. Note that in some cases the
|
||||
Tickets can be only downloaded if the order is paid and if ticket downloads are active. Note that in some cases the
|
||||
ticket file might not yet have been created. In that case, you will receive a status code :http:statuscode:`409` and
|
||||
you are expected to retry the request after a short period of waiting.
|
||||
|
||||
@@ -853,7 +848,7 @@ Creating orders
|
||||
|
||||
You can supply the following fields of the resource:
|
||||
|
||||
* ``code`` (optional) – Only ``A-Z`` and ``0-9``, but without ``O`` and ``1``.
|
||||
* ``code`` (optional)
|
||||
* ``status`` (optional) – Defaults to pending for non-free orders and paid for free orders. You can only set this to
|
||||
``"n"`` for pending or ``"p"`` for paid. We will create a payment object for this order either in state ``created``
|
||||
or in state ``confirmed``, depending on this value. If you create a paid order, the ``order_paid`` signal will
|
||||
@@ -1640,10 +1635,6 @@ Fetching individual positions
|
||||
Order position ticket download
|
||||
------------------------------
|
||||
|
||||
.. versionchanged:: 4.10
|
||||
|
||||
The API now supports ticket downloads for pending orders if allowed by the event settings.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/download/(output)/
|
||||
|
||||
Download tickets for one order position, identified by its internal ID.
|
||||
@@ -1655,7 +1646,7 @@ Order position ticket download
|
||||
The referenced URL can provide a download or a regular, human-viewable website - so it is advised to open this URL
|
||||
in a webbrowser and leave it up to the user to handle the result.
|
||||
|
||||
Tickets can only be downloaded if ticket downloads are active and – depending on event settings – the order is either paid or pending. Also, depending on event
|
||||
Tickets can be only downloaded if the order is paid and if ticket downloads are active. Also, depending on event
|
||||
configuration downloads might be only unavailable for add-on products or non-admission products.
|
||||
Note that in some cases the ticket file might not yet have been created. In that case, you will receive a status
|
||||
code :http:statuscode:`409` and you are expected to retry the request after a short period of waiting.
|
||||
|
||||
@@ -89,8 +89,7 @@ Endpoints
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``.
|
||||
Default: ``position``
|
||||
:query integer subevent: Only return quotas of the sub-event with the given ID.
|
||||
:query integer subevent__in: Only return quotas of sub-events with one the given IDs (comma-separated).
|
||||
:query integer subevent: Only return quotas of the sub-event with the given ID
|
||||
:query string with_availability: Set to ``true`` to get availability information. Can lead to increased answer times.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
.. spelling:: checkin
|
||||
|
||||
Data shredders
|
||||
==============
|
||||
|
||||
pretix and it's plugins include a number of data shredders that allow you to clear personal information from the system.
|
||||
This page shows you how to use these shredders through the API.
|
||||
|
||||
.. versionchanged:: 4.12
|
||||
|
||||
This feature has been added to the API.
|
||||
|
||||
.. warning::
|
||||
|
||||
Unlike the user interface, the API will not force you to download tax-relevant data before you delete it.
|
||||
|
||||
Listing available shredders
|
||||
---------------------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/shredders/
|
||||
|
||||
Returns a list of all exporters shredders for a given event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/shredders/ 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": [
|
||||
{
|
||||
"identifier": "question_answers",
|
||||
"verbose_name": "Answers to questions"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
: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.
|
||||
|
||||
Running an export
|
||||
-----------------
|
||||
|
||||
Before you can delete data, you need to start a data export.
|
||||
Since exports often include large data sets, they might take longer than the duration of an HTTP request. Therefore,
|
||||
creating an export is a two-step process. First you need to start an export task with one of the following to API
|
||||
endpoints:
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/shredders/export/
|
||||
|
||||
Starts an export task. If your input parameters validate correctly, a ``202 Accepted`` status code is returned.
|
||||
The body points you to the download URL of the result as well as the URL for the next step.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/shredders/export/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"shredders": ["question_answers"]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 202 Accepted
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/shredders/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/",
|
||||
"shred": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/shredders/shred/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param identifier: The ``identifier`` field of the exporter to run
|
||||
:statuscode 202: no error
|
||||
:statuscode 400: Invalid input options or event data is not ready to be deleted
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
|
||||
Downloading the result
|
||||
----------------------
|
||||
|
||||
When starting an export, you receive a ``download`` URL for downloading the result. Running a ``GET`` request on that result will
|
||||
yield one of the following status codes:
|
||||
|
||||
* ``200 OK`` – The export succeeded. The body will be your resulting file. Might be large!
|
||||
* ``409 Conflict`` – Your export is still running. The body will be JSON with the structure ``{"status": "running"}``. ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
|
||||
* ``410 Gone`` – Running the export has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
|
||||
* ``404 Not Found`` – The export does not exist / is expired / belongs to a different API key.
|
||||
|
||||
|
||||
Shredding the data
|
||||
------------------
|
||||
|
||||
When starting an export, you receive a ``shred`` URL for actually shredding the data.
|
||||
You can only start the actual shredding process after the export file was generated, however you are not forced to download
|
||||
the file (we'd recommend it in most cases, though).
|
||||
The download will no longer be possible after the shredding.
|
||||
Since shredding often requires deleting large data sets, it might take longer than the duration of an HTTP request.
|
||||
Therefore, shredding again is a two-step process. First you need to start a shredder task with one of the following to API
|
||||
endpoints:
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/shredders/shred/(id1)/(id2)/
|
||||
|
||||
Starts an export task. If your input parameters validate correctly, a ``202 Accepted`` status code is returned.
|
||||
The body points you to an URL you can use to check the status.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/shredders/shred/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 202 Accepted
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/shredders/status/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id1: Opaque value given to you in the previous response
|
||||
:param id2: Opaque value given to you in the previous response
|
||||
:statuscode 202: no error
|
||||
:statuscode 400: Invalid input options
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 404: The export does not exist / is expired / belongs to a different API key.
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 409: Your export is still running. The body will be JSON with the structure ``{"status": "running"}``. ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
|
||||
:statuscode 410: Either the job has timed out or running the export has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
|
||||
|
||||
|
||||
Checking the result
|
||||
-------------------
|
||||
|
||||
When starting to shred, you receive a ``status`` URL for checking for success.
|
||||
Running a ``GET`` request on that result will yield one of the following status codes:
|
||||
|
||||
* ``200 OK`` – The shredding succeeded.
|
||||
* ``409 Conflict`` – Shredding is still running. The body will be JSON with the structure ``{"status": "running"}``. ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
|
||||
* ``410 Gone`` – We no longer know about this process, probably the process was started more than an hour ago. Might also occur after successful operations on small pretix installations without asynchronous task handling.
|
||||
* ``417 Expectation Failed`` – Running the export has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
|
||||
@@ -474,7 +474,6 @@ Endpoints
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
|
||||
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
|
||||
:query sales_channel: If set to a sales channel identifier, the response will only contain subevents from events available on this sales channel.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
.. _`rest-vouchers`:
|
||||
|
||||
Vouchers
|
||||
========
|
||||
|
||||
|
||||
@@ -9,6 +9,5 @@ ticket scanning apps and we want to ensure the implementations are as similar as
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
pricing
|
||||
checkin
|
||||
layouts
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
@@ -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
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 236 KiB |
@@ -2,25 +2,8 @@
|
||||
|
||||
|
||||
partition "data-based check" {
|
||||
"Check based on local database" -down-> "Is addon_match set to true?"
|
||||
"Check based on local database" --> "Is the order in status PAID or PENDING\nand is the position not canceled?"
|
||||
--> if "" then
|
||||
-down->[no] "Is the order in status PAID or PENDING\nand is the position not canceled?"
|
||||
else
|
||||
-right->[yes] "Build a list that includes the position\nas well as all its add-ons"
|
||||
-down-> "Filter list for products that are part of the check-in list"
|
||||
--> if "" then
|
||||
-down->[one found] Proceed with the matching position
|
||||
--> "Is the order in status PAID or PENDING\nand is the position not canceled?"
|
||||
else
|
||||
--> if "" then
|
||||
-right->[none found] "Return error PRODUCT "
|
||||
else
|
||||
-down->[multiple found] Return error AMBIGUOUS
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
"Is the order in status PAID or PENDING\nand is the position not canceled?" --> if "" then
|
||||
-right->[no] "Return error CANCELED"
|
||||
else
|
||||
-down->[yes] "Is the product part of the check-in list?"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 147 KiB |
@@ -19,25 +19,8 @@ else
|
||||
endif
|
||||
|
||||
|
||||
===CHECK=== -down-> "Is addon_match set to true?"
|
||||
===CHECK=== -down-> "Is the order in status PAID or PENDING\nand is the position not canceled?"
|
||||
--> if "" then
|
||||
-down->[no] "Is the order in status PAID or PENDING\nand is the position not canceled?"
|
||||
else
|
||||
-right->[yes] "Build a list that includes the position\nas well as all its add-ons"
|
||||
-down-> "Filter list for products that are part of the check-in list"
|
||||
--> if "" then
|
||||
-down->[one found] Proceed with the matching position
|
||||
--> "Is the order in status PAID or PENDING\nand is the position not canceled?"
|
||||
else
|
||||
--> if "" then
|
||||
-right->[none found] "Return error PRODUCT "
|
||||
else
|
||||
-down->[multiple found] Return error AMBIGUOUS
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
"Is the order in status PAID or PENDING\nand is the position not canceled?" --> if "" then
|
||||
-right->[no] "Return error CANCELED"
|
||||
else
|
||||
-down->[yes] "Is the product part of the check-in list?"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -227,108 +227,6 @@ Endpoints
|
||||
: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:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/vouchers/
|
||||
|
||||
Returns a list of all vouchers connected to an exhibitor. The response contains the same data as described in
|
||||
:ref:`rest-vouchers`.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/vouchers/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
"max_usages": 1,
|
||||
"redeemed": 0,
|
||||
"valid_until": null,
|
||||
"block_quota": false,
|
||||
"allow_ignore_quota": false,
|
||||
"price_mode": "set",
|
||||
"value": "12.00",
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": "",
|
||||
"seat": 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
|
||||
: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/(id)/vouchers/attach/
|
||||
|
||||
Attaches an **existing** voucher to an exhibitor. You need to send either the ``id`` **or** the ``code`` field of
|
||||
the voucher.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/vouchers/attach/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"id": 15
|
||||
}
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/vouchers/attach/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"code": "43K6LKM37FBVR2YG"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{}
|
||||
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the event to use
|
||||
:param id: The ``id`` field of the exhibitor to use
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: Invalid data sent, e.g. voucher does not exist
|
||||
: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.
|
||||
@@ -532,7 +430,6 @@ event object Object describi
|
||||
├ 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.
|
||||
├ terms_url string URL to terms of service. 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
|
||||
@@ -568,7 +465,6 @@ scan_types list of objects Only used for a
|
||||
"imprint_url": null,
|
||||
"privacy_url": null,
|
||||
"help_url": null,
|
||||
"terms_url": null,
|
||||
"logo_url": null,
|
||||
"organizer": "sampleconf"
|
||||
},
|
||||
|
||||
@@ -19,7 +19,6 @@ If you want to **create** a plugin, please go to the
|
||||
certificates
|
||||
digital
|
||||
exhibitors
|
||||
shipping
|
||||
imported_secrets
|
||||
webinar
|
||||
presale-saml
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
Shipping
|
||||
========
|
||||
|
||||
The shipping plugin provides a HTTP API that exposes the various layouts used to generate PDF badges.
|
||||
|
||||
Shipping address resource
|
||||
-------------------------
|
||||
|
||||
The shipping address resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
company string Customer company name
|
||||
name string Customer name
|
||||
street string Customer street
|
||||
zipcode string Customer ZIP code
|
||||
city string Customer city
|
||||
country string Customer country code
|
||||
state string Customer state (ISO 3166-2 code). Only supported in
|
||||
AU, BR, CA, CN, MY, MX, and US.
|
||||
gift boolean Request by customer to not disclose prices in the shipping
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Shipping status resource
|
||||
------------------------
|
||||
|
||||
The shipping status resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
method integer Internal ID of shipping method
|
||||
status string Status, one of ``"new"`` or ``"shipped"``
|
||||
method_type string Method type, one of ``"ship"``, ``"online"``, or ``"collect"``
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Print job resource
|
||||
------------------
|
||||
|
||||
The print job resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
code string Order code of the ticket order
|
||||
event string Event slug
|
||||
status string Status, one of ``"new"`` or ``"shipped"``
|
||||
method string Method type, one of ``"ship"``, ``"online"``, or ``"collect"``
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/shippingaddress/
|
||||
|
||||
Returns the shipping address of an order
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/democon/orders/ABC12/shippingaddress/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"company": "ACME Corp",
|
||||
"name": "John Doe",
|
||||
"street": "Sesame Street 12\nAp. 5",
|
||||
"zipcode": "12345",
|
||||
"city": "Berlin",
|
||||
"country": "DE",
|
||||
"state": "",
|
||||
"gift": false
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of a valid event
|
||||
:param order: The ``code`` field of a valid order
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
:statuscode 404: The order does not exist or no shipping address is attached.
|
||||
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/shippingaddress/
|
||||
|
||||
Returns the shipping status of an order
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/democon/orders/ABC12/shippingstatus/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"method": 23,
|
||||
"method_type": "ship",
|
||||
"status": "new"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of a valid event
|
||||
:param order: The ``code`` field of a valid order
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
:statuscode 404: The order does not exist or no shipping address is attached.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/printjobs/
|
||||
|
||||
Returns a list of ticket orders, only useful with some query filters
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/printjobs/?method=ship&status=new HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"event": "democon",
|
||||
"order": "ABC12",
|
||||
"method": "ship",
|
||||
"status": "new"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query string method: Filter by response field ``method`` (can be passed multiple times)
|
||||
:query string status: Filter by response field ``status``
|
||||
:query string event: Filter by response field ``event``
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of a valid event
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/printjobs/poll/
|
||||
|
||||
Returns the PDF file for the next job to print.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/printjobs/poll/?method=ship&status=new 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/pdf
|
||||
X-Pretix-Order-Code: ABC12
|
||||
|
||||
...
|
||||
|
||||
:query string method: Filter by response field ``method`` (can be passed multiple times)
|
||||
:query string status: Filter by response field ``status``
|
||||
:query string event: Filter by response field ``event``
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of a valid event
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/printjobs/(order)/ack/
|
||||
|
||||
Change an order's status to "shipped".
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/printjobs/ABC12/ack/ 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 a valid organizer
|
||||
:param event: The ``slug`` field of a valid event
|
||||
:param order: The ``code`` field of a valid order
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
:statuscode 404: The order does not exist.
|
||||
@@ -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.
|
||||
|
||||
@@ -78,7 +78,7 @@ Synchronization setting any
|
||||
----------------------------------------------- ----------------------------------- ----------------------------------------------------------------------- -----------------------------------------------------------------------
|
||||
Ticket secrets any Random Signed Random Signed
|
||||
=============================================== =================================== =================================== =================================== ================================= =====================================
|
||||
Scenario supported on platforms Android, Desktop, iOS Android, Desktop, iOS Android, Desktop Android, Desktop, iOS Android, Desktop, iOS
|
||||
Scenario supported on platforms Android, Desktop, iOS Android, Desktop, iOS Android, Desktop Android, Desktop Android, Desktop
|
||||
Synchronization speed for large data sets slow slow fast fast
|
||||
Tickets can be scanned yes yes yes no yes
|
||||
Ticket is valid after sale immediately next sync (~5 minutes) immediately never immediately
|
||||
@@ -90,7 +90,6 @@ Name and seat visible on scanner yes
|
||||
Order-specific check-in attention flag yes yes yes (except directly after sale) n/a no
|
||||
Ticket search by order code or name yes yes yes (except directly after sale) no no
|
||||
Check-in statistics on scanner yes yes mostly accurate no no
|
||||
Support for add-on check-in with main ticket yes yes yes (except directly after sale) no no
|
||||
=============================================== =================================== =================================== =================================== ================================= =====================================
|
||||
|
||||
.. _EdDSA: https://en.wikipedia.org/wiki/EdDSA#Ed25519
|
||||
|
||||
@@ -135,10 +135,6 @@ Alternatively, you can select one or more categories to be shown::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" categories="12,25"></pretix-widget>
|
||||
|
||||
Or variation IDs::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" variations="15,2,68"></pretix-widget>
|
||||
|
||||
Multi-event selection
|
||||
---------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,8 +14,6 @@ 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/paypal2/templates *
|
||||
recursive-include pretix/plugins/paypal2/static *
|
||||
recursive-include pretix/plugins/pretixdroid/templates *
|
||||
recursive-include pretix/plugins/pretixdroid/static *
|
||||
recursive-include pretix/plugins/sendmail/templates *
|
||||
|
||||
@@ -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.13.0.dev0"
|
||||
__version__ = "4.9.0.dev0"
|
||||
|
||||
@@ -45,7 +45,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
allowlist = (
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
@@ -68,8 +67,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
)
|
||||
|
||||
|
||||
@@ -79,7 +76,6 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
|
||||
allowlist = (
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
@@ -100,8 +96,6 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
)
|
||||
|
||||
|
||||
@@ -111,7 +105,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
allowlist = (
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
@@ -133,8 +126,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
)
|
||||
|
||||
|
||||
@@ -144,7 +135,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
allowlist = (
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
@@ -161,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'),
|
||||
@@ -200,8 +188,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'plugins:pretix_seating:event.plan'),
|
||||
('GET', 'plugins:pretix_seating:selection.simple'),
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import os
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.files import File
|
||||
from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy
|
||||
@@ -34,19 +33,13 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
|
||||
)
|
||||
from pretix.base.models import Quota, Seat, Voucher
|
||||
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
|
||||
@@ -61,13 +54,11 @@ 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)
|
||||
voucher = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel', 'voucher')
|
||||
'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel')
|
||||
|
||||
def create(self, validated_data):
|
||||
answers_data = validated_data.pop('answers')
|
||||
@@ -127,53 +118,15 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError('The specified seat ID is not unique.')
|
||||
else:
|
||||
validated_data['seat'] = seat
|
||||
if not seat.is_available(
|
||||
sales_channel=validated_data.get('sales_channel', 'web'),
|
||||
distance_ignore_cart_id=validated_data['cart_id'],
|
||||
):
|
||||
raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
|
||||
elif seated:
|
||||
raise ValidationError('The specified product requires to choose a seat.')
|
||||
|
||||
if validated_data.get('voucher'):
|
||||
try:
|
||||
voucher = self.context['event'].vouchers.get(code__iexact=validated_data.get('voucher'))
|
||||
except Voucher.DoesNotExist:
|
||||
raise ValidationError('The specified voucher does not exist.')
|
||||
|
||||
if voucher and not voucher.applies_to(validated_data.get('item'), validated_data.get('variation')):
|
||||
raise ValidationError('The specified voucher is not valid for the given item and variation.')
|
||||
|
||||
if voucher and voucher.seat and voucher.seat != validated_data.get('seat'):
|
||||
raise ValidationError('The specified voucher is not valid for this seat.')
|
||||
|
||||
if voucher and voucher.subevent_id and (not validated_data.get('subevent') or voucher.subevent_id != validated_data['subevent'].pk):
|
||||
raise ValidationError('The specified voucher is not valid for this subevent.')
|
||||
|
||||
if voucher.valid_until is not None and voucher.valid_until < now():
|
||||
raise ValidationError('The specified voucher is expired.')
|
||||
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=voucher) & Q(event=self.context['event']) & Q(expires__gte=now())
|
||||
)
|
||||
cart_count = redeemed_in_carts.count()
|
||||
v_avail = voucher.max_usages - voucher.redeemed - cart_count
|
||||
if v_avail < 1:
|
||||
raise ValidationError('The specified voucher has already been used the maximum number of times.')
|
||||
|
||||
validated_data['voucher'] = voucher
|
||||
|
||||
if validated_data.get('seat'):
|
||||
if not validated_data['seat'].is_available(
|
||||
sales_channel=validated_data.get('sales_channel', 'web'),
|
||||
distance_ignore_cart_id=validated_data['cart_id'],
|
||||
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
|
||||
):
|
||||
raise ValidationError(
|
||||
gettext_lazy('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name))
|
||||
|
||||
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:
|
||||
|
||||
@@ -26,7 +26,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from pretix.api.serializers.event import SubEventSerializer
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.models import Checkin, CheckinList
|
||||
from pretix.base.models import CheckinList
|
||||
|
||||
|
||||
class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
@@ -37,7 +37,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
model = CheckinList
|
||||
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
|
||||
'include_pending', 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit',
|
||||
'rules', 'exit_all_at', 'addon_match')
|
||||
'rules', 'exit_all_at')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -78,31 +78,3 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
CheckinList.validate_rules(data.get('rules'))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class CheckinRPCRedeemInputSerializer(serializers.Serializer):
|
||||
lists = serializers.PrimaryKeyRelatedField(required=True, many=True, queryset=CheckinList.objects.none())
|
||||
secret = serializers.CharField(required=True, allow_null=False)
|
||||
force = serializers.BooleanField(default=False, required=False)
|
||||
type = serializers.ChoiceField(choices=Checkin.CHECKIN_TYPES, default=Checkin.TYPE_ENTRY)
|
||||
ignore_unpaid = serializers.BooleanField(default=False, required=False)
|
||||
questions_supported = serializers.BooleanField(default=True, required=False)
|
||||
nonce = serializers.CharField(required=False, allow_null=True)
|
||||
datetime = serializers.DateTimeField(required=False, allow_null=True)
|
||||
answers = serializers.JSONField(required=False, allow_null=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event')
|
||||
|
||||
|
||||
class MiniCheckinListSerializer(I18nAwareModelSerializer):
|
||||
event = serializers.SlugRelatedField(slug_field='slug', read_only=True)
|
||||
subevent = serializers.PrimaryKeyRelatedField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CheckinList
|
||||
fields = ('id', 'name', 'event', 'subevent', 'include_pending')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -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
|
||||
@@ -54,10 +54,7 @@ from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
||||
from pretix.base.services.seating import (
|
||||
SeatProtected, generate_seats, validate_plan_change,
|
||||
)
|
||||
from pretix.base.settings import (
|
||||
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
|
||||
LazyI18nStringList, validate_event_settings,
|
||||
)
|
||||
from pretix.base.settings import LazyI18nStringList, validate_event_settings
|
||||
from pretix.base.signals import api_event_settings_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -765,7 +762,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'cancel_allow_user_paid_adjust_fees_step',
|
||||
'cancel_allow_user_paid_refund_as_giftcard',
|
||||
'cancel_allow_user_paid_require_approval',
|
||||
'cancel_allow_user_paid_require_approval_fee_unknown',
|
||||
'change_allow_user_variation',
|
||||
'change_allow_user_addons',
|
||||
'change_allow_user_until',
|
||||
@@ -780,7 +776,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'logo_image_large',
|
||||
'logo_show_title',
|
||||
'og_image',
|
||||
'name_scheme',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -846,25 +841,4 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'invoice_address_from_country',
|
||||
'invoice_address_from_tax_id',
|
||||
'invoice_address_from_vat_id',
|
||||
'name_scheme',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['_name_scheme_fields'] = serializers.JSONField(
|
||||
read_only=True,
|
||||
default=[{"key": k, "label": str(v), "weight": w} for k, v, w, *__ in PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)['fields']]
|
||||
)
|
||||
self.fields['_name_scheme_salutations'] = serializers.JSONField(
|
||||
read_only=True,
|
||||
default=[{"key": k, "label": str(v)} for k, v in PERSON_NAME_SALUTATIONS]
|
||||
)
|
||||
self.fields['_name_scheme_titles'] = serializers.JSONField(
|
||||
read_only=True,
|
||||
default=(
|
||||
[{"key": k, "label": k}
|
||||
for k in PERSON_NAME_TITLE_GROUPS.get(self.event.settings.name_scheme_titles)[1]]
|
||||
if self.event.settings.name_scheme_titles
|
||||
else []
|
||||
)
|
||||
)
|
||||
|
||||
@@ -184,9 +184,8 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.read_only:
|
||||
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
|
||||
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
|
||||
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
|
||||
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -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
|
||||
@@ -92,7 +90,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country',
|
||||
'state', 'vat_id', 'vat_id_validated', 'custom_field', 'internal_reference')
|
||||
'state', 'vat_id', 'vat_id_validated', 'internal_reference')
|
||||
read_only_fields = ('last_modified',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -341,10 +339,10 @@ class PdfDataSerializer(serializers.Field):
|
||||
# we serialize a list.
|
||||
|
||||
if 'vars' not in self.context:
|
||||
self.context['vars'] = get_variables(self.context['event'])
|
||||
self.context['vars'] = get_variables(self.context['request'].event)
|
||||
|
||||
if 'vars_images' not in self.context:
|
||||
self.context['vars_images'] = get_images(self.context['event'])
|
||||
self.context['vars_images'] = get_images(self.context['request'].event)
|
||||
|
||||
for k, f in self.context['vars'].items():
|
||||
try:
|
||||
@@ -422,14 +420,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
request = self.context.get('request')
|
||||
pdf_data_forbidden = (
|
||||
# We check this based on permission if we are on /events/…/orders/ or /events/…/orderpositions/ or
|
||||
# /events/…/checkinlists/…/positions/
|
||||
# We're unable to check this on this level if we're on /checkinrpc/, in which case we rely on the view
|
||||
# layer to not set pdf_data=true in the first place.
|
||||
request and hasattr(request, 'event') and 'can_view_orders' not in request.eventpermset
|
||||
)
|
||||
if ('pdf_data' in self.context and not self.context['pdf_data']) or pdf_data_forbidden:
|
||||
if request and (not request.query_params.get('pdf_data', 'false') == 'true' or 'can_view_orders' not in request.eventpermset):
|
||||
self.fields.pop('pdf_data', None)
|
||||
|
||||
def validate(self, data):
|
||||
@@ -488,13 +479,13 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if 'subevent' in self.context['expand']:
|
||||
if 'subevent' in self.context['request'].query_params.getlist('expand'):
|
||||
self.fields['subevent'] = SubEventSerializer(read_only=True)
|
||||
|
||||
if 'item' in self.context['expand']:
|
||||
if 'item' in self.context['request'].query_params.getlist('expand'):
|
||||
self.fields['item'] = ItemSerializer(read_only=True, context=self.context)
|
||||
|
||||
if 'variation' in self.context['expand']:
|
||||
if 'variation' in self.context['request'].query_params.getlist('expand'):
|
||||
self.fields['variation'] = InlineItemVariationSerializer(read_only=True)
|
||||
|
||||
|
||||
@@ -597,10 +588,10 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.context['pdf_data']:
|
||||
if not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
|
||||
self.fields['positions'].child.fields.pop('pdf_data', None)
|
||||
|
||||
for exclude_field in self.context['exclude']:
|
||||
for exclude_field in self.context['request'].query_params.getlist('exclude'):
|
||||
p = exclude_field.split('.')
|
||||
if p[0] in self.fields:
|
||||
if len(p) == 1:
|
||||
@@ -984,18 +975,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
else:
|
||||
ia = None
|
||||
|
||||
lock_required = False
|
||||
for pos_data in positions_data:
|
||||
pos_data['_quotas'] = list(
|
||||
pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
if pos_data.get('variation')
|
||||
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
)
|
||||
if pos_data.get('voucher') or pos_data.get('seat') or any(q.size is not None for q in pos_data['_quotas']):
|
||||
lock_required = True
|
||||
|
||||
lockfn = self.context['event'].lock
|
||||
if simulate or not lock_required:
|
||||
if simulate:
|
||||
lockfn = NoLockManager
|
||||
with lockfn() as now_dt:
|
||||
free_seats = set()
|
||||
@@ -1059,18 +1040,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
|
||||
@@ -1119,7 +1111,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
|
||||
new_quotas = pos_data['_quotas']
|
||||
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
if pos_data.get('variation')
|
||||
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
|
||||
if len(new_quotas) == 0:
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(pos_data.get('item'))
|
||||
@@ -1164,85 +1158,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' and k != '_quotas'})
|
||||
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)
|
||||
@@ -1255,7 +1216,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)
|
||||
@@ -1278,6 +1238,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:
|
||||
|
||||
@@ -396,6 +396,7 @@ class OrderChangeOperationSerializer(serializers.Serializer):
|
||||
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'])
|
||||
|
||||
@@ -71,16 +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')
|
||||
|
||||
|
||||
class CustomerCreateSerializer(CustomerSerializer):
|
||||
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = CustomerSerializer.Meta.fields + ('send_email',)
|
||||
fields = ('identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
|
||||
'locale', 'last_modified')
|
||||
|
||||
|
||||
class MembershipTypeSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -1,36 +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 rest_framework import serializers
|
||||
|
||||
|
||||
class ShredderSerializer(serializers.Serializer):
|
||||
identifier = serializers.CharField()
|
||||
verbose_name = serializers.CharField()
|
||||
|
||||
|
||||
class JobRunSerializer(serializers.Serializer):
|
||||
shredders = serializers.MultipleChoiceField(choices=[])
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
shredders = kwargs.pop('shredders')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['shredders'].choices = ((k.identifier, k.identifier) for k in shredders)
|
||||
@@ -41,9 +41,8 @@ from rest_framework import routers
|
||||
from pretix.api.views import cart
|
||||
|
||||
from .views import (
|
||||
checkin, device, discount, event, exporters, idempotency, item, oauth,
|
||||
order, organizer, shredders, upload, user, version, voucher, waitinglist,
|
||||
webhooks,
|
||||
checkin, device, event, exporters, item, oauth, order, organizer, upload,
|
||||
user, version, voucher, waitinglist, webhooks,
|
||||
)
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
@@ -73,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)
|
||||
@@ -85,7 +83,6 @@ event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
||||
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||
event_router.register(r'cartpositions', cart.CartPositionViewSet)
|
||||
event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters')
|
||||
event_router.register(r'shredders', shredders.EventShreddersViewSet, basename='shredders')
|
||||
|
||||
checkinlist_router = routers.DefaultRouter()
|
||||
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')
|
||||
@@ -114,10 +111,6 @@ for app in apps.get_app_configs():
|
||||
urlpatterns = [
|
||||
re_path(r'^', include(router.urls)),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/', include(orga_router.urls)),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/redeem/$', checkin.CheckinRPCRedeemView.as_view(),
|
||||
name="checkinrpc.redeem"),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/search/$', checkin.CheckinRPCSearchView.as_view(),
|
||||
name="checkinrpc.search"),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/settings/$', organizer.OrganizerSettingsView.as_view(),
|
||||
name="organizer.settings"),
|
||||
re_path(r'^organizers/(?P<organizer>[^/]+)/giftcards/(?P<giftcard>[^/]+)/', include(giftcard_router.urls)),
|
||||
@@ -139,7 +132,6 @@ urlpatterns = [
|
||||
re_path(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
|
||||
re_path(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
|
||||
re_path(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
|
||||
re_path(r"^idempotency_query$", idempotency.IdempotencyQueryView.as_view(), name="idempotency.query"),
|
||||
re_path(r"^upload$", upload.UploadView.as_view(), name="upload"),
|
||||
re_path(r"^me$", user.MeView.as_view(), name="user.me"),
|
||||
re_path(r"^version$", version.VersionView.as_view(), name="version"),
|
||||
|
||||
@@ -19,16 +19,12 @@
|
||||
# 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 operator
|
||||
from functools import reduce
|
||||
|
||||
import django_filters
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as BaseValidationError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Count, Exists, F, Max, OrderBy, OuterRef, Prefetch, Q, Subquery,
|
||||
prefetch_related_objects,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import Http404
|
||||
@@ -38,18 +34,13 @@ from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from packaging.version import parse
|
||||
from rest_framework import views, viewsets
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.fields import DateTimeField
|
||||
from rest_framework.generics import ListAPIView
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.checkin import (
|
||||
CheckinListSerializer, CheckinRPCRedeemInputSerializer,
|
||||
MiniCheckinListSerializer,
|
||||
)
|
||||
from pretix.api.serializers.checkin import CheckinListSerializer
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
CheckinListOrderPositionSerializer, FailedCheckinSerializer,
|
||||
@@ -59,7 +50,7 @@ from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
Question, RevokedTicketSecret, TeamAPIToken,
|
||||
Question,
|
||||
)
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
|
||||
@@ -166,7 +157,6 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
list=self.get_object(),
|
||||
successful=False,
|
||||
forced=True,
|
||||
force_sent=True,
|
||||
device=self.request.auth if isinstance(self.request.auth, Device) else None,
|
||||
gate=self.request.auth.gate if isinstance(self.request.auth, Device) else None,
|
||||
**kwargs,
|
||||
@@ -274,399 +264,6 @@ with scopes_disabled():
|
||||
return queryset.filter(SQLLogic(self.checkinlist).apply(self.checkinlist.rules))
|
||||
|
||||
|
||||
def _handle_file_upload(data, user, auth):
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
|
||||
file__isnull=False,
|
||||
pk=data[len("file:"):],
|
||||
)
|
||||
except (ValidationError, BaseValidationError, IndexError): # invalid uuid
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
except CachedFile.DoesNotExist:
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
|
||||
allowed_types = (
|
||||
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
|
||||
)
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
return cf.file
|
||||
|
||||
|
||||
def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_products=False, pdf_data=False, expand=None):
|
||||
list_by_event = {cl.event_id: cl for cl in checkinlists}
|
||||
if not checkinlists:
|
||||
raise ValidationError('No check-in list passed.')
|
||||
if len(list_by_event) != len(checkinlists):
|
||||
raise ValidationError('Selecting two check-in lists from the same event is unsupported.')
|
||||
|
||||
cqs = Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id__in=[cl.pk for cl in checkinlists]
|
||||
).order_by().values('position_id').annotate(
|
||||
m=Max('datetime')
|
||||
).values('m')
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event__in=list_by_event.keys(),
|
||||
).annotate(
|
||||
last_checked_in=Subquery(cqs)
|
||||
).prefetch_related('order__event', 'order__event__organizer')
|
||||
|
||||
lists_qs = []
|
||||
for checkinlist in checkinlists:
|
||||
list_q = Q(order__event_id=checkinlist.event_id)
|
||||
if checkinlist.subevent:
|
||||
list_q &= Q(subevent=checkinlist.subevent)
|
||||
if not ignore_status:
|
||||
list_q &= Q(order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if checkinlist.include_pending else [Order.STATUS_PAID])
|
||||
if not checkinlist.all_products and not ignore_products:
|
||||
list_q &= Q(item__in=checkinlist.limit_products.values_list('id', flat=True))
|
||||
lists_qs.append(list_q)
|
||||
|
||||
qs = qs.filter(reduce(operator.or_, lists_qs))
|
||||
|
||||
if pdf_data:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists])
|
||||
),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
Event.objects.select_related('organizer')
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists])
|
||||
),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
|
||||
|
||||
if expand and 'subevent' in expand:
|
||||
qs = qs.prefetch_related(
|
||||
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
|
||||
'subevent__seat_category_mappings', 'subevent__meta_values'
|
||||
)
|
||||
|
||||
if expand and 'item' in expand:
|
||||
qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values',
|
||||
'item__variations').select_related('item__tax_rule')
|
||||
|
||||
if expand and 'variation' in expand:
|
||||
qs = qs.prefetch_related('variation')
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
|
||||
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
|
||||
legacy_url_support=False):
|
||||
if not checkinlists:
|
||||
raise ValidationError('No check-in list passed.')
|
||||
|
||||
list_by_event = {cl.event_id: cl for cl in checkinlists}
|
||||
prefetch_related_objects([cl for cl in checkinlists if not cl.all_products], 'limit_products')
|
||||
|
||||
device = auth if isinstance(auth, Device) else None
|
||||
gate = auth.gate if isinstance(auth, Device) else None
|
||||
|
||||
context = {
|
||||
'request': request,
|
||||
'expand': expand,
|
||||
}
|
||||
|
||||
def _make_context(context, event):
|
||||
return {
|
||||
**context,
|
||||
'event': op.order.event,
|
||||
'pdf_data': pdf_data and (
|
||||
user if user and user.is_authenticated else auth
|
||||
).has_event_permission(request.organizer, event, 'can_view_orders', request),
|
||||
}
|
||||
|
||||
common_checkin_args = dict(
|
||||
raw_barcode=raw_barcode,
|
||||
type=checkin_type,
|
||||
list=checkinlists[0],
|
||||
datetime=datetime,
|
||||
device=device,
|
||||
gate=gate,
|
||||
nonce=nonce,
|
||||
forced=force,
|
||||
)
|
||||
raw_barcode_for_checkin = None
|
||||
from_revoked_secret = False
|
||||
|
||||
# 1. Gather a list of positions that could be the one we looking fore, either from their ID, secret or
|
||||
# parent secret
|
||||
queryset = _checkin_list_position_queryset(checkinlists, pdf_data=pdf_data, ignore_status=True, ignore_products=True).order_by(
|
||||
F('addon_to').asc(nulls_first=True)
|
||||
)
|
||||
|
||||
q = Q(secret=raw_barcode)
|
||||
if any(cl.addon_match for cl in checkinlists):
|
||||
q |= Q(addon_to__secret=raw_barcode)
|
||||
if raw_barcode.isnumeric() and not untrusted_input and legacy_url_support:
|
||||
q |= Q(pk=raw_barcode)
|
||||
|
||||
op_candidates = list(queryset.filter(q))
|
||||
if not op_candidates and '+' in raw_barcode and legacy_url_support:
|
||||
# In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'.
|
||||
# `id`, however, is part of a path where this technically is not allowed. Old versions of our
|
||||
# scan apps still do it, so we try work around it!
|
||||
q = Q(secret=raw_barcode.replace('+', ' '))
|
||||
if any(cl.addon_match for cl in checkinlists):
|
||||
q |= Q(addon_to__secret=raw_barcode.replace('+', ' '))
|
||||
op_candidates = list(queryset.filter(q))
|
||||
|
||||
# 2. Handle the "nothing found" case: Either it's really a bogus secret that we don't know (-> error), or it
|
||||
# might be a revoked one that we actually know (-> error, but with better error message and logging and
|
||||
# with respecting the force option).
|
||||
if not op_candidates:
|
||||
revoked_matches = list(RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode))
|
||||
if len(revoked_matches) == 0:
|
||||
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': checkinlists[0].pk,
|
||||
'barcode': raw_barcode,
|
||||
'searched_lists': [cl.pk for cl in checkinlists]
|
||||
}, user=user, auth=auth)
|
||||
|
||||
for cl in checkinlists:
|
||||
for k, s in cl.event.ticket_secret_generators.items():
|
||||
try:
|
||||
parsed = s.parse_secret(raw_barcode)
|
||||
common_checkin_args.update({
|
||||
'raw_item': parsed.item,
|
||||
'raw_variation': parsed.variation,
|
||||
'raw_subevent': parsed.subevent,
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
Checkin.objects.create(
|
||||
position=None,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_INVALID,
|
||||
**common_checkin_args,
|
||||
)
|
||||
|
||||
if force and legacy_url_support and isinstance(auth, Device):
|
||||
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
|
||||
# valid at the time but no longer exists at time of upload, the device would retry to
|
||||
# upload the same scan over and over again. Since we can't update all devices quickly,
|
||||
# here's a dirty workaround to make it stop.
|
||||
try:
|
||||
brand = auth.software_brand
|
||||
ver = parse(auth.software_version)
|
||||
legacy_mode = (
|
||||
(brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or
|
||||
(brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or
|
||||
(brand == 'pretixSCAN' and ver < parse('1.11.2'))
|
||||
)
|
||||
if legacy_mode:
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_ALREADY_REDEEMED,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'__warning': 'Compatibility hack active due to detected old pretixSCAN version',
|
||||
}, status=400)
|
||||
except: # we don't care e.g. about invalid version numbers
|
||||
pass
|
||||
|
||||
return Response({
|
||||
'detail': 'Not found.', # for backwards compatibility
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_INVALID,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'list': MiniCheckinListSerializer(checkinlists[0]).data,
|
||||
}, status=404)
|
||||
elif revoked_matches and force:
|
||||
op_candidates = [revoked_matches[0].position]
|
||||
if list_by_event[revoked_matches[0].event_id].addon_match:
|
||||
op_candidates += list(revoked_matches[0].position.addons.all())
|
||||
raw_barcode_for_checkin = raw_barcode
|
||||
from_revoked_secret = True
|
||||
else:
|
||||
op = revoked_matches[0].position
|
||||
op.order.log_action('pretix.event.checkin.revoked', data={
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': list_by_event[revoked_matches[0].event_id].pk,
|
||||
'barcode': raw_barcode
|
||||
}, user=user, auth=auth)
|
||||
common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_REVOKED,
|
||||
**common_checkin_args
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_REVOKED,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, revoked_matches[0].event)).data,
|
||||
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
|
||||
}, status=400)
|
||||
|
||||
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
|
||||
# key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
|
||||
# which add-on has the right product.
|
||||
if len(op_candidates) > 1:
|
||||
op_candidates_matching_product = [
|
||||
op for op in op_candidates
|
||||
if (
|
||||
(list_by_event[op.order.event_id].addon_match or op.secret == raw_barcode or legacy_url_support) and
|
||||
(list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()})
|
||||
)
|
||||
]
|
||||
|
||||
if len(op_candidates_matching_product) == 0:
|
||||
# None of the found add-ons has the correct product, too bad! We could just error out here, but
|
||||
# instead we just continue with *any* product and have it rejected by the check in perform_checkin.
|
||||
# This has the advantage of a better error message.
|
||||
op_candidates = [op_candidates[0]]
|
||||
elif len(op_candidates_matching_product) > 1:
|
||||
# It's still ambiguous, we'll error out.
|
||||
# We choose the first match (regardless of product) for the logging since it's most likely to be the
|
||||
# base product according to our order_by above.
|
||||
op = op_candidates[0]
|
||||
op.order.log_action('pretix.event.checkin.denied', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'errorcode': Checkin.REASON_AMBIGUOUS,
|
||||
'reason_explanation': None,
|
||||
'force': force,
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': list_by_event[op.order.event_id].pk,
|
||||
}, user=user, auth=auth)
|
||||
common_checkin_args['list'] = list_by_event[op.order.event_id]
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_AMBIGUOUS,
|
||||
error_explanation=None,
|
||||
**common_checkin_args,
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_AMBIGUOUS,
|
||||
'reason_explanation': None,
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
|
||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||
}, status=400)
|
||||
else:
|
||||
op_candidates = op_candidates_matching_product
|
||||
|
||||
op = op_candidates[0]
|
||||
common_checkin_args['list'] = list_by_event[op.order.event_id]
|
||||
|
||||
# 5. Pre-validate all incoming answers, handle file upload
|
||||
given_answers = {}
|
||||
if answers_data:
|
||||
for q in op.item.questions.filter(ask_during_checkin=True):
|
||||
if str(q.pk) in answers_data:
|
||||
try:
|
||||
if q.type == Question.TYPE_FILE:
|
||||
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
|
||||
else:
|
||||
given_answers[q] = q.clean_answer(answers_data[str(q.pk)])
|
||||
except (ValidationError, BaseValidationError):
|
||||
pass
|
||||
|
||||
# 6. Pass to our actual check-in logic
|
||||
with language(op.order.event.settings.locale):
|
||||
try:
|
||||
perform_checkin(
|
||||
op=op,
|
||||
clist=list_by_event[op.order.event_id],
|
||||
given_answers=given_answers,
|
||||
force=force,
|
||||
ignore_unpaid=ignore_unpaid,
|
||||
nonce=nonce,
|
||||
datetime=datetime,
|
||||
questions_supported=questions_supported,
|
||||
canceled_supported=canceled_supported,
|
||||
user=user,
|
||||
auth=auth,
|
||||
type=checkin_type,
|
||||
raw_barcode=raw_barcode_for_checkin,
|
||||
from_revoked_secret=from_revoked_secret,
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
'status': 'incomplete',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
|
||||
'questions': [
|
||||
QuestionSerializer(q).data for q in e.questions
|
||||
],
|
||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||
}, status=400)
|
||||
except CheckInError as e:
|
||||
op.order.log_action('pretix.event.checkin.denied', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'errorcode': e.code,
|
||||
'reason_explanation': e.reason,
|
||||
'force': force,
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': list_by_event[op.order.event_id].pk,
|
||||
}, user=user, auth=auth)
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=e.code,
|
||||
error_explanation=e.reason,
|
||||
**common_checkin_args,
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': e.code,
|
||||
'reason_explanation': e.reason,
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
|
||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||
}, status=400)
|
||||
else:
|
||||
return Response({
|
||||
'status': 'ok',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
|
||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||
}, status=201)
|
||||
|
||||
|
||||
class ExtendedBackend(DjangoFilterBackend):
|
||||
def get_filterset_kwargs(self, request, queryset, view):
|
||||
kwargs = super().get_filterset_kwargs(request, queryset, view)
|
||||
@@ -682,7 +279,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = CheckinListOrderPositionSerializer
|
||||
queryset = OrderPosition.all.none()
|
||||
filter_backends = (ExtendedBackend, RichOrderingFilter)
|
||||
ordering = (F('attendee_name_cached').asc(nulls_last=True), 'positionid')
|
||||
ordering = ('attendee_name_cached', 'positionid')
|
||||
ordering_fields = (
|
||||
'order__code', 'order__datetime', 'positionid', 'attendee_name',
|
||||
'last_checked_in', 'order__email',
|
||||
@@ -711,8 +308,6 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['expand'] = self.request.query_params.getlist('expand')
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
|
||||
return ctx
|
||||
|
||||
def get_filterset_kwargs(self):
|
||||
@@ -723,18 +318,80 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@cached_property
|
||||
def checkinlist(self):
|
||||
try:
|
||||
return get_object_or_404(self.request.event.checkin_lists, pk=self.kwargs.get("list"))
|
||||
return get_object_or_404(CheckinList, event=self.request.event, pk=self.kwargs.get("list"))
|
||||
except ValueError:
|
||||
raise Http404()
|
||||
|
||||
def get_queryset(self, ignore_status=False, ignore_products=False):
|
||||
qs = _checkin_list_position_queryset(
|
||||
[self.checkinlist],
|
||||
ignore_status=self.request.query_params.get('ignore_status', 'false') == 'true' or ignore_status,
|
||||
ignore_products=ignore_products,
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
|
||||
expand=self.request.query_params.getlist('expand'),
|
||||
)
|
||||
cqs = Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=self.checkinlist.pk
|
||||
).order_by().values('position_id').annotate(
|
||||
m=Max('datetime')
|
||||
).values('m')
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.request.event,
|
||||
).annotate(
|
||||
last_checked_in=Subquery(cqs)
|
||||
).prefetch_related('order__event', 'order__event__organizer')
|
||||
if self.checkinlist.subevent:
|
||||
qs = qs.filter(
|
||||
subevent=self.checkinlist.subevent
|
||||
)
|
||||
|
||||
if self.request.query_params.get('ignore_status', 'false') != 'true' and not ignore_status:
|
||||
qs = qs.filter(
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.checkinlist.include_pending else [Order.STATUS_PAID]
|
||||
)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
Event.objects.select_related('organizer')
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
|
||||
|
||||
if not self.checkinlist.all_products and not ignore_products:
|
||||
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
|
||||
|
||||
if 'subevent' in self.request.query_params.getlist('expand'):
|
||||
qs = qs.prefetch_related(
|
||||
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
|
||||
'subevent__seat_category_mappings', 'subevent__meta_values'
|
||||
)
|
||||
|
||||
if 'item' in self.request.query_params.getlist('expand'):
|
||||
qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values', 'item__variations').select_related('item__tax_rule')
|
||||
|
||||
if 'variation' in self.request.query_params.getlist('expand'):
|
||||
qs = qs.prefetch_related('variation')
|
||||
|
||||
if 'pk' not in self.request.resolver_match.kwargs and 'can_view_orders' not in self.request.eventpermset \
|
||||
and len(self.request.query_params.get('search', '')) < 3:
|
||||
@@ -745,153 +402,215 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@action(detail=False, methods=['POST'], url_name='redeem', url_path='(?P<pk>.*)/redeem')
|
||||
def redeem(self, *args, **kwargs):
|
||||
force = bool(self.request.data.get('force', False))
|
||||
checkin_type = self.request.data.get('type', None) or Checkin.TYPE_ENTRY
|
||||
if checkin_type not in dict(Checkin.CHECKIN_TYPES):
|
||||
type = self.request.data.get('type', None) or Checkin.TYPE_ENTRY
|
||||
if type not in dict(Checkin.CHECKIN_TYPES):
|
||||
raise ValidationError("Invalid check-in type.")
|
||||
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
|
||||
nonce = self.request.data.get('nonce')
|
||||
untrusted_input = (
|
||||
self.request.GET.get('untrusted_input', '') not in ('0', 'false', 'False', '')
|
||||
or (isinstance(self.request.auth, Device) and 'pretixscan' in (self.request.auth.software_brand or '').lower())
|
||||
)
|
||||
|
||||
if 'datetime' in self.request.data:
|
||||
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
|
||||
else:
|
||||
dt = now()
|
||||
|
||||
answers_data = self.request.data.get('answers')
|
||||
return _redeem_process(
|
||||
checkinlists=[self.checkinlist],
|
||||
raw_barcode=kwargs['pk'],
|
||||
answers_data=answers_data,
|
||||
common_checkin_args = dict(
|
||||
raw_barcode=self.kwargs['pk'],
|
||||
type=type,
|
||||
list=self.checkinlist,
|
||||
datetime=dt,
|
||||
force=force,
|
||||
checkin_type=checkin_type,
|
||||
ignore_unpaid=ignore_unpaid,
|
||||
device=self.request.auth if isinstance(self.request.auth, Device) else None,
|
||||
gate=self.request.auth.gate if isinstance(self.request.auth, Device) else None,
|
||||
nonce=nonce,
|
||||
untrusted_input=untrusted_input,
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
expand=self.request.query_params.getlist('expand'),
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
|
||||
questions_supported=self.request.data.get('questions_supported', True),
|
||||
canceled_supported=self.request.data.get('canceled_supported', False),
|
||||
request=self.request, # this is not clean, but we need it in the serializers for URL generation
|
||||
legacy_url_support=True,
|
||||
forced=force,
|
||||
)
|
||||
raw_barcode_for_checkin = None
|
||||
|
||||
try:
|
||||
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
|
||||
if self.kwargs['pk'].isnumeric():
|
||||
op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
||||
else:
|
||||
# In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'.
|
||||
# `id`, however, is part of a path where this technically is not allowed. Old versions of our
|
||||
# scan apps still do it, so we try work around it!
|
||||
try:
|
||||
op = queryset.get(secret=self.kwargs['pk'])
|
||||
except OrderPosition.DoesNotExist:
|
||||
op = queryset.get(secret=self.kwargs['pk'].replace('+', ' '))
|
||||
except OrderPosition.DoesNotExist:
|
||||
revoked_matches = list(self.request.event.revoked_secrets.filter(secret=self.kwargs['pk']))
|
||||
if len(revoked_matches) == 0:
|
||||
self.request.event.log_action('pretix.event.checkin.unknown', data={
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk,
|
||||
'barcode': self.kwargs['pk']
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
|
||||
class CheckinRPCRedeemView(views.APIView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders'))
|
||||
elif self.request.user.is_authenticated:
|
||||
events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
for k, s in self.request.event.ticket_secret_generators.items():
|
||||
try:
|
||||
parsed = s.parse_secret(self.kwargs['pk'])
|
||||
common_checkin_args.update({
|
||||
'raw_item': parsed.item,
|
||||
'raw_variation': parsed.variation,
|
||||
'raw_subevent': parsed.subevent,
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
Checkin.objects.create(
|
||||
position=None,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_INVALID,
|
||||
**common_checkin_args,
|
||||
)
|
||||
|
||||
if force and isinstance(self.request.auth, Device):
|
||||
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
|
||||
# valid at the time but no longer exists at time of upload, the device would retry to
|
||||
# upload the same scan over and over again. Since we can't update all devices quickly,
|
||||
# here's a dirty workaround to make it stop.
|
||||
try:
|
||||
brand = self.request.auth.software_brand
|
||||
ver = parse(self.request.auth.software_version)
|
||||
legacy_mode = (
|
||||
(brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or
|
||||
(brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or
|
||||
(brand == 'pretixSCAN' and ver < parse('1.11.2'))
|
||||
)
|
||||
if legacy_mode:
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_ALREADY_REDEEMED,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'__warning': 'Compatibility hack active due to detected old pretixSCAN version',
|
||||
}, status=400)
|
||||
except: # we don't care e.g. about invalid version numbers
|
||||
pass
|
||||
|
||||
return Response({
|
||||
'detail': 'Not found.', # for backwards compatibility
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_INVALID,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
}, status=404)
|
||||
elif revoked_matches and force:
|
||||
op = revoked_matches[0].position
|
||||
raw_barcode_for_checkin = self.kwargs['pk']
|
||||
else:
|
||||
op = revoked_matches[0].position
|
||||
op.order.log_action('pretix.event.checkin.revoked', data={
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk,
|
||||
'barcode': self.kwargs['pk']
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_REVOKED,
|
||||
**common_checkin_args
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_REVOKED,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=400)
|
||||
|
||||
given_answers = {}
|
||||
if 'answers' in self.request.data:
|
||||
aws = self.request.data.get('answers')
|
||||
for q in op.item.questions.filter(ask_during_checkin=True):
|
||||
if str(q.pk) in aws:
|
||||
try:
|
||||
if q.type == Question.TYPE_FILE:
|
||||
given_answers[q] = self._handle_file_upload(aws[str(q.pk)])
|
||||
else:
|
||||
given_answers[q] = q.clean_answer(aws[str(q.pk)])
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
with language(self.request.event.settings.locale):
|
||||
try:
|
||||
perform_checkin(
|
||||
op=op,
|
||||
clist=self.checkinlist,
|
||||
given_answers=given_answers,
|
||||
force=force,
|
||||
ignore_unpaid=ignore_unpaid,
|
||||
nonce=nonce,
|
||||
datetime=dt,
|
||||
questions_supported=self.request.data.get('questions_supported', True),
|
||||
canceled_supported=self.request.data.get('canceled_supported', False),
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
type=type,
|
||||
raw_barcode=raw_barcode_for_checkin,
|
||||
from_revoked_secret=True,
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
'status': 'incomplete',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data,
|
||||
'questions': [
|
||||
QuestionSerializer(q).data for q in e.questions
|
||||
]
|
||||
}, status=400)
|
||||
except CheckInError as e:
|
||||
op.order.log_action('pretix.event.checkin.denied', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'errorcode': e.code,
|
||||
'reason_explanation': e.reason,
|
||||
'force': force,
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=e.code,
|
||||
error_explanation=e.reason,
|
||||
**common_checkin_args,
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': e.code,
|
||||
'reason_explanation': e.reason,
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=400)
|
||||
else:
|
||||
return Response({
|
||||
'status': 'ok',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=201)
|
||||
|
||||
def _handle_file_upload(self, data):
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}',
|
||||
file__isnull=False,
|
||||
pk=data[len("file:"):],
|
||||
)
|
||||
else:
|
||||
raise ValueError("unknown authentication method")
|
||||
except (ValidationError, IndexError): # invalid uuid
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
except CachedFile.DoesNotExist:
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
|
||||
s = CheckinRPCRedeemInputSerializer(data=request.data, context={'events': events})
|
||||
s.is_valid(raise_exception=True)
|
||||
return _redeem_process(
|
||||
checkinlists=s.validated_data['lists'],
|
||||
raw_barcode=s.validated_data['secret'],
|
||||
answers_data=s.validated_data.get('answers'),
|
||||
datetime=s.validated_data.get('datetime') or now(),
|
||||
force=s.validated_data['force'],
|
||||
checkin_type=s.validated_data['type'],
|
||||
ignore_unpaid=s.validated_data['ignore_unpaid'],
|
||||
nonce=s.validated_data.get('nonce'),
|
||||
untrusted_input=True,
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
expand=self.request.query_params.getlist('expand'),
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
|
||||
questions_supported=s.validated_data['questions_supported'],
|
||||
canceled_supported=True,
|
||||
request=self.request, # this is not clean, but we need it in the serializers for URL generation
|
||||
legacy_url_support=False,
|
||||
allowed_types = (
|
||||
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
|
||||
)
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
|
||||
class CheckinRPCSearchView(ListAPIView):
|
||||
serializer_class = CheckinListOrderPositionSerializer
|
||||
queryset = OrderPosition.all.none()
|
||||
filter_backends = (ExtendedBackend, RichOrderingFilter)
|
||||
ordering = (F('attendee_name_cached').asc(nulls_last=True), 'positionid')
|
||||
ordering_fields = (
|
||||
'order__code', 'order__datetime', 'positionid', 'attendee_name',
|
||||
'last_checked_in', 'order__email',
|
||||
)
|
||||
ordering_custom = {
|
||||
'attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_first=True),
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
'-attendee_name': {
|
||||
'_order': F('display_name').desc(nulls_last=True),
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
'last_checked_in': {
|
||||
'_order': OrderBy(F('last_checked_in'), nulls_first=True),
|
||||
},
|
||||
'-last_checked_in': {
|
||||
'_order': OrderBy(F('last_checked_in'), nulls_last=True, descending=True),
|
||||
},
|
||||
}
|
||||
filterset_class = OrderPositionFilter
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['expand'] = self.request.query_params.getlist('expand')
|
||||
ctx['pdf_data'] = False
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
def lists(self):
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
events = self.request.auth.get_events_with_permission(('can_view_orders', 'can_checkin_orders'))
|
||||
elif self.request.user.is_authenticated:
|
||||
events = self.request.user.get_events_with_permission(('can_view_orders', 'can_checkin_orders'), self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
else:
|
||||
raise ValueError("unknown authentication method")
|
||||
requested_lists = [int(l) for l in self.request.query_params.getlist('list') if l.isdigit()]
|
||||
lists = list(
|
||||
CheckinList.objects.filter(event__in=events).select_related('event').filter(id__in=requested_lists)
|
||||
)
|
||||
if len(lists) != len(requested_lists):
|
||||
missing_lists = set(requested_lists) - {l.pk for l in lists}
|
||||
raise PermissionDenied("You requested lists that do not exist or that you do not have access to: " + ", ".join(str(l) for l in missing_lists))
|
||||
return lists
|
||||
|
||||
@cached_property
|
||||
def has_full_access_permission(self):
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
events = self.request.auth.get_events_with_permission('can_view_orders')
|
||||
elif self.request.user.is_authenticated:
|
||||
events = self.request.user.get_events_with_permission('can_view_orders', self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
else:
|
||||
raise ValueError("unknown authentication method")
|
||||
|
||||
full_access_lists = CheckinList.objects.filter(event__in=events).filter(id__in=[c.pk for c in self.lists]).count()
|
||||
return len(self.lists) == full_access_lists
|
||||
|
||||
def get_queryset(self, ignore_status=False, ignore_products=False):
|
||||
qs = _checkin_list_position_queryset(
|
||||
self.lists,
|
||||
ignore_status=self.request.query_params.get('ignore_status', 'false') == 'true' or ignore_status,
|
||||
ignore_products=ignore_products,
|
||||
pdf_data=self.request.query_params.get('pdf_data', 'false') == 'true',
|
||||
expand=self.request.query_params.getlist('expand'),
|
||||
)
|
||||
|
||||
if len(self.request.query_params.get('search', '')) < 3 and not self.has_full_access_permission:
|
||||
qs = qs.none()
|
||||
|
||||
return qs
|
||||
return cf.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)
|
||||
@@ -321,7 +321,6 @@ with scopes_disabled():
|
||||
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
|
||||
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
|
||||
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
|
||||
sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs')
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
@@ -354,9 +353,6 @@ with scopes_disabled():
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
def sales_channel_qs(self, queryset, name, value):
|
||||
return queryset.filter(event__sales_channels__contains=value)
|
||||
|
||||
|
||||
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = SubEventSerializer
|
||||
|
||||
@@ -1,80 +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 json
|
||||
import logging
|
||||
from hashlib import sha1
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from pretix.api.models import ApiCall
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IdempotencyQueryView(APIView):
|
||||
# Experimental feature, therefore undocumented for now
|
||||
authentication_classes = ()
|
||||
permission_classes = ()
|
||||
|
||||
def get(self, request, format=None):
|
||||
idempotency_key = request.GET.get("key")
|
||||
auth_hash_parts = '{}:{}'.format(
|
||||
request.headers.get('Authorization', ''),
|
||||
request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')
|
||||
)
|
||||
auth_hash = sha1(auth_hash_parts.encode()).hexdigest()
|
||||
if not idempotency_key:
|
||||
return JsonResponse({
|
||||
'detail': 'No idempotency key given.'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
call = ApiCall.objects.get(
|
||||
auth_hash=auth_hash,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
except ApiCall.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'detail': 'Idempotency key not seen before.'
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if call.locked:
|
||||
r = JsonResponse(
|
||||
{'detail': 'Concurrent request with idempotency key.'},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
r['Retry-After'] = 5
|
||||
return r
|
||||
|
||||
content = call.response_body
|
||||
if isinstance(content, memoryview):
|
||||
content = content.tobytes()
|
||||
r = HttpResponse(
|
||||
content=content,
|
||||
status=call.response_code,
|
||||
)
|
||||
for k, v in json.loads(call.response_headers).values():
|
||||
r[k] = v
|
||||
return r
|
||||
@@ -461,9 +461,7 @@ with scopes_disabled():
|
||||
class QuotaFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Quota
|
||||
fields = {
|
||||
'subevent': ['exact', 'in'],
|
||||
}
|
||||
fields = ['subevent']
|
||||
|
||||
|
||||
class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
|
||||
@@ -27,9 +27,7 @@ from decimal import Decimal
|
||||
import django_filters
|
||||
import pytz
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects,
|
||||
)
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models.functions import Coalesce, Concat
|
||||
from django.http import FileResponse, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -63,10 +61,9 @@ from pretix.api.serializers.orderchange import (
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue,
|
||||
Invoice, InvoiceAddress, ItemMetaValue, Order, OrderFee, OrderPayment,
|
||||
OrderPosition, OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule,
|
||||
TeamAPIToken, generate_secret,
|
||||
CachedCombinedTicket, CachedTicket, Checkin, Device, Event, Invoice,
|
||||
InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
|
||||
Quota, SubEvent, TaxRule, TeamAPIToken, generate_secret,
|
||||
)
|
||||
from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret
|
||||
from pretix.base.payment import PaymentException
|
||||
@@ -188,8 +185,6 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
|
||||
ctx['exclude'] = self.request.query_params.getlist('exclude')
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -207,49 +202,36 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
if 'invoice_address' not in self.request.GET.getlist('exclude'):
|
||||
qs = qs.select_related('invoice_address')
|
||||
|
||||
qs = qs.prefetch_related(self._positions_prefetch(self.request))
|
||||
return qs
|
||||
|
||||
def _positions_prefetch(self, request):
|
||||
if request.query_params.get('include_canceled_positions', 'false') == 'true':
|
||||
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
|
||||
opq = OrderPosition.all
|
||||
else:
|
||||
opq = OrderPosition.objects
|
||||
if request.query_params.get('pdf_data', 'false') == 'true':
|
||||
prefetch_related_objects([request.organizer], 'meta_properties')
|
||||
prefetch_related_objects(
|
||||
[request.event],
|
||||
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'), to_attr='meta_values_cached'),
|
||||
'questions',
|
||||
'item_meta_properties',
|
||||
)
|
||||
return Prefetch(
|
||||
'positions',
|
||||
opq.all().prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
Prefetch('item', queryset=self.request.event.items.prefetch_related(
|
||||
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached')
|
||||
)),
|
||||
'variation',
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
'item__category',
|
||||
'addon_to__answers', 'addon_to__answers__options', 'addon_to__answers__question',
|
||||
Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related(
|
||||
Prefetch('meta_values', to_attr='meta_values_cached', queryset=SubEventMetaValue.objects.select_related('property'))
|
||||
)),
|
||||
Prefetch('addons', opq.select_related('item', 'variation', 'seat'))
|
||||
).select_related('seat', 'addon_to', 'addon_to__seat')
|
||||
)
|
||||
else:
|
||||
return Prefetch(
|
||||
'positions',
|
||||
opq.all().prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'item', 'variation',
|
||||
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
|
||||
'seat',
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
'positions',
|
||||
opq.all().prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item__category', 'addon_to', 'seat',
|
||||
Prefetch('addons', opq.select_related('item', 'variation', 'seat'))
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
'positions',
|
||||
opq.all().prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'item', 'variation',
|
||||
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
|
||||
'seat',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return qs
|
||||
|
||||
def _get_output_provider(self, identifier):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
@@ -279,11 +261,8 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
provider = self._get_output_provider(output)
|
||||
order = self.get_object()
|
||||
|
||||
if order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
|
||||
raise PermissionDenied("Downloads are not available for canceled or expired orders.")
|
||||
|
||||
if order.status == Order.STATUS_PENDING and not request.event.settings.ticket_download_pending:
|
||||
raise PermissionDenied("Downloads are not available for pending orders.")
|
||||
if order.status != Order.STATUS_PAID:
|
||||
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
||||
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=order, provider=provider.identifier, file__isnull=False
|
||||
@@ -637,7 +616,6 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
serializer = SimulatedOrderSerializer(order, context=serializer.context)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
else:
|
||||
prefetch_related_objects([order], self._positions_prefetch(request))
|
||||
serializer = OrderSerializer(order, context=serializer.context)
|
||||
|
||||
order.log_action(
|
||||
@@ -950,7 +928,6 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -961,49 +938,25 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
qs = qs.filter(order__event=self.request.event)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
prefetch_related_objects([self.request.organizer], 'meta_properties')
|
||||
prefetch_related_objects(
|
||||
[self.request.event],
|
||||
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'), to_attr='meta_values_cached'),
|
||||
'questions',
|
||||
'item_meta_properties',
|
||||
)
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
Prefetch('item', queryset=self.request.event.items.prefetch_related(
|
||||
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
|
||||
to_attr='meta_values_cached')
|
||||
)),
|
||||
'variation',
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
'item__category',
|
||||
'addon_to__answers', 'addon_to__answers__options', 'addon_to__answers__question',
|
||||
Prefetch('addons', qs.select_related('item', 'variation')),
|
||||
Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related(
|
||||
Prefetch('meta_values', to_attr='meta_values_cached',
|
||||
queryset=SubEventMetaValue.objects.select_related('property'))
|
||||
)),
|
||||
Prefetch('order', self.request.event.orders.select_related('invoice_address').prefetch_related(
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
Event.objects.select_related('organizer')
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
qs.prefetch_related(
|
||||
'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
Prefetch('item', queryset=self.request.event.items.prefetch_related(
|
||||
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
|
||||
to_attr='meta_values_cached')
|
||||
)),
|
||||
'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item__category',
|
||||
Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related(
|
||||
Prefetch('meta_values', to_attr='meta_values_cached',
|
||||
queryset=SubEventMetaValue.objects.select_related('property'))
|
||||
)),
|
||||
Prefetch('addons', qs.select_related('item', 'variation', 'seat'))
|
||||
).select_related('addon_to', 'seat', 'addon_to__seat')
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'addon_to', 'seat', 'addon_to__seat'
|
||||
'item', 'variation', 'item__category', 'addon_to', 'seat'
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
@@ -1166,11 +1119,8 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||
provider = self._get_output_provider(output)
|
||||
pos = self.get_object()
|
||||
|
||||
if pos.order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
|
||||
raise PermissionDenied("Downloads are not available for canceled or expired orders.")
|
||||
|
||||
if pos.order.status == Order.STATUS_PENDING and not request.event.settings.ticket_download_pending:
|
||||
raise PermissionDenied("Downloads are not available for pending orders.")
|
||||
if pos.order.status != Order.STATUS_PAID:
|
||||
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
||||
if not pos.generate_ticket:
|
||||
raise PermissionDenied("Downloads are not enabled for this product.")
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import django_filters
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
@@ -39,8 +38,8 @@ from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.organizer import (
|
||||
CustomerCreateSerializer, CustomerSerializer, DeviceSerializer,
|
||||
GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer,
|
||||
CustomerSerializer, DeviceSerializer, GiftCardSerializer,
|
||||
GiftCardTransactionSerializer, MembershipSerializer,
|
||||
MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer,
|
||||
SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer,
|
||||
TeamMemberSerializer, TeamSerializer,
|
||||
@@ -515,24 +514,15 @@ class CustomerViewSet(viewsets.ModelViewSet):
|
||||
raise MethodNotAllowed("Customers cannot be deleted.")
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer, send_email=False):
|
||||
customer = serializer.save(organizer=self.request.organizer, password=make_password(None))
|
||||
def perform_create(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
serializer.instance.log_action(
|
||||
'pretix.customer.created',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
)
|
||||
if send_email:
|
||||
customer.send_activation_mail()
|
||||
return customer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = CustomerCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer, send_email=serializer.validated_data.pop('send_email', False))
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
return inst
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
|
||||
@@ -1,240 +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 datetime import timedelta
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from pretix.api.serializers.shredders import (
|
||||
JobRunSerializer, ShredderSerializer,
|
||||
)
|
||||
from pretix.base.models import CachedFile
|
||||
from pretix.base.services.shredder import export, shred
|
||||
from pretix.base.shredder import shred_constraints
|
||||
from pretix.helpers.http import ChunkBasedFileResponse
|
||||
|
||||
|
||||
class ShreddersMixin:
|
||||
def list(self, request, *args, **kwargs):
|
||||
res = ShredderSerializer(self.shredders, many=True)
|
||||
return Response({
|
||||
"count": len(self.shredders),
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": res.data
|
||||
})
|
||||
|
||||
def get_object(self):
|
||||
instances = [e for e in self.shredders if e.identifier == self.kwargs.get('pk')]
|
||||
if not instances:
|
||||
raise Http404()
|
||||
return instances[0]
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
serializer = ShredderSerializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
||||
def download(self, *args, **kwargs):
|
||||
cf = get_object_or_404(
|
||||
CachedFile,
|
||||
id=kwargs['cfid'],
|
||||
session_key=f'api-shredder-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}'
|
||||
)
|
||||
if cf.file:
|
||||
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
||||
return resp
|
||||
elif not settings.HAS_CELERY:
|
||||
return Response(
|
||||
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
|
||||
res = AsyncResult(kwargs['asyncid'])
|
||||
if res.failed():
|
||||
if isinstance(res.info, dict) and res.info['exc_type'] in ('ShredError', 'ExportError'):
|
||||
msg = res.info['exc_message']
|
||||
else:
|
||||
msg = 'Internal error'
|
||||
return Response(
|
||||
{'status': 'failed', 'message': msg},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'status': 'running' if res.state in ('PROGRESS', 'STARTED', 'SUCCESS') else 'waiting',
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['GET'], url_name='status', url_path='status/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
||||
def status(self, *args, **kwargs):
|
||||
if settings.HAS_CELERY:
|
||||
res = AsyncResult(kwargs['asyncid'])
|
||||
if res.failed():
|
||||
if isinstance(res.info, dict) and res.info['exc_type'] in ('ShredError', 'ExportError'):
|
||||
msg = res.info['exc_message']
|
||||
else:
|
||||
msg = 'Internal error'
|
||||
return Response(
|
||||
{'status': 'failed', 'message': msg},
|
||||
status=status.HTTP_417_EXPECTATION_FAILED
|
||||
)
|
||||
elif res.successful():
|
||||
return Response(
|
||||
{'status': 'ok', 'message': 'OK'},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
try:
|
||||
CachedFile.objects.get(
|
||||
id=kwargs['cfid'],
|
||||
session_key=f'api-shredder-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}'
|
||||
)
|
||||
except CachedFile.DoesNotExist:
|
||||
return Response(
|
||||
{'status': 'gone', 'message': 'May have succeeded or timed out'},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'status': 'running' if res.state in ('PROGRESS', 'STARTED', 'SUCCESS') else 'waiting',
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['POST'], url_name='shred', url_path='shred/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
||||
def shred(self, *args, **kwargs):
|
||||
cf = get_object_or_404(
|
||||
CachedFile,
|
||||
id=kwargs['cfid'],
|
||||
session_key=f'api-shredder-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}'
|
||||
)
|
||||
if cf.file:
|
||||
async_result = self.do_shred(cf)
|
||||
url_kwargs = {
|
||||
'asyncid': str(async_result.id),
|
||||
'cfid': str(cf.id),
|
||||
}
|
||||
url_kwargs.update(self.kwargs)
|
||||
return Response({
|
||||
'status': reverse('api-v1:shredders-status', kwargs=url_kwargs, request=self.request),
|
||||
}, status=status.HTTP_202_ACCEPTED)
|
||||
elif not settings.HAS_CELERY:
|
||||
return Response(
|
||||
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
|
||||
res = AsyncResult(kwargs['asyncid'])
|
||||
if res.failed():
|
||||
if isinstance(res.info, dict) and res.info['exc_type'] in ('ShredError', 'ExportError'):
|
||||
msg = res.info['exc_message']
|
||||
else:
|
||||
msg = 'Internal error'
|
||||
return Response(
|
||||
{'status': 'failed', 'message': msg},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
'status': 'running' if res.state in ('PROGRESS', 'STARTED', 'SUCCESS') else 'waiting',
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['POST'])
|
||||
def export(self, *args, **kwargs):
|
||||
serializer = JobRunSerializer(shredders=self.shredders, data=self.request.data, **self.get_serializer_kwargs())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
cf = CachedFile(web_download=False)
|
||||
cf.date = now()
|
||||
cf.expires = now() + timedelta(hours=2)
|
||||
cf.session_key = f'api-shredder-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}'
|
||||
cf.save()
|
||||
d = serializer.data
|
||||
for k, v in d.items():
|
||||
if isinstance(v, set):
|
||||
d[k] = list(v)
|
||||
async_result = self.do_export(cf, serializer.validated_data['shredders'])
|
||||
|
||||
url_kwargs = {
|
||||
'asyncid': str(async_result.id),
|
||||
'cfid': str(cf.id),
|
||||
}
|
||||
url_kwargs.update(self.kwargs)
|
||||
return Response({
|
||||
'download': reverse('api-v1:shredders-download', kwargs=url_kwargs, request=self.request),
|
||||
'shred': reverse('api-v1:shredders-shred', kwargs=url_kwargs, request=self.request),
|
||||
}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
class EventShreddersViewSet(ShreddersMixin, viewsets.ViewSet):
|
||||
permission = 'can_change_orders'
|
||||
|
||||
def get_serializer_kwargs(self):
|
||||
return {}
|
||||
|
||||
@cached_property
|
||||
def shredders(self):
|
||||
shredders = []
|
||||
for k, v in sorted(self.request.event.get_data_shredders().items(), key=lambda s: s[1].verbose_name):
|
||||
shredders.append(v)
|
||||
return shredders
|
||||
|
||||
def do_export(self, cf, shredders):
|
||||
constr = shred_constraints(self.request.event)
|
||||
if constr:
|
||||
raise ValidationError(constr)
|
||||
|
||||
return export.apply_async(args=(
|
||||
self.request.event.id,
|
||||
list(shredders),
|
||||
f'api-shredder-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}',
|
||||
str(cf.pk)
|
||||
))
|
||||
|
||||
def do_shred(self, cf):
|
||||
constr = shred_constraints(self.request.event)
|
||||
if constr:
|
||||
raise ValidationError(constr)
|
||||
|
||||
return shred.apply_async(args=(
|
||||
self.request.event.id,
|
||||
str(cf.pk),
|
||||
True,
|
||||
))
|
||||
@@ -25,7 +25,7 @@ from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import (
|
||||
BooleanFilter, CharFilter, DjangoFilterBackend, FilterSet,
|
||||
BooleanFilter, DjangoFilterBackend, FilterSet,
|
||||
)
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import status, viewsets
|
||||
@@ -40,7 +40,6 @@ from pretix.base.models import Voucher
|
||||
with scopes_disabled():
|
||||
class VoucherFilter(FilterSet):
|
||||
active = BooleanFilter(method='filter_active')
|
||||
code = CharFilter(lookup_expr='iexact')
|
||||
|
||||
class Meta:
|
||||
model = Voucher
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -475,11 +475,8 @@ def base_placeholders(sender, **kwargs):
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_admission_time', ['event_or_subevent'],
|
||||
lambda event_or_subevent:
|
||||
date_format(event_or_subevent.date_admission.astimezone(event_or_subevent.timezone), 'TIME_FORMAT')
|
||||
if event_or_subevent.date_admission
|
||||
else '',
|
||||
lambda event: date_format(event.date_admission.astimezone(event.timezone), 'TIME_FORMAT') if event.date_admission else '',
|
||||
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'],
|
||||
|
||||
@@ -23,7 +23,6 @@ from .answers import * # noqa
|
||||
from .dekodi import * # noqa
|
||||
from .events import * # noqa
|
||||
from .invoices import * # noqa
|
||||
from .items import * # noqa
|
||||
from .json import * # noqa
|
||||
from .mail import * # noqa
|
||||
from .orderlist import * # noqa
|
||||
|
||||
@@ -1,222 +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 django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from openpyxl.styles import Alignment
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from ...helpers.safe_openpyxl import SafeCell
|
||||
from ..channels import get_all_sales_channels
|
||||
from ..exporter import ListExporter
|
||||
from ..models import ItemMetaValue
|
||||
from ..signals import register_data_exporters
|
||||
|
||||
|
||||
def _max(a1, a2):
|
||||
if a1 and a2:
|
||||
return max(a1, a2)
|
||||
return a1 or a2
|
||||
|
||||
|
||||
def _min(a1, a2):
|
||||
if a1 and a2:
|
||||
return min(a1, a2)
|
||||
return a1 or a2
|
||||
|
||||
|
||||
class ItemDataExporter(ListExporter):
|
||||
identifier = 'itemdata'
|
||||
verbose_name = _('Product data')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
locales = self.event.settings.locales
|
||||
scs = get_all_sales_channels()
|
||||
header = [
|
||||
_("Product ID"),
|
||||
_("Variation ID"),
|
||||
_("Product category"),
|
||||
_("Internal name"),
|
||||
]
|
||||
for l in locales:
|
||||
header.append(
|
||||
_("Item name") + f" ({l})"
|
||||
)
|
||||
for l in locales:
|
||||
header.append(
|
||||
_("Variation") + f" ({l})"
|
||||
)
|
||||
header += [
|
||||
_("Active"),
|
||||
_("Sales channels"),
|
||||
_("Default price"),
|
||||
_("Free price input"),
|
||||
_("Sales tax"),
|
||||
_("Is an admission ticket"),
|
||||
_("Generate tickets"),
|
||||
_("Waiting list"),
|
||||
_("Available from"),
|
||||
_("Available until"),
|
||||
_("This product can only be bought using a voucher."),
|
||||
_("This product will only be shown if a voucher matching the product is redeemed."),
|
||||
_("Buying this product requires approval"),
|
||||
_("Only sell this product as part of a bundle"),
|
||||
_("Allow product to be canceled or changed"),
|
||||
_("Minimum amount per order"),
|
||||
_("Maximum amount per order"),
|
||||
_("Requires special attention"),
|
||||
_("Original price"),
|
||||
_("This product is a gift card"),
|
||||
_("Require a valid membership"),
|
||||
_("Hide without a valid membership"),
|
||||
]
|
||||
props = list(self.event.item_meta_properties.all())
|
||||
for p in props:
|
||||
header.append(p.name)
|
||||
|
||||
if form_data["_format"] == "xlsx":
|
||||
row = []
|
||||
for h in header:
|
||||
c = SafeCell(self.__ws, value=h)
|
||||
c.alignment = Alignment(wrap_text=True, vertical='top')
|
||||
row.append(c)
|
||||
else:
|
||||
row = header
|
||||
|
||||
yield row
|
||||
|
||||
for i in self.event.items.prefetch_related(
|
||||
'variations',
|
||||
Prefetch(
|
||||
'meta_values',
|
||||
ItemMetaValue.objects.select_related('property'),
|
||||
to_attr='meta_values_cached'
|
||||
)
|
||||
).select_related('category', 'tax_rule'):
|
||||
m = i.meta_data
|
||||
vars = list(i.variations.all())
|
||||
|
||||
if vars:
|
||||
for v in vars:
|
||||
row = [
|
||||
i.pk,
|
||||
v.pk,
|
||||
str(i.category) if i.category else "",
|
||||
i.internal_name or "",
|
||||
]
|
||||
for l in locales:
|
||||
row.append(i.name.localize(l))
|
||||
for l in locales:
|
||||
row.append(v.value.localize(l))
|
||||
row += [
|
||||
_("Yes") if i.active and v.active else "",
|
||||
", ".join([str(sn.verbose_name) for s, sn in scs.items() if s in i.sales_channels and s in v.sales_channels]),
|
||||
v.default_price or i.default_price,
|
||||
_("Yes") if i.free_price else "",
|
||||
str(i.tax_rule) if i.tax_rule else "",
|
||||
_("Yes") if i.admission else "",
|
||||
_("Yes") if i.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
|
||||
_("Yes") if i.allow_waitinglist else "",
|
||||
date_format(_max(i.available_from, v.available_from).astimezone(self.timezone),
|
||||
"SHORT_DATETIME_FORMAT") if i.available_from or v.available_from else "",
|
||||
date_format(_min(i.available_until, v.available_until).astimezone(self.timezone),
|
||||
"SHORT_DATETIME_FORMAT") if i.available_until or v.available_until else "",
|
||||
_("Yes") if i.require_voucher else "",
|
||||
_("Yes") if i.hide_without_voucher or v.hide_without_voucher else "",
|
||||
_("Yes") if i.require_approval or v.require_approval else "",
|
||||
_("Yes") if i.require_bundling else "",
|
||||
_("Yes") if i.allow_cancel else "",
|
||||
i.min_per_order if i.min_per_order is not None else "",
|
||||
i.max_per_order if i.max_per_order is not None else "",
|
||||
_("Yes") if i.checkin_attention else "",
|
||||
v.original_price or i.original_price or "",
|
||||
_("Yes") if i.issue_giftcard else "",
|
||||
_("Yes") if i.require_membership or v.require_membership else "",
|
||||
_("Yes") if i.require_membership_hidden or v.require_membership_hidden else "",
|
||||
]
|
||||
row += [
|
||||
m.get(p.name, '') for p in props
|
||||
]
|
||||
yield row
|
||||
|
||||
else:
|
||||
row = [
|
||||
i.pk,
|
||||
"",
|
||||
str(i.category) if i.category else "",
|
||||
i.internal_name or "",
|
||||
]
|
||||
for l in locales:
|
||||
row.append(i.name.localize(l))
|
||||
for l in locales:
|
||||
row.append("")
|
||||
row += [
|
||||
_("Yes") if i.active else "",
|
||||
", ".join([str(sn.verbose_name) for s, sn in scs.items() if s in i.sales_channels]),
|
||||
i.default_price,
|
||||
_("Yes") if i.free_price else "",
|
||||
str(i.tax_rule) if i.tax_rule else "",
|
||||
_("Yes") if i.admission else "",
|
||||
_("Yes") if i.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
|
||||
_("Yes") if i.allow_waitinglist else "",
|
||||
date_format(i.available_from.astimezone(self.timezone),
|
||||
"SHORT_DATETIME_FORMAT") if i.available_from else "",
|
||||
date_format(i.available_until.astimezone(self.timezone),
|
||||
"SHORT_DATETIME_FORMAT") if i.available_until else "",
|
||||
_("Yes") if i.require_voucher else "",
|
||||
_("Yes") if i.hide_without_voucher else "",
|
||||
_("Yes") if i.require_approval else "",
|
||||
_("Yes") if i.require_bundling else "",
|
||||
_("Yes") if i.allow_cancel else "",
|
||||
i.min_per_order if i.min_per_order is not None else "",
|
||||
i.max_per_order if i.max_per_order is not None else "",
|
||||
_("Yes") if i.checkin_attention else "",
|
||||
i.original_price or "",
|
||||
_("Yes") if i.issue_giftcard else "",
|
||||
_("Yes") if i.require_membership else "",
|
||||
_("Yes") if i.require_membership_hidden else "",
|
||||
]
|
||||
|
||||
row += [
|
||||
m.get(p.name, '') for p in props
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_products'.format(self.events.first().organizer.slug)
|
||||
|
||||
def prepare_xlsx_sheet(self, ws):
|
||||
self.__ws = ws
|
||||
ws.freeze_panes = 'A1'
|
||||
ws.column_dimensions['C'].width = 25
|
||||
ws.column_dimensions['D'].width = 25
|
||||
for i in range(len(self.event.settings.locales)):
|
||||
ws.column_dimensions[get_column_letter(5 + 2 * i + 0)].width = 25
|
||||
ws.column_dimensions[get_column_letter(5 + 2 * i + 1)].width = 25
|
||||
ws.column_dimensions[get_column_letter(5 + 2 * len(self.event.settings.locales) + 1)].width = 25
|
||||
ws.row_dimensions[1].height = 40
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_itemdata")
|
||||
def register_itemdata_exporter(sender, **kwargs):
|
||||
return ItemDataExporter
|
||||
@@ -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__')
|
||||
|
||||
@@ -615,7 +611,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers += [
|
||||
_('Sales channel'), _('Order locale'),
|
||||
_('E-mail address verified'),
|
||||
_('External customer ID'),
|
||||
_('Payment providers'),
|
||||
]
|
||||
|
||||
@@ -633,7 +628,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'),
|
||||
@@ -735,8 +730,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(',')))
|
||||
|
||||
@@ -196,16 +196,10 @@ class SecretKeySettingsWidget(forms.TextInput):
|
||||
attrs.update({
|
||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||
})
|
||||
self.__reflect_value = False
|
||||
super().__init__(attrs)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
value = super().value_from_datadict(data, files, name)
|
||||
self.__reflect_value = value and value != SECRET_REDACTED
|
||||
return value
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
if value and not self.__reflect_value:
|
||||
if value:
|
||||
value = SECRET_REDACTED
|
||||
return super().get_context(name, value, attrs)
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
if self.require_all_fields and not all(v for v in value):
|
||||
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
|
||||
|
||||
if sum(len(v) for v in value.values() if v) > 250:
|
||||
if sum(len(v) for v in value if v) > 250:
|
||||
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
|
||||
|
||||
return value
|
||||
@@ -429,7 +429,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
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ from typing import Tuple
|
||||
import bleach
|
||||
import vat_moss.exchange_rates
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.db.models import Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import (
|
||||
@@ -48,7 +47,7 @@ from reportlab.platypus import (
|
||||
)
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import Event, Invoice, Order, OrderPayment
|
||||
from pretix.base.models import Event, Invoice, Order
|
||||
from pretix.base.signals import register_invoice_renderers
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.helpers.reportlab import ThumbnailingImageReader
|
||||
@@ -511,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'),
|
||||
@@ -590,33 +589,15 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.65, .05, .30)]
|
||||
|
||||
if self.invoice.event.settings.invoice_show_payments and not self.invoice.is_cancellation:
|
||||
if self.invoice.order.status == Order.STATUS_PENDING:
|
||||
pending_sum = self.invoice.order.pending_sum
|
||||
if pending_sum != total:
|
||||
tdata.append([pgettext('invoice', 'Received payments')] + (['', '', ''] if has_taxes else ['']) + [
|
||||
money_filter(pending_sum - total, self.invoice.event.currency)
|
||||
])
|
||||
tdata.append([pgettext('invoice', 'Outstanding payments')] + (['', '', ''] if has_taxes else ['']) + [
|
||||
money_filter(pending_sum, self.invoice.event.currency)
|
||||
])
|
||||
tstyledata += [
|
||||
('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold),
|
||||
]
|
||||
elif self.invoice.order.payments.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), provider='giftcard'
|
||||
).exists():
|
||||
giftcard_sum = self.invoice.order.payments.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
provider='giftcard'
|
||||
).aggregate(
|
||||
s=Sum('amount')
|
||||
)['s'] or Decimal('0.00')
|
||||
tdata.append([pgettext('invoice', 'Paid by gift card')] + (['', '', ''] if has_taxes else ['']) + [
|
||||
money_filter(giftcard_sum, self.invoice.event.currency)
|
||||
if self.invoice.event.settings.invoice_show_payments and not self.invoice.is_cancellation and \
|
||||
self.invoice.order.status == Order.STATUS_PENDING:
|
||||
pending_sum = self.invoice.order.pending_sum
|
||||
if pending_sum != total:
|
||||
tdata.append([pgettext('invoice', 'Received payments')] + (['', '', ''] if has_taxes else ['']) + [
|
||||
money_filter(pending_sum - total, self.invoice.event.currency)
|
||||
])
|
||||
tdata.append([pgettext('invoice', 'Remaining amount')] + (['', '', ''] if has_taxes else ['']) + [
|
||||
money_filter(total - giftcard_sum, self.invoice.event.currency)
|
||||
tdata.append([pgettext('invoice', 'Outstanding payments')] + (['', '', ''] if has_taxes else ['']) + [
|
||||
money_filter(pending_sum, self.invoice.event.currency)
|
||||
])
|
||||
tstyledata += [
|
||||
('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import migrations, models
|
||||
from django_mysql.checks import mysql_connections
|
||||
from django_mysql.utils import connection_is_mariadb
|
||||
|
||||
|
||||
def set_attendee_name_parts(apps, schema_editor):
|
||||
@@ -30,7 +31,7 @@ def check_mysqlversion(apps, schema_editor):
|
||||
conns = list(mysql_connections())
|
||||
found = 'Unknown version'
|
||||
for alias, conn in conns:
|
||||
if hasattr(conn, 'mysql_is_mariadb') and conn.mysql_is_mariadb and hasattr(conn, 'mysql_version'):
|
||||
if connection_is_mariadb(conn) and hasattr(conn, 'mysql_version'):
|
||||
if conn.mysql_version >= (10, 2, 7):
|
||||
any_conn_works = True
|
||||
else:
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
# Generated by Django 3.2.12 on 2022-05-12 15:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0214_customer_notes_ext_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customer',
|
||||
name='identifier',
|
||||
field=models.CharField(db_index=True, max_length=190),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='customer',
|
||||
unique_together={('organizer', 'email'), ('organizer', 'identifier')},
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.12 on 2022-04-29 13:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0215_customer_organizer_identifier_unique'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='force_sent',
|
||||
field=models.BooleanField(default=False, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
# Generated by Django 3.2.12 on 2022-06-15 08:10
|
||||
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0216_checkin_forced_sent'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OrganizerFooterLink',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('label', i18nfield.fields.I18nCharField(max_length=200)),
|
||||
('url', models.URLField()),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='footer_links', to='pretixbase.organizer')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventFooterLink',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('label', i18nfield.fields.I18nCharField(max_length=200)),
|
||||
('url', models.URLField()),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='footer_links', to='pretixbase.event')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.2 on 2022-06-29 17:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0217_eventfooterlink_organizerfooterlink'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='checkinlist',
|
||||
name='addon_match',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -34,9 +34,7 @@
|
||||
|
||||
import binascii
|
||||
import json
|
||||
import operator
|
||||
from datetime import timedelta
|
||||
from functools import reduce
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import webauthn
|
||||
@@ -493,14 +491,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
if request and self.has_active_staff_session(request.session.session_key):
|
||||
return Event.objects.all()
|
||||
|
||||
if isinstance(permission, (tuple, list)):
|
||||
q = reduce(operator.or_, [Q(**{p: True}) for p in permission])
|
||||
else:
|
||||
q = Q(**{permission: True})
|
||||
kwargs = {permission: True}
|
||||
|
||||
return Event.objects.filter(
|
||||
Q(organizer_id__in=self.teams.filter(q, all_events=True).values_list('organizer', flat=True))
|
||||
| Q(id__in=self.teams.filter(q).values_list('limit_events__id', flat=True))
|
||||
Q(organizer_id__in=self.teams.filter(all_events=True, **kwargs).values_list('organizer', flat=True))
|
||||
| Q(id__in=self.teams.filter(**kwargs).values_list('limit_events__id', flat=True))
|
||||
)
|
||||
|
||||
@scopes_disabled()
|
||||
|
||||
@@ -56,12 +56,6 @@ class CheckinList(LoggedModel):
|
||||
default=False,
|
||||
help_text=_('With this option, people will be able to check in even if the '
|
||||
'order has not been paid.'))
|
||||
addon_match = models.BooleanField(
|
||||
verbose_name=_('Allow checking in add-on tickets by scanning the main ticket'),
|
||||
default=False,
|
||||
help_text=_('A scan will only be possible if the check-in list is configured such that there is always exactly '
|
||||
'one matching add-on ticket. Ambiguous scans will be rejected..')
|
||||
)
|
||||
gates = models.ManyToManyField(
|
||||
'Gate', verbose_name=_("Gates"), blank=True,
|
||||
help_text=_("Does not have any effect for the validation of tickets, only for the automatic configuration of "
|
||||
@@ -194,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'
|
||||
}
|
||||
@@ -202,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
|
||||
@@ -264,7 +256,6 @@ class Checkin(models.Model):
|
||||
REASON_REVOKED = 'revoked'
|
||||
REASON_INCOMPLETE = 'incomplete'
|
||||
REASON_ALREADY_REDEEMED = 'already_redeemed'
|
||||
REASON_AMBIGUOUS = 'ambiguous'
|
||||
REASON_ERROR = 'error'
|
||||
REASONS = (
|
||||
(REASON_CANCELED, _('Order canceled')),
|
||||
@@ -275,7 +266,6 @@ class Checkin(models.Model):
|
||||
(REASON_INCOMPLETE, _('Information required')),
|
||||
(REASON_ALREADY_REDEEMED, _('Ticket already used')),
|
||||
(REASON_PRODUCT, _('Ticket type not allowed here')),
|
||||
(REASON_AMBIGUOUS, _('Ticket code is ambiguous on list')),
|
||||
(REASON_ERROR, _('Server error')),
|
||||
)
|
||||
|
||||
@@ -334,13 +324,7 @@ class Checkin(models.Model):
|
||||
type = models.CharField(max_length=100, choices=CHECKIN_TYPES, default=TYPE_ENTRY)
|
||||
|
||||
nonce = models.CharField(max_length=190, null=True, blank=True)
|
||||
|
||||
# Whether or not the scan was made offline
|
||||
force_sent = models.BooleanField(default=False, null=True, blank=True)
|
||||
|
||||
# Whether the scan was made offline AND would have not been possible online
|
||||
forced = models.BooleanField(default=False)
|
||||
|
||||
device = models.ForeignKey(
|
||||
'pretixbase.Device', related_name='checkins', on_delete=models.PROTECT, null=True, blank=True
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ from django.conf import settings
|
||||
from django.contrib.auth.hashers import (
|
||||
check_password, is_password_usable, make_password,
|
||||
)
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
@@ -45,18 +44,7 @@ class Customer(LoggedModel):
|
||||
"""
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
||||
identifier = models.CharField(
|
||||
max_length=190,
|
||||
db_index=True,
|
||||
help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do '
|
||||
'not input one, we will generate one automatically.'),
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r"^[a-zA-Z0-9]([a-zA-Z0-9.\-_]*[a-zA-Z0-9])?$",
|
||||
message=_("The identifier may only contain letters, numbers, dots, dashes, and underscores. It must start and end with a letter or number."),
|
||||
),
|
||||
],
|
||||
)
|
||||
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)
|
||||
@@ -71,13 +59,11 @@ 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')
|
||||
|
||||
class Meta:
|
||||
unique_together = [['organizer', 'email'], ['organizer', 'identifier']]
|
||||
unique_together = [['organizer', 'email']]
|
||||
ordering = ('email',)
|
||||
|
||||
def get_email_field_name(self):
|
||||
@@ -104,8 +90,6 @@ class Customer(LoggedModel):
|
||||
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)
|
||||
@@ -216,27 +200,6 @@ class Customer(LoggedModel):
|
||||
testmode=testmode,
|
||||
)
|
||||
|
||||
def send_activation_mail(self):
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.presale.forms.customer import TokenGenerator
|
||||
|
||||
ctx = self.get_email_context()
|
||||
token = TokenGenerator().make_token(self)
|
||||
ctx['url'] = build_absolute_uri(
|
||||
self.organizer,
|
||||
'presale:organizer.customer.activate'
|
||||
) + '?id=' + self.identifier + '&token=' + token
|
||||
mail(
|
||||
self.email,
|
||||
_('Activate your account at {organizer}').format(organizer=self.organizer.name),
|
||||
self.organizer.settings.mail_text_customer_registration,
|
||||
ctx,
|
||||
locale=self.locale,
|
||||
customer=self,
|
||||
organizer=self.organizer,
|
||||
)
|
||||
|
||||
|
||||
class AttendeeProfile(models.Model):
|
||||
customer = models.ForeignKey(
|
||||
|
||||
@@ -179,7 +179,6 @@ class Device(LoggedModel):
|
||||
return {
|
||||
'can_view_orders',
|
||||
'can_change_orders',
|
||||
'can_view_vouchers',
|
||||
'can_manage_gift_cards'
|
||||
}
|
||||
|
||||
@@ -255,9 +254,7 @@ class Device(LoggedModel):
|
||||
:param request: Ignored, for compatibility with User model
|
||||
:return: Iterable of Events
|
||||
"""
|
||||
if (
|
||||
isinstance(permission, (list, tuple)) and any(p in self.permission_set() for p in permission)
|
||||
) or (isinstance(permission, str) and permission in self.permission_set()):
|
||||
if permission in self.permission_set():
|
||||
return self.get_events_with_any_permission()
|
||||
else:
|
||||
return self.organizer.events.none()
|
||||
|
||||
@@ -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
|
||||
@@ -608,14 +608,11 @@ class Event(EventMixin, LoggedModel):
|
||||
return super().presale_has_ended
|
||||
|
||||
def delete_all_orders(self, really=False):
|
||||
from .orders import (
|
||||
OrderFee, OrderPayment, OrderPosition, OrderRefund, Transaction,
|
||||
)
|
||||
from .orders import OrderFee, OrderPayment, OrderPosition, OrderRefund
|
||||
|
||||
if not really:
|
||||
raise TypeError("Pass really=True as a parameter.")
|
||||
|
||||
Transaction.objects.filter(order__event=self).delete()
|
||||
OrderPosition.all.filter(order__event=self, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order__event=self).delete()
|
||||
OrderFee.objects.filter(order__event=self).delete()
|
||||
@@ -703,8 +700,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.
|
||||
@@ -721,17 +718,12 @@ class Event(EventMixin, LoggedModel):
|
||||
self.save()
|
||||
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
|
||||
|
||||
for fl in EventFooterLink.objects.filter(event=other):
|
||||
fl.pk = None
|
||||
fl.event = self
|
||||
fl.save(force_insert=True)
|
||||
|
||||
tax_map = {}
|
||||
for t in other.tax_rules.all():
|
||||
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 = {}
|
||||
@@ -739,7 +731,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 = {}
|
||||
@@ -747,7 +739,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 = {}
|
||||
@@ -768,7 +760,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:
|
||||
@@ -778,19 +770,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
|
||||
@@ -798,7 +790,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'):
|
||||
@@ -809,7 +801,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:
|
||||
@@ -818,16 +810,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())
|
||||
@@ -835,7 +817,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:
|
||||
@@ -843,7 +825,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]
|
||||
@@ -853,10 +835,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):
|
||||
@@ -872,7 +854,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])
|
||||
@@ -881,25 +863,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 = (
|
||||
@@ -1234,7 +1212,7 @@ class Event(EventMixin, LoggedModel):
|
||||
self.set_active_plugins(plugins_active)
|
||||
|
||||
plugins_available = self.get_available_plugins()
|
||||
if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'):
|
||||
if hasattr(plugins_available[module].app, 'uninstalled'):
|
||||
getattr(plugins_available[module].app, 'uninstalled')(self)
|
||||
|
||||
regenerate_css.apply_async(args=(self.pk,))
|
||||
@@ -1448,10 +1426,7 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
@property
|
||||
def meta_data(self):
|
||||
data = self.event.meta_data
|
||||
if hasattr(self, 'meta_values_cached'):
|
||||
data.update({v.property.name: v.value for v in self.meta_values_cached})
|
||||
else:
|
||||
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
|
||||
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
|
||||
return data
|
||||
|
||||
@property
|
||||
@@ -1623,25 +1598,3 @@ class SubEventMetaValue(LoggedModel):
|
||||
super().save(*args, **kwargs)
|
||||
if self.subevent:
|
||||
self.subevent.event.cache.clear()
|
||||
|
||||
|
||||
class EventFooterLink(models.Model):
|
||||
"""
|
||||
A footer link assigned to an event.
|
||||
"""
|
||||
event = models.ForeignKey('Event', on_delete=models.CASCADE, related_name='footer_links')
|
||||
label = I18nCharField(
|
||||
max_length=200,
|
||||
verbose_name=_("Link text"),
|
||||
)
|
||||
url = models.URLField(
|
||||
verbose_name=_("Link URL"),
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
self.event.cache.clear()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
self.event.cache.clear()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -1243,13 +1243,7 @@ class Question(LoggedModel):
|
||||
max_length=190,
|
||||
verbose_name=_("Internal identifier"),
|
||||
help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do '
|
||||
'not input one, we will generate one automatically.'),
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r"^[a-zA-Z0-9.\-_]+$",
|
||||
message=_("The identifier may only contain letters, numbers, dots, dashes, and underscores."),
|
||||
),
|
||||
],
|
||||
'not input one, we will generate one automatically.')
|
||||
)
|
||||
help_text = I18nTextField(
|
||||
verbose_name=_("Help text"),
|
||||
@@ -1467,17 +1461,7 @@ class Question(LoggedModel):
|
||||
|
||||
class QuestionOption(models.Model):
|
||||
question = models.ForeignKey('Question', related_name='options', on_delete=models.CASCADE)
|
||||
identifier = models.CharField(
|
||||
max_length=190,
|
||||
help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do '
|
||||
'not input one, we will generate one automatically.'),
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r"^[a-zA-Z0-9.\-_]+$",
|
||||
message=_("The identifier may only contain letters, numbers, dots, dashes, and underscores."),
|
||||
),
|
||||
],
|
||||
)
|
||||
identifier = models.CharField(max_length=190)
|
||||
answer = I18nCharField(verbose_name=_('Answer'))
|
||||
position = models.IntegerField(default=0)
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -843,7 +843,7 @@ class Order(LockModel, LoggedModel):
|
||||
if terms:
|
||||
term_last = min(terms)
|
||||
else:
|
||||
return None
|
||||
term_last = None
|
||||
else:
|
||||
term_last = term_last.datetime(self.event).date()
|
||||
term_last = make_aware(datetime.combine(
|
||||
@@ -906,10 +906,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
|
||||
@@ -1275,6 +1275,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 +1314,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)
|
||||
@@ -1588,7 +1587,7 @@ class OrderPayment(models.Model):
|
||||
if status_change:
|
||||
self.order.create_transactions()
|
||||
|
||||
def fail(self, info=None, user=None, auth=None, log_data=None):
|
||||
def fail(self, info=None, user=None, auth=None):
|
||||
"""
|
||||
Marks the order as failed and sets info to ``info``, but only if the order is in ``created`` or ``pending``
|
||||
state. This is equivalent to setting ``state`` to ``OrderPayment.PAYMENT_STATE_FAILED`` and logging a failure,
|
||||
@@ -1616,7 +1615,6 @@ class OrderPayment(models.Model):
|
||||
'local_id': self.local_id,
|
||||
'provider': self.provider,
|
||||
'info': info,
|
||||
'data': log_data,
|
||||
}, user=user, auth=auth)
|
||||
|
||||
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
|
||||
@@ -2162,9 +2160,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')
|
||||
@@ -2237,8 +2232,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)
|
||||
@@ -2587,25 +2580,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')
|
||||
@@ -2619,66 +2599,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):
|
||||
|
||||
@@ -46,7 +46,6 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import get_current_timezone, make_aware, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField
|
||||
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.validators import OrganizerSlugBanlistValidator
|
||||
@@ -461,31 +460,7 @@ class TeamAPIToken(models.Model):
|
||||
:param request: Ignored, for compatibility with User model
|
||||
:return: Iterable of Events
|
||||
"""
|
||||
if (
|
||||
isinstance(permission, (list, tuple)) and any(getattr(self.team, p, False) for p in permission)
|
||||
) or (isinstance(permission, str) and getattr(self.team, permission, False)):
|
||||
if getattr(self.team, permission, False):
|
||||
return self.get_events_with_any_permission()
|
||||
else:
|
||||
return self.team.organizer.events.none()
|
||||
|
||||
|
||||
class OrganizerFooterLink(models.Model):
|
||||
"""
|
||||
A footer link assigned to an organizer.
|
||||
"""
|
||||
organizer = models.ForeignKey('Organizer', on_delete=models.CASCADE, related_name='footer_links')
|
||||
label = I18nCharField(
|
||||
max_length=200,
|
||||
verbose_name=_("Link text"),
|
||||
)
|
||||
url = models.URLField(
|
||||
verbose_name=_("Link URL"),
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
self.organizer.cache.clear()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
self.organizer.cache.clear()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,7 +55,7 @@ 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 django.utils.translation import gettext_lazy as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from PyPDF2 import PdfFileReader
|
||||
from pytz import timezone
|
||||
@@ -521,19 +521,12 @@ def variables_from_questions(sender, *args, **kwargs):
|
||||
|
||||
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):
|
||||
value = order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
||||
if key == 'salutation' and value:
|
||||
return pgettext('person_name_salutation', value)
|
||||
return value
|
||||
return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
||||
|
||||
|
||||
def get_images(event):
|
||||
@@ -549,14 +542,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),
|
||||
@@ -574,12 +559,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),
|
||||
@@ -734,6 +713,7 @@ class Renderer:
|
||||
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:'):
|
||||
@@ -831,10 +811,9 @@ class Renderer:
|
||||
textColor=Color(o['color'][0] / 255, o['color'][1] / 255, o['color'][2] / 255),
|
||||
alignment=align_map[o['align']]
|
||||
)
|
||||
# add an almost-invisible space   after hyphens as word-wrap in ReportLab only works on space chars
|
||||
text = conditional_escape(
|
||||
self._get_text_content(op, order, o) or "",
|
||||
).replace("\n", "<br/>\n").replace("-", "- ")
|
||||
).replace("\n", "<br/>\n")
|
||||
|
||||
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
|
||||
# to resolve all ligatures and python-bidi to switch RTL texts.
|
||||
|
||||
@@ -143,8 +143,8 @@ class Sig1TicketSecretGenerator(BaseTicketSecretGenerator):
|
||||
The resulting string is REVERSED, to avoid all secrets of same length beginning with the same 10
|
||||
characters, which would make it impossible to search for secrets manually.
|
||||
"""
|
||||
verbose_name = _('pretix signature scheme 1 (for very large events, changes semantics of offline scanning – '
|
||||
'please refer to documentation or support for details)')
|
||||
verbose_name = _('pretix signature scheme 1 (for very large events, does not work with pretixSCAN on iOS and '
|
||||
'changes semantics of offline scanning – please refer to documentation or support for details)')
|
||||
identifier = 'pretix_sig1'
|
||||
use_revocation_list = True
|
||||
|
||||
|
||||
@@ -54,14 +54,11 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import OrderFee
|
||||
from pretix.base.models.tax import TaxRule
|
||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.checkin import _save_answers
|
||||
from pretix.base.services.locking import LockTimeoutException, NoLockManager
|
||||
from pretix.base.services.pricing import (
|
||||
apply_discounts, get_line_price, get_listed_price, get_price,
|
||||
is_included_for_free,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, LazyI18nStringList
|
||||
@@ -148,15 +145,13 @@ error_messages = {
|
||||
|
||||
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
|
||||
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
|
||||
'price_after_voucher', 'custom_price_input',
|
||||
'custom_price_input_is_net'))
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
|
||||
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat',
|
||||
'price_before_voucher'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price_after_voucher'))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'voucher',
|
||||
'quotas', 'subevent', 'seat', 'listed_price',
|
||||
'price_after_voucher'))
|
||||
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price'))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
||||
'quotas', 'subevent', 'seat', 'price_before_voucher'))
|
||||
order = {
|
||||
RemoveOperation: 10,
|
||||
VoucherOperation: 15,
|
||||
@@ -183,8 +178,8 @@ class CartManager:
|
||||
|
||||
@property
|
||||
def positions(self):
|
||||
return self.event.cartposition_set.filter(
|
||||
Q(cart_id=self.cart_id)
|
||||
return CartPosition.objects.filter(
|
||||
Q(cart_id=self.cart_id) & Q(event=self.event)
|
||||
).select_related('item', 'subevent')
|
||||
|
||||
def _is_seated(self, item, subevent):
|
||||
@@ -395,6 +390,7 @@ class CartManager:
|
||||
'addons'
|
||||
).order_by('-is_bundled')
|
||||
err = None
|
||||
changed_prices = {}
|
||||
for cp in expired:
|
||||
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
|
||||
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
|
||||
@@ -405,16 +401,40 @@ class CartManager:
|
||||
if cp.is_bundled:
|
||||
bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first()
|
||||
if bundle:
|
||||
listed_price = bundle.designated_price or 0
|
||||
price = bundle.designated_price or 0
|
||||
else:
|
||||
listed_price = cp.price
|
||||
price_after_voucher = listed_price
|
||||
price = cp.price
|
||||
|
||||
changed_prices[cp.pk] = price
|
||||
|
||||
if not cp.includes_tax:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
|
||||
force_custom_price=True, cp_is_net=False)
|
||||
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
|
||||
else:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
|
||||
force_custom_price=True)
|
||||
pbv = TAXED_ZERO
|
||||
else:
|
||||
listed_price = get_listed_price(cp.item, cp.variation, cp.subevent)
|
||||
if cp.voucher:
|
||||
price_after_voucher = cp.voucher.calculate_price(listed_price)
|
||||
bundled_sum = Decimal('0.00')
|
||||
if not cp.addon_to_id:
|
||||
for bundledp in cp.addons.all():
|
||||
if bundledp.is_bundled:
|
||||
bundledprice = changed_prices.get(bundledp.pk, bundledp.price)
|
||||
bundled_sum += bundledprice
|
||||
|
||||
if not cp.includes_tax:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||
cp_is_net=True, bundled_sum=bundled_sum)
|
||||
price = TaxedPrice(net=price.net, gross=price.net, rate=Decimal('0'), tax=Decimal('0'), name='')
|
||||
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
|
||||
cp_is_net=True, bundled_sum=bundled_sum)
|
||||
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=Decimal('0'), tax=Decimal('0'), name='')
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||
bundled_sum=bundled_sum)
|
||||
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
|
||||
bundled_sum=bundled_sum)
|
||||
|
||||
quotas = list(cp.quotas)
|
||||
if not quotas:
|
||||
@@ -430,8 +450,7 @@ class CartManager:
|
||||
|
||||
op = self.ExtendOperation(
|
||||
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
|
||||
quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price,
|
||||
price_after_voucher=price_after_voucher,
|
||||
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat, price_before_voucher=pbv
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
|
||||
@@ -447,10 +466,7 @@ class CartManager:
|
||||
try:
|
||||
voucher = self.event.vouchers.get(code__iexact=voucher_code.strip())
|
||||
except Voucher.DoesNotExist:
|
||||
if self.event.organizer.accepted_gift_cards.filter(secret__iexact=voucher_code).exists():
|
||||
raise CartError(error_messages['gift_card'])
|
||||
else:
|
||||
raise CartError(error_messages['voucher_invalid'])
|
||||
raise CartError(error_messages['voucher_invalid'])
|
||||
voucher_use_diff = Counter()
|
||||
ops = []
|
||||
|
||||
@@ -473,22 +489,26 @@ class CartManager:
|
||||
if p.is_bundled:
|
||||
continue
|
||||
|
||||
if p.listed_price is None:
|
||||
if p.addon_to_id and is_included_for_free(p.item, p.addon_to):
|
||||
listed_price = Decimal('0.00')
|
||||
else:
|
||||
listed_price = get_listed_price(p.item, p.variation, p.subevent)
|
||||
else:
|
||||
listed_price = p.listed_price
|
||||
price_after_voucher = voucher.calculate_price(listed_price)
|
||||
bundled_sum = Decimal('0.00')
|
||||
if not p.addon_to_id:
|
||||
for bundledp in p.addons.all():
|
||||
if bundledp.is_bundled:
|
||||
bundledprice = bundledp.price
|
||||
bundled_sum += bundledprice
|
||||
|
||||
price = self._get_price(p.item, p.variation, voucher, None, p.subevent, bundled_sum=bundled_sum)
|
||||
"""
|
||||
if price.gross > p.price:
|
||||
continue
|
||||
"""
|
||||
|
||||
voucher_use_diff[voucher] += 1
|
||||
ops.append((listed_price - price_after_voucher, self.VoucherOperation(p, voucher, price_after_voucher)))
|
||||
ops.append((p.price - price.gross, self.VoucherOperation(p, voucher, price)))
|
||||
|
||||
# If there are not enough voucher usages left for the full cart, let's apply them in the order that benefits
|
||||
# the user the most.
|
||||
ops.sort(key=lambda k: k[0], reverse=True)
|
||||
self._operations += [k[1] for k in ops]
|
||||
self._operations += [k[1] for k in ops]\
|
||||
|
||||
if not voucher_use_diff:
|
||||
raise CartError(error_messages['voucher_no_match'])
|
||||
@@ -555,6 +575,7 @@ class CartManager:
|
||||
|
||||
# Fetch bundled items
|
||||
bundled = []
|
||||
bundled_sum = Decimal('0.00')
|
||||
db_bundles = list(item.bundles.all())
|
||||
self._update_items_cache([b.bundled_item_id for b in db_bundles], [b.bundled_variation_id for b in db_bundles])
|
||||
for bundle in db_bundles:
|
||||
@@ -574,49 +595,28 @@ class CartManager:
|
||||
else:
|
||||
bundle_quotas = []
|
||||
|
||||
if bundle.designated_price:
|
||||
bprice = self._get_price(bitem, bvar, None, bundle.designated_price, subevent, force_custom_price=True,
|
||||
cp_is_net=False)
|
||||
else:
|
||||
bprice = TAXED_ZERO
|
||||
bundled_sum += bundle.designated_price * bundle.count
|
||||
|
||||
bop = self.AddOperation(
|
||||
count=bundle.count,
|
||||
item=bitem,
|
||||
variation=bvar,
|
||||
voucher=None,
|
||||
quotas=bundle_quotas,
|
||||
addon_to='FAKE',
|
||||
subevent=subevent,
|
||||
bundled=[],
|
||||
seat=None,
|
||||
listed_price=bundle.designated_price,
|
||||
price_after_voucher=bundle.designated_price,
|
||||
custom_price_input=None,
|
||||
custom_price_input_is_net=False,
|
||||
count=bundle.count, item=bitem, variation=bvar, price=bprice,
|
||||
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
|
||||
includes_tax=bool(bprice.rate), bundled=[], seat=None, price_before_voucher=bprice,
|
||||
)
|
||||
self._check_item_constraints(bop, operations)
|
||||
bundled.append(bop)
|
||||
|
||||
listed_price = get_listed_price(item, variation, subevent)
|
||||
if voucher:
|
||||
price_after_voucher = voucher.calculate_price(listed_price)
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
custom_price = None
|
||||
if item.free_price and i.get('price'):
|
||||
custom_price = Decimal(str(i.get('price')).replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
raise ValueError('price_too_high')
|
||||
price = self._get_price(item, variation, voucher, i.get('price'), subevent, bundled_sum=bundled_sum)
|
||||
pbv = self._get_price(item, variation, None, i.get('price'), subevent, bundled_sum=bundled_sum)
|
||||
|
||||
op = self.AddOperation(
|
||||
count=i['count'],
|
||||
item=item,
|
||||
variation=variation,
|
||||
voucher=voucher,
|
||||
quotas=quotas,
|
||||
addon_to=False,
|
||||
subevent=subevent,
|
||||
bundled=bundled,
|
||||
seat=seat,
|
||||
listed_price=listed_price,
|
||||
price_after_voucher=price_after_voucher,
|
||||
custom_price_input=custom_price,
|
||||
custom_price_input_is_net=self.event.settings.display_net_prices,
|
||||
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
|
||||
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat,
|
||||
price_before_voucher=pbv
|
||||
)
|
||||
self._check_item_constraints(op, operations)
|
||||
operations.append(op)
|
||||
@@ -707,27 +707,16 @@ class CartManager:
|
||||
input_addons[cp.id][a['item'], a['variation']] = a.get('count', 1)
|
||||
selected_addons[cp.id, item.category_id][a['item'], a['variation']] = a.get('count', 1)
|
||||
|
||||
if is_included_for_free(item, cp):
|
||||
listed_price = Decimal('0.00')
|
||||
if price_included[cp.pk].get(item.category_id):
|
||||
price = TAXED_ZERO
|
||||
else:
|
||||
listed_price = get_listed_price(item, variation, cp.subevent)
|
||||
custom_price = None
|
||||
if item.free_price and a.get('price'):
|
||||
custom_price = Decimal(str(a.get('price')).replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
raise ValueError('price_too_high')
|
||||
price = self._get_price(item, variation, None, a.get('price'), cp.subevent)
|
||||
|
||||
# Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky)
|
||||
for ca in current_addons[cp][a['item'], a['variation']]:
|
||||
if ca.listed_price != listed_price:
|
||||
ca.listed_price = ca.listed_price
|
||||
ca.price_after_voucher = ca.price_after_voucher
|
||||
ca.save(update_fields=['listed_price', 'price_after_voucher'])
|
||||
if ca.custom_price_input != custom_price:
|
||||
ca.custom_price_input = custom_price
|
||||
ca.custom_price_input_is_net = self.event.settings.display_net_prices
|
||||
ca.price_after_voucher = ca.price_after_voucher
|
||||
ca.save(update_fields=['custom_price_input', 'custom_price_input'])
|
||||
if ca.price != price.gross:
|
||||
ca.price = price.gross
|
||||
ca.save(update_fields=['price'])
|
||||
|
||||
if a.get('count', 1) > len(current_addons[cp][a['item'], a['variation']]):
|
||||
# This add-on is new, add it to the cart
|
||||
@@ -736,18 +725,9 @@ class CartManager:
|
||||
|
||||
op = self.AddOperation(
|
||||
count=a.get('count', 1) - len(current_addons[cp][a['item'], a['variation']]),
|
||||
item=item,
|
||||
variation=variation,
|
||||
voucher=None,
|
||||
quotas=quotas,
|
||||
addon_to=cp,
|
||||
subevent=cp.subevent,
|
||||
bundled=[],
|
||||
seat=None,
|
||||
listed_price=listed_price,
|
||||
price_after_voucher=listed_price,
|
||||
custom_price_input=custom_price,
|
||||
custom_price_input_is_net=self.event.settings.display_net_prices,
|
||||
item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
||||
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None,
|
||||
price_before_voucher=None
|
||||
)
|
||||
self._check_item_constraints(op, operations)
|
||||
operations.append(op)
|
||||
@@ -947,11 +927,11 @@ class CartManager:
|
||||
|
||||
if voucher_available_count < 1:
|
||||
if op.voucher in self._voucher_depend_on_cart:
|
||||
err = err or _(error_messages['voucher_redeemed_cart']) % self.event.settings.reservation_time
|
||||
err = err or error_messages['voucher_redeemed_cart'] % self.event.settings.reservation_time
|
||||
else:
|
||||
err = err or error_messages['voucher_redeemed']
|
||||
elif voucher_available_count < requested_count:
|
||||
err = err or _(error_messages['voucher_redeemed_partial']) % voucher_available_count
|
||||
err = err or error_messages['voucher_redeemed_partial'] % voucher_available_count
|
||||
|
||||
available_count = min(quota_available_count, voucher_available_count)
|
||||
|
||||
@@ -992,31 +972,13 @@ class CartManager:
|
||||
err = err or error_messages['seat_unavailable']
|
||||
|
||||
for k in range(available_count):
|
||||
line_price = get_line_price(
|
||||
price_after_voucher=op.price_after_voucher,
|
||||
custom_price_input=op.custom_price_input,
|
||||
custom_price_input_is_net=op.custom_price_input_is_net,
|
||||
tax_rule=op.item.tax_rule,
|
||||
invoice_address=self.invoice_address,
|
||||
bundled_sum=sum([pp.count * pp.price_after_voucher for pp in op.bundled]),
|
||||
)
|
||||
cp = CartPosition(
|
||||
event=self.event,
|
||||
item=op.item,
|
||||
variation=op.variation,
|
||||
expires=self._expiry,
|
||||
cart_id=self.cart_id,
|
||||
voucher=op.voucher,
|
||||
addon_to=op.addon_to if op.addon_to else None,
|
||||
subevent=op.subevent,
|
||||
seat=op.seat,
|
||||
listed_price=op.listed_price,
|
||||
price_after_voucher=op.price_after_voucher,
|
||||
custom_price_input=op.custom_price_input,
|
||||
custom_price_input_is_net=op.custom_price_input_is_net,
|
||||
line_price_gross=line_price.gross,
|
||||
tax_rate=line_price.tax,
|
||||
price=line_price.gross,
|
||||
event=self.event, item=op.item, variation=op.variation,
|
||||
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
|
||||
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
|
||||
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat,
|
||||
override_tax_rate=op.price.rate,
|
||||
price_before_voucher=op.price_before_voucher.gross if op.price_before_voucher is not None else None
|
||||
)
|
||||
if self.event.settings.attendee_names_asked:
|
||||
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
|
||||
@@ -1045,26 +1007,12 @@ class CartManager:
|
||||
if op.bundled:
|
||||
cp.save() # Needs to be in the database already so we have a PK that we can reference
|
||||
for b in op.bundled:
|
||||
bline_price = (
|
||||
b.item.tax_rule or TaxRule(rate=Decimal('0.00'))
|
||||
).tax(b.listed_price, base_price_is='gross', invoice_address=self.invoice_address) # todo compare with previous behaviour
|
||||
for j in range(b.count):
|
||||
new_cart_positions.append(CartPosition(
|
||||
event=self.event,
|
||||
item=b.item,
|
||||
variation=b.variation,
|
||||
expires=self._expiry, cart_id=self.cart_id,
|
||||
voucher=None,
|
||||
addon_to=cp,
|
||||
subevent=b.subevent,
|
||||
listed_price=b.listed_price,
|
||||
price_after_voucher=b.price_after_voucher,
|
||||
custom_price_input=b.custom_price_input,
|
||||
custom_price_input_is_net=b.custom_price_input_is_net,
|
||||
line_price_gross=bline_price.gross,
|
||||
tax_rate=bline_price.tax,
|
||||
price=bline_price.gross,
|
||||
is_bundled=True
|
||||
event=self.event, item=b.item, variation=b.variation,
|
||||
price=b.price.gross, expires=self._expiry, cart_id=self.cart_id,
|
||||
voucher=None, addon_to=cp, override_tax_rate=b.price.rate,
|
||||
subevent=b.subevent, includes_tax=b.includes_tax, is_bundled=True
|
||||
))
|
||||
|
||||
new_cart_positions.append(cp)
|
||||
@@ -1076,11 +1024,11 @@ class CartManager:
|
||||
op.position.delete()
|
||||
elif available_count == 1:
|
||||
op.position.expires = self._expiry
|
||||
op.position.listed_price = op.listed_price
|
||||
op.position.price_after_voucher = op.price_after_voucher
|
||||
# op.position.price will be updated by recompute_final_prices_and_taxes()
|
||||
op.position.price = op.price.gross
|
||||
if op.price_before_voucher is not None:
|
||||
op.position.price_before_voucher = op.price_before_voucher.gross
|
||||
try:
|
||||
op.position.save(force_update=True, update_fields=['expires', 'listed_price', 'price_after_voucher'])
|
||||
op.position.save(force_update=True)
|
||||
except DatabaseError:
|
||||
# Best effort... The position might have been deleted in the meantime!
|
||||
pass
|
||||
@@ -1098,10 +1046,10 @@ class CartManager:
|
||||
# be expected
|
||||
continue
|
||||
|
||||
op.position.price_after_voucher = op.price_after_voucher
|
||||
op.position.price_before_voucher = op.position.price
|
||||
op.position.price = op.price.gross
|
||||
op.position.voucher = op.voucher
|
||||
# op.posiiton.price will be set in recompute_final_prices_and_taxes
|
||||
op.position.save(update_fields=['price_after_voucher', 'voucher'])
|
||||
op.position.save()
|
||||
vouchers_ok[op.voucher] -= 1
|
||||
|
||||
for p in new_cart_positions:
|
||||
@@ -1126,35 +1074,6 @@ class CartManager:
|
||||
|
||||
return False
|
||||
|
||||
def recompute_final_prices_and_taxes(self):
|
||||
positions = sorted(list(self.positions), key=lambda op: -(op.addon_to_id or 0))
|
||||
diff = Decimal('0.00')
|
||||
for cp in positions:
|
||||
if cp.listed_price is None:
|
||||
# migration from old system? also used in unit tests
|
||||
cp.update_listed_price_and_voucher()
|
||||
cp.migrate_free_price_if_necessary()
|
||||
|
||||
cp.update_line_price(self.invoice_address, [b for b in positions if b.addon_to_id == cp.pk and b.is_bundled])
|
||||
|
||||
discount_results = apply_discounts(
|
||||
self.event,
|
||||
self._sales_channel,
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
||||
for cp in positions
|
||||
]
|
||||
)
|
||||
|
||||
for cp, (new_price, discount) in zip(positions, discount_results):
|
||||
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
|
||||
diff += new_price - cp.price
|
||||
cp.price = new_price
|
||||
cp.discount = discount
|
||||
cp.save(update_fields=['price', 'discount'])
|
||||
|
||||
return diff
|
||||
|
||||
def commit(self):
|
||||
self._check_presale_dates()
|
||||
self._check_max_cart_size()
|
||||
@@ -1172,11 +1091,33 @@ class CartManager:
|
||||
self.now_dt = now_dt
|
||||
self._extend_expiry_of_valid_existing_positions()
|
||||
err = self._perform_operations() or err
|
||||
self.recompute_final_prices_and_taxes()
|
||||
if err:
|
||||
raise CartError(err)
|
||||
|
||||
|
||||
def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress):
|
||||
positions = CartPosition.objects.filter(
|
||||
cart_id=cart_id, event=event
|
||||
).select_related('item', 'item__tax_rule')
|
||||
totaldiff = Decimal('0.00')
|
||||
for pos in positions:
|
||||
if not pos.item.tax_rule:
|
||||
continue
|
||||
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
|
||||
|
||||
if pos.tax_rate != rate:
|
||||
if not pos.item.tax_rule.keep_gross_if_rate_changes:
|
||||
current_net = pos.price - pos.tax_value
|
||||
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
||||
totaldiff += new_gross - pos.price
|
||||
pos.price = new_gross
|
||||
pos.includes_tax = rate != Decimal('0.00')
|
||||
pos.override_tax_rate = rate
|
||||
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
|
||||
|
||||
return totaldiff
|
||||
|
||||
|
||||
def get_fees(event, request, total, invoice_address, provider, positions):
|
||||
from pretix.presale.views.cart import cart_session
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ import pytz
|
||||
from django.core.files import File
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import (
|
||||
BooleanField, Count, ExpressionWrapper, F, IntegerField, Max, Min,
|
||||
OuterRef, Q, Subquery, Value,
|
||||
BooleanField, Count, ExpressionWrapper, F, IntegerField, OuterRef, Q,
|
||||
Subquery, Value,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, TruncDate
|
||||
from django.dispatch import receiver
|
||||
@@ -60,7 +60,7 @@ from pretix.helpers.jsonlogic import Logic
|
||||
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
|
||||
from pretix.helpers.jsonlogic_query import (
|
||||
Equal, GreaterEqualThan, GreaterThan, InList, LowerEqualThan, LowerThan,
|
||||
MinutesSince, tolerance,
|
||||
tolerance,
|
||||
)
|
||||
|
||||
|
||||
@@ -210,60 +210,19 @@ def _logic_explain(rules, ev, rule_data):
|
||||
elif var == 'product' or var == 'variation':
|
||||
var_weights[vname] = (1000, 0)
|
||||
var_texts[vname] = _('Ticket type not allowed')
|
||||
elif var in ('entries_number', 'entries_today', 'entries_days', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday'):
|
||||
elif var in ('entries_number', 'entries_today', 'entries_days'):
|
||||
w = {
|
||||
'minutes_since_first_entry': 80,
|
||||
'minutes_since_last_entry': 90,
|
||||
'entries_days': 100,
|
||||
'entries_number': 120,
|
||||
'entries_today': 140,
|
||||
'now_isoweekday': 210,
|
||||
}
|
||||
operator_weights = {
|
||||
'==': 2,
|
||||
'<': 1,
|
||||
'<=': 1,
|
||||
'>': 1,
|
||||
'>=': 1,
|
||||
'!=': 3,
|
||||
}
|
||||
l = {
|
||||
'minutes_since_last_entry': _('time since last entry'),
|
||||
'minutes_since_first_entry': _('time since first entry'),
|
||||
'entries_days': _('number of days with an entry'),
|
||||
'entries_number': _('number of entries'),
|
||||
'entries_today': _('number of entries today'),
|
||||
'now_isoweekday': _('week day'),
|
||||
}
|
||||
compare_to = rhs[0]
|
||||
penalty = 0
|
||||
|
||||
if var in ('minutes_since_last_entry', 'minutes_since_first_entry'):
|
||||
is_comparison_to_minus_one = (
|
||||
(operator == '<' and compare_to <= 0) or
|
||||
(operator == '<=' and compare_to < 0) or
|
||||
(operator == '>=' and compare_to < 0) or
|
||||
(operator == '>' and compare_to <= 0) or
|
||||
(operator == '==' and compare_to == -1) or
|
||||
(operator == '!=' and compare_to == -1)
|
||||
)
|
||||
if is_comparison_to_minus_one:
|
||||
# These are "technical" comparisons without real meaning, we don't want to show them.
|
||||
penalty = 1000
|
||||
|
||||
var_weights[vname] = (w[var] + operator_weights.get(operator, 0) + penalty, abs(compare_to - rule_data[var]))
|
||||
|
||||
if var == 'now_isoweekday':
|
||||
compare_to = {
|
||||
1: _('Monday'),
|
||||
2: _('Tuesday'),
|
||||
3: _('Wednesday'),
|
||||
4: _('Thursday'),
|
||||
5: _('Friday'),
|
||||
6: _('Saturday'),
|
||||
7: _('Sunday'),
|
||||
}.get(compare_to, compare_to)
|
||||
|
||||
var_weights[vname] = (w[var], abs(compare_to - rule_data[var]))
|
||||
if operator == '==':
|
||||
var_texts[vname] = _('{variable} is not {value}').format(variable=l[var], value=compare_to)
|
||||
elif operator in ('<', '<='):
|
||||
@@ -272,7 +231,6 @@ def _logic_explain(rules, ev, rule_data):
|
||||
var_texts[vname] = _('Minimum {variable} exceeded').format(variable=l[var])
|
||||
elif operator == '!=':
|
||||
var_texts[vname] = _('{variable} is {value}').format(variable=l[var], value=compare_to)
|
||||
|
||||
else:
|
||||
raise ValueError(f'Unknown variable {var}')
|
||||
|
||||
@@ -331,11 +289,6 @@ class LazyRuleVars:
|
||||
def now(self):
|
||||
return self._dt
|
||||
|
||||
@property
|
||||
def now_isoweekday(self):
|
||||
tz = self._clist.event.timezone
|
||||
return self._dt.astimezone(tz).isoweekday()
|
||||
|
||||
@property
|
||||
def product(self):
|
||||
return self._position.item_id
|
||||
@@ -362,30 +315,6 @@ class LazyRuleVars:
|
||||
day=TruncDate('datetime', tzinfo=tz)
|
||||
).values('day').distinct().count()
|
||||
|
||||
@cached_property
|
||||
def minutes_since_last_entry(self):
|
||||
tz = self._clist.event.timezone
|
||||
with override(tz):
|
||||
last_entry = self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).order_by('datetime').last()
|
||||
if last_entry is None:
|
||||
# Returning "None" would be "correct", but the handling of "None" in JSON logic is inconsistent
|
||||
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
|
||||
# consistent.
|
||||
return -1
|
||||
return (now() - last_entry.datetime).total_seconds() // 60
|
||||
|
||||
@cached_property
|
||||
def minutes_since_first_entry(self):
|
||||
tz = self._clist.event.timezone
|
||||
with override(tz):
|
||||
last_entry = self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).order_by('datetime').first()
|
||||
if last_entry is None:
|
||||
# Returning "None" would be "correct", but the handling of "None" in JSON logic is inconsistent
|
||||
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
|
||||
# consistent.
|
||||
return -1
|
||||
return (now() - last_entry.datetime).total_seconds() // 60
|
||||
|
||||
|
||||
class SQLLogic:
|
||||
"""
|
||||
@@ -470,8 +399,6 @@ class SQLLogic:
|
||||
elif operator == 'var':
|
||||
if values[0] == 'now':
|
||||
return Value(now().astimezone(pytz.UTC))
|
||||
elif values[0] == 'now_isoweekday':
|
||||
return Value(now().astimezone(self.list.event.timezone).isoweekday())
|
||||
elif values[0] == 'product':
|
||||
return F('item_id')
|
||||
elif values[0] == 'variation':
|
||||
@@ -523,38 +450,6 @@ class SQLLogic:
|
||||
Value(0),
|
||||
output_field=IntegerField()
|
||||
)
|
||||
elif values[0] == 'minutes_since_last_entry':
|
||||
sq_last_entry = Subquery(
|
||||
Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
type=Checkin.TYPE_ENTRY,
|
||||
list_id=self.list.pk,
|
||||
).values('position_id').order_by().annotate(
|
||||
m=Max('datetime')
|
||||
).values('m')
|
||||
)
|
||||
|
||||
return Coalesce(
|
||||
MinutesSince(sq_last_entry),
|
||||
Value(-1),
|
||||
output_field=IntegerField()
|
||||
)
|
||||
elif values[0] == 'minutes_since_first_entry':
|
||||
sq_last_entry = Subquery(
|
||||
Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
type=Checkin.TYPE_ENTRY,
|
||||
list_id=self.list.pk,
|
||||
).values('position_id').order_by().annotate(
|
||||
m=Min('datetime')
|
||||
).values('m')
|
||||
)
|
||||
|
||||
return Coalesce(
|
||||
MinutesSince(sq_last_entry),
|
||||
Value(-1),
|
||||
output_field=IntegerField()
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unknown operator {operator}')
|
||||
|
||||
@@ -796,7 +691,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
gate=device.gate if device else None,
|
||||
nonce=nonce,
|
||||
forced=force and (not entry_allowed or from_revoked_secret),
|
||||
force_sent=force,
|
||||
raw_barcode=raw_barcode,
|
||||
)
|
||||
op.order.log_action('pretix.event.checkin', data={
|
||||
|
||||
@@ -412,9 +412,8 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
logger.exception('Could not attach invoice to email')
|
||||
pass
|
||||
|
||||
if attach_size < settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT - 1:
|
||||
# Do not attach more than (limit - 1 MB) in tickets (1MB space for invoice, email itself, …),
|
||||
# it will bounce way to often.
|
||||
if attach_size < settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT:
|
||||
# Do not attach more than 4MB, it will bounce way to often.
|
||||
for a in args:
|
||||
try:
|
||||
email.attach(*a)
|
||||
@@ -486,14 +485,17 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
try:
|
||||
backend.send_messages([email])
|
||||
except (smtplib.SMTPResponseException, smtplib.SMTPSenderRefused) as e:
|
||||
if e.smtp_code in (101, 111, 421, 422, 431, 432, 442, 447, 452):
|
||||
if e.smtp_code == 432 and settings.HAS_REDIS:
|
||||
if e.smtp_code in (101, 111, 421, 422, 431, 432, 442, 447, 452, 454):
|
||||
if e.smtp_code in (432, 454) and settings.HAS_REDIS:
|
||||
# This is likely Microsoft Exchange Online which has a pretty bad rate limit of max. 3 concurrent
|
||||
# SMTP connections which is *easily* exceeded with many celery threads. Just retrying with exponential
|
||||
# backoff won't be good enough if we have a lot of emails, instead we'll need to make sure our retry
|
||||
# intervals scatter such that the email won't all be retried at the same time again and cause the
|
||||
# same problem.
|
||||
# See also https://docs.microsoft.com/en-us/exchange/troubleshoot/send-emails/smtp-submission-improvements
|
||||
#
|
||||
# Also observed on AWS SES with 454:
|
||||
# https://aws.amazon.com/blogs/messaging-and-targeting/how-to-handle-a-throttling-maximum-sending-rate-exceeded-error/
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
redis_key = "pretix_mail_retry_" + hashlib.sha1(f"{getattr(backend, 'username', '_')}@{getattr(backend, 'host', '_')}".encode()).hexdigest()
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import Counter, defaultdict, namedtuple
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
@@ -69,6 +68,7 @@ from pretix.base.models import (
|
||||
Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import ItemBundle
|
||||
from pretix.base.models.orders import (
|
||||
InvoiceAddress, OrderFee, OrderRefund, generate_secret,
|
||||
)
|
||||
@@ -86,9 +86,7 @@ from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.memberships import (
|
||||
create_membership, validate_memberships_in_order,
|
||||
)
|
||||
from pretix.base.services.pricing import (
|
||||
apply_discounts, get_listed_price, get_price,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
||||
from pretix.base.signals import (
|
||||
@@ -567,8 +565,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
_check_date(event, now_dt)
|
||||
|
||||
products_seen = Counter()
|
||||
q_avail = Counter()
|
||||
v_avail = Counter()
|
||||
changed_prices = {}
|
||||
v_budget = {}
|
||||
deleted_positions = set()
|
||||
seats_seen = set()
|
||||
@@ -585,8 +582,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
cp.delete()
|
||||
|
||||
sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled))
|
||||
|
||||
# Check availability
|
||||
for i, cp in enumerate(sorted_positions):
|
||||
if cp.pk in deleted_positions:
|
||||
continue
|
||||
@@ -606,17 +601,29 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
break
|
||||
|
||||
if cp.voucher:
|
||||
if cp.voucher not in v_avail:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
|
||||
).exclude(cart_id=cp.cart_id)
|
||||
v_avail[cp.voucher] = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
|
||||
v_avail[cp.voucher] -= 1
|
||||
if v_avail[cp.voucher] < 0:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
|
||||
).exclude(pk=cp.pk)
|
||||
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
|
||||
if v_avail < 1:
|
||||
err = err or error_messages['voucher_redeemed']
|
||||
delete(cp)
|
||||
continue
|
||||
|
||||
if cp.voucher.budget is not None:
|
||||
if cp.voucher not in v_budget:
|
||||
v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used()
|
||||
disc = cp.price_before_voucher - cp.price
|
||||
if disc > v_budget[cp.voucher]:
|
||||
new_disc = max(0, v_budget[cp.voucher])
|
||||
cp.price = cp.price + (disc - new_disc)
|
||||
cp.save()
|
||||
err = err or error_messages['voucher_budget_used']
|
||||
v_budget[cp.voucher] -= new_disc
|
||||
continue
|
||||
else:
|
||||
v_budget[cp.voucher] -= disc
|
||||
|
||||
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
|
||||
err = err or error_messages['some_subevent_not_started']
|
||||
delete(cp)
|
||||
@@ -655,6 +662,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)
|
||||
) and not cp.is_bundled:
|
||||
delete(cp)
|
||||
cp.delete()
|
||||
err = error_messages['voucher_required']
|
||||
break
|
||||
|
||||
@@ -663,14 +671,56 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
# time, since we absolutely can not overbook a seat.
|
||||
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel):
|
||||
err = err or error_messages['seat_unavailable']
|
||||
delete(cp)
|
||||
cp.delete()
|
||||
continue
|
||||
|
||||
if cp.expires >= now_dt and not cp.voucher:
|
||||
# Other checks are not necessary
|
||||
continue
|
||||
|
||||
if len(quotas) == 0:
|
||||
max_discount = None
|
||||
if cp.price_before_voucher is not None and cp.voucher in v_budget:
|
||||
current_discount = cp.price_before_voucher - cp.price
|
||||
max_discount = max(v_budget[cp.voucher] + current_discount, 0)
|
||||
|
||||
try:
|
||||
if cp.is_bundled:
|
||||
try:
|
||||
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
|
||||
bprice = bundle.designated_price or 0
|
||||
except ItemBundle.DoesNotExist:
|
||||
bprice = cp.price
|
||||
except ItemBundle.MultipleObjectsReturned:
|
||||
raise OrderError("Invalid product configuration (duplicate bundle)")
|
||||
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
|
||||
custom_price_is_tax_rate=cp.override_tax_rate,
|
||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
|
||||
custom_price_is_tax_rate=cp.override_tax_rate,
|
||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||
changed_prices[cp.pk] = bprice
|
||||
else:
|
||||
bundled_sum = Decimal('0.00')
|
||||
if not cp.addon_to_id:
|
||||
for bundledp in cp.addons.all():
|
||||
if bundledp.is_bundled:
|
||||
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price)
|
||||
|
||||
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
|
||||
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
|
||||
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
|
||||
pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False,
|
||||
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
|
||||
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
|
||||
except TaxRule.SaleNotAllowed:
|
||||
err = err or error_messages['country_blocked']
|
||||
cp.delete()
|
||||
continue
|
||||
|
||||
if max_discount is not None:
|
||||
v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross)
|
||||
|
||||
if price is False or len(quotas) == 0:
|
||||
err = err or error_messages['unavailable']
|
||||
delete(cp)
|
||||
continue
|
||||
@@ -692,88 +742,42 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
delete(cp)
|
||||
continue
|
||||
|
||||
if pbv is not None and pbv.gross != price.gross:
|
||||
cp.price_before_voucher = pbv.gross
|
||||
else:
|
||||
cp.price_before_voucher = None
|
||||
|
||||
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
|
||||
cp.price = price.gross
|
||||
cp.includes_tax = bool(price.rate)
|
||||
cp.save()
|
||||
err = err or error_messages['price_changed']
|
||||
continue
|
||||
|
||||
quota_ok = True
|
||||
|
||||
ignore_all_quotas = cp.expires >= now_dt or (
|
||||
cp.voucher and (
|
||||
cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)
|
||||
)
|
||||
)
|
||||
cp.voucher and (cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)))
|
||||
|
||||
if not ignore_all_quotas:
|
||||
for quota in quotas:
|
||||
if cp.voucher and cp.voucher.block_quota and cp.voucher.quota_id == quota.pk:
|
||||
continue
|
||||
if quota not in q_avail:
|
||||
avail = quota.availability(now_dt)
|
||||
q_avail[quota] = avail[1] if avail[1] is not None else sys.maxsize
|
||||
q_avail[quota] -= 1
|
||||
if q_avail[quota] < 0:
|
||||
avail = quota.availability(now_dt)
|
||||
if avail[0] != Quota.AVAILABILITY_OK:
|
||||
# This quota is sold out/currently unavailable, so do not sell this at all
|
||||
err = err or error_messages['unavailable']
|
||||
quota_ok = False
|
||||
break
|
||||
|
||||
if not quota_ok:
|
||||
if quota_ok:
|
||||
cp.expires = now_dt + timedelta(
|
||||
minutes=event.settings.get('reservation_time', as_type=int))
|
||||
cp.save()
|
||||
else:
|
||||
# Sorry, can't let you keep that!
|
||||
delete(cp)
|
||||
|
||||
# Check prices
|
||||
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
|
||||
old_total = sum(cp.price for cp in sorted_positions)
|
||||
for i, cp in enumerate(sorted_positions):
|
||||
if cp.listed_price is None:
|
||||
# migration from pre-discount cart positions
|
||||
cp.update_listed_price_and_voucher(max_discount=None)
|
||||
cp.migrate_free_price_if_necessary()
|
||||
|
||||
# deal with max discount
|
||||
max_discount = None
|
||||
if cp.voucher and cp.voucher.budget is not None:
|
||||
if cp.voucher not in v_budget:
|
||||
v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used()
|
||||
max_discount = max(v_budget[cp.voucher], 0)
|
||||
|
||||
if cp.expires < now_dt or cp.listed_price is None:
|
||||
# Guarantee on listed price is expired
|
||||
cp.update_listed_price_and_voucher(max_discount=max_discount)
|
||||
elif cp.voucher:
|
||||
cp.update_listed_price_and_voucher(max_discount=max_discount, voucher_only=True)
|
||||
|
||||
if max_discount is not None:
|
||||
v_budget[cp.voucher] = v_budget[cp.voucher] - (cp.listed_price - cp.price_after_voucher)
|
||||
|
||||
try:
|
||||
cp.update_line_price(address, [b for b in sorted_positions if b.addon_to_id == cp.pk and b.is_bundled and b.pk and b.pk not in deleted_positions])
|
||||
except TaxRule.SaleNotAllowed:
|
||||
err = err or error_messages['country_blocked']
|
||||
delete(cp)
|
||||
continue
|
||||
|
||||
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
|
||||
discount_results = apply_discounts(
|
||||
event,
|
||||
sales_channel,
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
||||
for cp in sorted_positions
|
||||
]
|
||||
)
|
||||
for cp, (new_price, discount) in zip(sorted_positions, discount_results):
|
||||
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
|
||||
cp.price = new_price
|
||||
cp.discount = discount
|
||||
cp.save(update_fields=['price', 'discount'])
|
||||
|
||||
new_total = sum(cp.price for cp in sorted_positions)
|
||||
if old_total != new_total:
|
||||
err = err or error_messages['price_changed']
|
||||
|
||||
# Store updated positions
|
||||
for cp in sorted_positions:
|
||||
cp.expires = now_dt + timedelta(
|
||||
minutes=event.settings.get('reservation_time', as_type=int))
|
||||
cp.save()
|
||||
|
||||
if err:
|
||||
raise OrderError(err, errargs)
|
||||
|
||||
@@ -1854,14 +1858,16 @@ class OrderChangeManager:
|
||||
op.position.item = op.item
|
||||
op.position.variation = op.variation
|
||||
op.position._calculate_tax()
|
||||
|
||||
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
|
||||
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
|
||||
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
|
||||
price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price))
|
||||
else:
|
||||
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
|
||||
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
|
||||
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
|
||||
op.position.price_before_voucher = max(
|
||||
op.position.price,
|
||||
get_price(
|
||||
op.position.item, op.position.variation,
|
||||
subevent=op.position.subevent,
|
||||
custom_price=op.position.price,
|
||||
invoice_address=self._invoice_address
|
||||
).gross
|
||||
)
|
||||
assign_ticket_secret(
|
||||
event=self.event, position=op.position, force_invalidate=False, save=False
|
||||
)
|
||||
@@ -1902,13 +1908,16 @@ class OrderChangeManager:
|
||||
assign_ticket_secret(
|
||||
event=self.event, position=op.position, force_invalidate=False, save=False
|
||||
)
|
||||
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
|
||||
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
|
||||
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
|
||||
price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price))
|
||||
else:
|
||||
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
|
||||
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
|
||||
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
|
||||
op.position.price_before_voucher = max(
|
||||
op.position.price,
|
||||
get_price(
|
||||
op.position.item, op.position.variation,
|
||||
subevent=op.position.subevent,
|
||||
custom_price=op.position.price,
|
||||
invoice_address=self._invoice_address
|
||||
).gross
|
||||
)
|
||||
op.position.save()
|
||||
elif isinstance(op, self.AddFeeOperation):
|
||||
self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={
|
||||
@@ -2304,11 +2313,6 @@ class OrderChangeManager:
|
||||
# Do nothing
|
||||
return
|
||||
|
||||
# Clear prefetched objects cache of order. We're going to modify the positions and fees and we have no guarantee
|
||||
# that every operation tuple points to a position/fee instance that has been fetched from the same object cache,
|
||||
# so it's dangerous to keep the cache around.
|
||||
self.order._prefetched_objects_cache = {}
|
||||
|
||||
# finally, incorporate difference in payment fees
|
||||
self._payment_fee_diff()
|
||||
|
||||
|
||||
@@ -20,30 +20,39 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import (
|
||||
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import Event, SubEvent
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
||||
|
||||
|
||||
def get_price(item: Item, variation: ItemVariation = None,
|
||||
voucher: Voucher = None, custom_price: Decimal = None,
|
||||
subevent: SubEvent = None, custom_price_is_net: bool = False,
|
||||
custom_price_is_tax_rate: Decimal = None,
|
||||
custom_price_is_tax_rate: Decimal=None,
|
||||
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None,
|
||||
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00'),
|
||||
max_discount: Decimal = None, tax_rule=None) -> TaxedPrice:
|
||||
if is_included_for_free(item, addon_to):
|
||||
return TAXED_ZERO
|
||||
if addon_to:
|
||||
try:
|
||||
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
|
||||
if iao.price_included:
|
||||
return TAXED_ZERO
|
||||
except ItemAddOn.DoesNotExist:
|
||||
pass
|
||||
|
||||
price = get_listed_price(item, variation, subevent)
|
||||
price = item.default_price
|
||||
if subevent and item.pk in subevent.item_price_overrides:
|
||||
price = subevent.item_price_overrides[item.pk]
|
||||
|
||||
if variation is not None:
|
||||
if variation.default_price is not None:
|
||||
price = variation.default_price
|
||||
if subevent and variation.pk in subevent.var_price_overrides:
|
||||
price = subevent.var_price_overrides[variation.pk]
|
||||
|
||||
if voucher:
|
||||
price = voucher.calculate_price(price, max_discount=max_discount)
|
||||
@@ -76,10 +85,10 @@ def get_price(item: Item, variation: ItemVariation = None,
|
||||
price = tax_rule.tax(price, invoice_address=invoice_address)
|
||||
|
||||
if custom_price_is_net:
|
||||
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net', override_tax_rate=price.rate,
|
||||
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net',
|
||||
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
else:
|
||||
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', override_tax_rate=price.rate,
|
||||
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', gross_price_is_tax_rate=custom_price_is_tax_rate,
|
||||
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
else:
|
||||
price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
@@ -89,83 +98,3 @@ def get_price(item: Item, variation: ItemVariation = None,
|
||||
price.tax = price.gross - price.net
|
||||
|
||||
return price
|
||||
|
||||
|
||||
def is_included_for_free(item: Item, addon_to: AbstractPosition):
|
||||
if addon_to:
|
||||
try:
|
||||
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
|
||||
if iao.price_included:
|
||||
return True
|
||||
except ItemAddOn.DoesNotExist:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubEvent = None) -> Decimal:
|
||||
price = item.default_price
|
||||
if subevent and item.pk in subevent.item_price_overrides:
|
||||
price = subevent.item_price_overrides[item.pk]
|
||||
|
||||
if variation is not None:
|
||||
if variation.default_price is not None:
|
||||
price = variation.default_price
|
||||
if subevent and variation.pk in subevent.var_price_overrides:
|
||||
price = subevent.var_price_overrides[variation.pk]
|
||||
|
||||
return price
|
||||
|
||||
|
||||
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
|
||||
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal) -> TaxedPrice:
|
||||
if not tax_rule:
|
||||
tax_rule = TaxRule(
|
||||
name='',
|
||||
rate=Decimal('0.00'),
|
||||
price_includes_tax=True,
|
||||
eu_reverse_charge=False,
|
||||
)
|
||||
if custom_price_input:
|
||||
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address)
|
||||
|
||||
if custom_price_input_is_net:
|
||||
price = tax_rule.tax(max(custom_price_input, price.net), base_price_is='net', override_tax_rate=price.rate,
|
||||
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
else:
|
||||
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
|
||||
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
else:
|
||||
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
|
||||
|
||||
return price
|
||||
|
||||
|
||||
def apply_discounts(event: Event, sales_channel: str,
|
||||
positions: List[Tuple[int, Optional[int], Decimal, bool, bool]]) -> List[Decimal]:
|
||||
"""
|
||||
Applies any dynamic discounts to a cart
|
||||
|
||||
:param event: Event the cart belongs to
|
||||
:param sales_channel: Sales channel the cart was created with
|
||||
:param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
|
||||
:return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input
|
||||
"""
|
||||
new_prices = {}
|
||||
|
||||
discount_qs = event.discounts.filter(
|
||||
Q(available_from__isnull=True) | Q(available_from__lte=now()),
|
||||
Q(available_until__isnull=True) | Q(available_until__gte=now()),
|
||||
sales_channels__contains=sales_channel,
|
||||
active=True,
|
||||
).prefetch_related('condition_limit_products').order_by('position', 'pk')
|
||||
for discount in discount_qs:
|
||||
result = discount.apply({
|
||||
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
|
||||
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions)
|
||||
if not is_bundled and idx not in new_prices
|
||||
})
|
||||
for k in result.keys():
|
||||
result[k] = (result[k], discount)
|
||||
new_prices.update(result)
|
||||
|
||||
return [new_prices.get(idx, (p[2], None)) for idx, p in enumerate(positions)]
|
||||
|
||||
@@ -132,7 +132,6 @@ def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
|
||||
'already used in a voucher.', s.name))
|
||||
|
||||
Seat.objects.bulk_create(create_seats)
|
||||
CartPosition.objects.filter(addon_to__seat__in=[s.pk for s in current_seats.values()]).delete()
|
||||
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()
|
||||
OrderPosition.all.filter(
|
||||
Q(canceled=True) | Q(order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)),
|
||||
|
||||
@@ -51,7 +51,7 @@ from pretix.celery_app import app
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask)
|
||||
def export(event: Event, shredders: List[str], session_key=None, cfid=None) -> None:
|
||||
def export(event: Event, shredders: List[str], session_key=None) -> None:
|
||||
known_shredders = event.get_data_shredders()
|
||||
|
||||
with NamedTemporaryFile() as rawfile:
|
||||
@@ -85,16 +85,13 @@ def export(event: Event, shredders: List[str], session_key=None, cfid=None) -> N
|
||||
|
||||
rawfile.seek(0)
|
||||
|
||||
if cfid:
|
||||
cf = CachedFile.objects.get(pk=cfid)
|
||||
else:
|
||||
cf = CachedFile()
|
||||
cf.date = now()
|
||||
cf.session_key = session_key
|
||||
cf.web_download = True
|
||||
cf.expires = now() + timedelta(hours=1)
|
||||
cf = CachedFile()
|
||||
cf.date = now()
|
||||
cf.filename = event.slug + '.zip'
|
||||
cf.type = 'application/zip'
|
||||
cf.session_key = session_key
|
||||
cf.web_download = True
|
||||
cf.expires = now() + timedelta(hours=1)
|
||||
cf.save()
|
||||
cf.file.save(cachedfile_name(cf, cf.filename), rawfile)
|
||||
|
||||
@@ -118,7 +115,7 @@ def shred(event: Event, fileid: str, confirm_code: str) -> None:
|
||||
if not shredder:
|
||||
continue
|
||||
shredders.append(shredder)
|
||||
if confirm_code is not True and any(shredder.require_download_confirmation for shredder in shredders):
|
||||
if any(shredder.require_download_confirmation for shredder in shredders):
|
||||
if indexdata['confirm_code'] != confirm_code:
|
||||
raise ShredError(_("The confirm code you entered was incorrect."))
|
||||
if event.logentry_set.filter(datetime__gte=parse(indexdata['time'])):
|
||||
|
||||
@@ -555,11 +555,9 @@ DEFAULTS = {
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'serializer_kwargs': dict(
|
||||
min_value=0,
|
||||
max_value=60 * 24 * 7,
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
min_value=0,
|
||||
max_value=60 * 24 * 7,
|
||||
label=_("Reservation period"),
|
||||
required=True,
|
||||
help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
|
||||
@@ -1514,15 +1512,6 @@ DEFAULTS = {
|
||||
"before the order is canceled and a refund is issued."),
|
||||
)
|
||||
},
|
||||
'cancel_allow_user_paid_require_approval_fee_unknown': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Do not show the cancellation fee to users when they request cancellation."),
|
||||
)
|
||||
},
|
||||
'cancel_allow_user_paid_refund_as_giftcard': {
|
||||
'default': 'off',
|
||||
'type': str,
|
||||
@@ -2594,9 +2583,7 @@ Your {organizer} team"""))
|
||||
},
|
||||
'name_scheme': {
|
||||
'default': 'full', # default for new events is 'given_family'
|
||||
'type': str,
|
||||
'serializer_class': serializers.ChoiceField,
|
||||
'serializer_kwargs': {},
|
||||
'type': str
|
||||
},
|
||||
'giftcard_length': {
|
||||
'default': settings.ENTROPY['giftcard_secret'],
|
||||
@@ -2991,9 +2978,6 @@ PERSON_NAME_SCHEMES = OrderedDict([
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
DEFAULTS['name_scheme']['serializer_kwargs']['choices'] = ((k, k) for k in PERSON_NAME_SCHEMES)
|
||||
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS = {
|
||||
# Source: http://www.bitboost.com/ref/international-address-formats.html
|
||||
# This is not a list of countries that *have* states, this is a list of countries where states
|
||||
|
||||
@@ -399,10 +399,7 @@ order_modified = EventPluginSignal()
|
||||
Arguments: ``order``
|
||||
|
||||
This signal is sent out every time an order's information is modified. The order object is given
|
||||
as the first argument. In contrast to ``order_changed``, this signal is sent out if information
|
||||
of an order or any of it's position is changed that concerns user input, such as attendee names,
|
||||
invoice addresses or question answers. If the order changes in a material way, such as changed
|
||||
products, prices, or tax rates, ``order_changed`` is used instead.
|
||||
as the first argument.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
@@ -412,10 +409,7 @@ order_changed = EventPluginSignal()
|
||||
Arguments: ``order``
|
||||
|
||||
This signal is sent out every time an order's content is changed. The order object is given
|
||||
as the first argument. In contrast to ``modified``, this signal is sent out if the order or
|
||||
any of its positions changes in a material way, such as changed products, prices, or tax rates,
|
||||
``order_changed`` is used instead. If "only" user input is changed, such as attendee names,
|
||||
invoice addresses or question answers, ``order_modified`` is used instead.
|
||||
as the first argument.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
@@ -96,7 +96,6 @@ ALLOWED_ATTRIBUTES = {
|
||||
'div': ['class'],
|
||||
'p': ['class'],
|
||||
'span': ['class', 'title'],
|
||||
'ol': ['start'],
|
||||
# Update doc/user/markdown.rst if you change this!
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user