mirror of
https://github.com/pretix/pretix.git
synced 2025-12-06 21:42:49 +00:00
Compare commits
239 Commits
validate-d
...
v3.16.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0026d8a0c | ||
|
|
1cee082821 | ||
|
|
6c1a3a4c68 | ||
|
|
156e8413f8 | ||
|
|
46ccce439a | ||
|
|
675de12a5d | ||
|
|
5992892035 | ||
|
|
1c81792cd7 | ||
|
|
73e7d407cd | ||
|
|
fa78583cd3 | ||
|
|
bcba7b70ca | ||
|
|
141c6d04b2 | ||
|
|
9c0da900a2 | ||
|
|
580479b266 | ||
|
|
4adaa2059d | ||
|
|
a900f39121 | ||
|
|
b625d987a9 | ||
|
|
71e7d527d1 | ||
|
|
6fd0880e79 | ||
|
|
8ca253c860 | ||
|
|
63c2852668 | ||
|
|
5b36fa198d | ||
|
|
ef8b6f60b8 | ||
|
|
6ca07662b6 | ||
|
|
45a499ebba | ||
|
|
1bfa4c6fda | ||
|
|
8a169d0496 | ||
|
|
40dbae76ca | ||
|
|
4203087eff | ||
|
|
88bf31bd7a | ||
|
|
3423923d84 | ||
|
|
beb33e21ee | ||
|
|
461ab8ba0a | ||
|
|
7562f333cf | ||
|
|
32f1c32936 | ||
|
|
eb0123e350 | ||
|
|
37ba885c55 | ||
|
|
8330448a94 | ||
|
|
8582bf8158 | ||
|
|
e872180ed1 | ||
|
|
cc88e70db6 | ||
|
|
c335dd35b3 | ||
|
|
cea8efc4a3 | ||
|
|
c6c0f92891 | ||
|
|
d5950821e2 | ||
|
|
78f2581bb8 | ||
|
|
c9f89dc920 | ||
|
|
fb7d38ede0 | ||
|
|
8be2f9ad6b | ||
|
|
c033efbfa2 | ||
|
|
d990f0e927 | ||
|
|
e011b7810d | ||
|
|
0d0bbe1ce5 | ||
|
|
488273d5f2 | ||
|
|
9fdaf040dc | ||
|
|
d109dde1e1 | ||
|
|
d713398e88 | ||
|
|
0898d13e4c | ||
|
|
04098ce002 | ||
|
|
f2a18325b6 | ||
|
|
4db0530c09 | ||
|
|
938d84b251 | ||
|
|
c65b2aa4f8 | ||
|
|
2583e6166a | ||
|
|
825fd1820b | ||
|
|
c8d039b196 | ||
|
|
72b6ff0389 | ||
|
|
ef4db07e8b | ||
|
|
ef1e5759eb | ||
|
|
9f1079dcc4 | ||
|
|
518c1fbbf2 | ||
|
|
b9c9a03cdd | ||
|
|
5060bac7e0 | ||
|
|
c4be508e26 | ||
|
|
c75f741d4f | ||
|
|
d6ef563f83 | ||
|
|
3f75a935a3 | ||
|
|
246e7c9443 | ||
|
|
3dd685bf7a | ||
|
|
1480bd0690 | ||
|
|
01af8568ca | ||
|
|
74461dde50 | ||
|
|
f0fd4272dc | ||
|
|
a0f60c71b9 | ||
|
|
6b2ab44b26 | ||
|
|
9472d81e55 | ||
|
|
b630174f72 | ||
|
|
25c35b0f73 | ||
|
|
c0792f4171 | ||
|
|
5d490728df | ||
|
|
21fbf095cf | ||
|
|
7b8ad1ebbe | ||
|
|
81f37d9ce5 | ||
|
|
40c4872459 | ||
|
|
f0574755a2 | ||
|
|
4cfedebf3b | ||
|
|
45376dd757 | ||
|
|
0999f41b0c | ||
|
|
565f77d13b | ||
|
|
5ae7a350b0 | ||
|
|
af7d9942f6 | ||
|
|
36efb25b98 | ||
|
|
7a496da945 | ||
|
|
03f1016cc7 | ||
|
|
4d4d2d5fe7 | ||
|
|
98f48e78a8 | ||
|
|
512c9f5301 | ||
|
|
d16c59e86c | ||
|
|
177b0505fd | ||
|
|
01f7a70347 | ||
|
|
3b4c99d450 | ||
|
|
3bb23bb77e | ||
|
|
89da0847ca | ||
|
|
07bed72b5e | ||
|
|
c103288eec | ||
|
|
03648b77b1 | ||
|
|
818d75ddd7 | ||
|
|
20f608caae | ||
|
|
7b3a6d47fc | ||
|
|
d586406c79 | ||
|
|
e214c8cb95 | ||
|
|
81f2b9db30 | ||
|
|
04a6ed20b9 | ||
|
|
d745bcf2c4 | ||
|
|
a1bfe05879 | ||
|
|
f156299cb3 | ||
|
|
023b1535d4 | ||
|
|
ec97dae695 | ||
|
|
f184ca1918 | ||
|
|
7f71ae6e4b | ||
|
|
84bafd94d5 | ||
|
|
ac7502b0a2 | ||
|
|
3c85591568 | ||
|
|
2787935fc6 | ||
|
|
6d432cf824 | ||
|
|
e09853c6c6 | ||
|
|
418c9196ba | ||
|
|
a949fd7fdc | ||
|
|
f9b834b798 | ||
|
|
0747f5b8b8 | ||
|
|
33b34f31d1 | ||
|
|
f93c780e6a | ||
|
|
9722e76e5f | ||
|
|
e33d15429e | ||
|
|
41c69aaa2a | ||
|
|
07ed7526c0 | ||
|
|
1043824853 | ||
|
|
a99a254f5c | ||
|
|
0429a0f811 | ||
|
|
c2ba312bad | ||
|
|
a3ff3cda12 | ||
|
|
aeba2a1e26 | ||
|
|
e57291914c | ||
|
|
7165cc4c3b | ||
|
|
fa5f33d3c6 | ||
|
|
c8df9c187e | ||
|
|
35270e7032 | ||
|
|
898ae3e2bc | ||
|
|
76d0c7be3a | ||
|
|
793832402c | ||
|
|
f6a500cd75 | ||
|
|
7a8f90478a | ||
|
|
6ea4315beb | ||
|
|
f3de5d5c96 | ||
|
|
fdc555f74f | ||
|
|
2505389e61 | ||
|
|
da38396191 | ||
|
|
2abe744bdd | ||
|
|
ce79bfb242 | ||
|
|
748cfa3487 | ||
|
|
eb80cf248e | ||
|
|
65e3efa5a3 | ||
|
|
3388c3ab09 | ||
|
|
65ff065f02 | ||
|
|
0f30958937 | ||
|
|
5cef80d58c | ||
|
|
19c328b6e7 | ||
|
|
fc6b644587 | ||
|
|
190ffe8d24 | ||
|
|
18eedd8a5f | ||
|
|
00667aff11 | ||
|
|
f1cd46f6dc | ||
|
|
674d7673ce | ||
|
|
71800074ca | ||
|
|
a7b331a9b0 | ||
|
|
1d541df381 | ||
|
|
32d32d68d9 | ||
|
|
5375f6aec1 | ||
|
|
99f3360c44 | ||
|
|
d391312aab | ||
|
|
70bf422537 | ||
|
|
86932e8a19 | ||
|
|
2d9bf5ecb9 | ||
|
|
c4e8da8ea4 | ||
|
|
715fdadf95 | ||
|
|
1b53d74aa9 | ||
|
|
66621aee6e | ||
|
|
18333041bb | ||
|
|
b4badaa472 | ||
|
|
a856f29426 | ||
|
|
1dab5149d4 | ||
|
|
4e870b7366 | ||
|
|
a8cbb06bb0 | ||
|
|
0be2043ded | ||
|
|
2554c7f5fc | ||
|
|
3912ceb79d | ||
|
|
593fc69d0c | ||
|
|
cf3c4d26cb | ||
|
|
bc8358cd97 | ||
|
|
e2461ab475 | ||
|
|
f97c97e661 | ||
|
|
1325cf1e7c | ||
|
|
ba8ea0e4d4 | ||
|
|
1c769f2876 | ||
|
|
2dee222482 | ||
|
|
d132cd27f3 | ||
|
|
9a2a4bedeb | ||
|
|
779cefeaad | ||
|
|
b36feb229f | ||
|
|
2e5861958d | ||
|
|
01c3b08583 | ||
|
|
5b81507600 | ||
|
|
75e100f108 | ||
|
|
8b08b43e77 | ||
|
|
9d70fd675c | ||
|
|
72504cd53a | ||
|
|
9056826b68 | ||
|
|
ecf05b2392 | ||
|
|
4aa9f073b3 | ||
|
|
19c2b8d89d | ||
|
|
5e355b4005 | ||
|
|
746c140cdb | ||
|
|
be413693ce | ||
|
|
6cf1074b8d | ||
|
|
504067f325 | ||
|
|
b1cffe9f72 | ||
|
|
c0dd631774 | ||
|
|
66cd63036c | ||
|
|
29a45d3ee4 |
23
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
23
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Please only create issues for bug reports. Feature requests or general questions
|
||||
should start as a "Discussion" on GitHub.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Please only create issues for bug reports. Feature requests or general questions should start as a "Discussion" on GitHub. -->
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Install system packages
|
||||
run: sudo apt update && sudo apt install enchant hunspell aspell-en
|
||||
- name: Install Dependencies
|
||||
run: pip3 install --no-use-pep517 -Ur doc/requirements.txt
|
||||
run: pip3 install -Ur doc/requirements.txt
|
||||
- name: Spellcheck docs
|
||||
run: make spelling
|
||||
working-directory: ./doc
|
||||
|
||||
4
.github/workflows/strings.yml
vendored
4
.github/workflows/strings.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Install system packages
|
||||
run: sudo apt update && sudo apt install gettext
|
||||
- name: Install Dependencies
|
||||
run: pip3 install --no-use-pep517 -Ur src/requirements.txt
|
||||
run: pip3 install -Ur src/requirements.txt
|
||||
- name: Compile messages
|
||||
run: python manage.py compilemessages
|
||||
working-directory: ./src
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
- name: Install system packages
|
||||
run: sudo apt update && sudo apt install enchant hunspell hunspell-de-de aspell-en aspell-de
|
||||
- name: Install Dependencies
|
||||
run: pip3 install --no-use-pep517 -Ur src/requirements/dev.txt
|
||||
run: pip3 install -Ur src/requirements/dev.txt
|
||||
- name: Spellcheck translations
|
||||
run: potypo
|
||||
working-directory: ./src
|
||||
|
||||
4
.github/workflows/style.yml
vendored
4
.github/workflows/style.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install Dependencies
|
||||
run: pip3 install --no-use-pep517 -Ur src/requirements/dev.txt
|
||||
run: pip3 install -Ur src/requirements/dev.txt
|
||||
- name: Run isort
|
||||
run: isort -c .
|
||||
working-directory: ./src
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt
|
||||
run: pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
- name: Run flake8
|
||||
run: flake8 .
|
||||
working-directory: ./src
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Install system dependencies
|
||||
run: sudo apt update && sudo apt install gettext mysql-client
|
||||
- name: Install Python dependencies
|
||||
run: pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt mysqlclient psycopg2-binary
|
||||
run: pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt mysqlclient psycopg2-binary
|
||||
- name: Run checks
|
||||
run: python manage.py check
|
||||
working-directory: ./src
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
file=/tmp/supervisor.sock
|
||||
|
||||
[supervisord]
|
||||
logfile=/tmp/supervisord.log
|
||||
logfile_maxbytes=50MB
|
||||
logfile_backups=10
|
||||
logfile=/dev/stdout
|
||||
logfile_maxbytes=0
|
||||
loglevel=info
|
||||
pidfile=/tmp/supervisord.pid
|
||||
nodaemon=false
|
||||
|
||||
@@ -3,5 +3,7 @@ command=/usr/sbin/nginx
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=10
|
||||
stdout_events_enabled=true
|
||||
stderr_events_enabled=true
|
||||
stdout_logfile=/dev/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/fd/2
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
@@ -4,3 +4,7 @@ autostart=true
|
||||
autorestart=true
|
||||
priority=5
|
||||
user=pretixuser
|
||||
stdout_logfile=/dev/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/fd/2
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
@@ -5,3 +5,7 @@ autorestart=true
|
||||
priority=5
|
||||
user=pretixuser
|
||||
environment=HOME=/pretix
|
||||
stdout_logfile=/dev/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/fd/2
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
@@ -95,6 +95,12 @@ pretix_model_instances
|
||||
the ``model`` name. Starting with pretix 3.11, these numbers might only be approximate for
|
||||
most tables when running on PostgreSQL to mitigate performance impact.
|
||||
|
||||
pretix_celery_tasks_queued_count
|
||||
The number of background tasks in the worker queue, labeled with ``queue``.
|
||||
|
||||
pretix_celery_tasks_queued_age_seconds
|
||||
The age of the longest-waiting in the worker queue in seconds, labeled with ``queue``.
|
||||
|
||||
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
|
||||
.. _Prometheus: https://prometheus.io/
|
||||
.. _cProfile: https://docs.python.org/3/library/profile.html
|
||||
|
||||
@@ -183,6 +183,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
|
||||
constructed from a number of
|
||||
days before the base point
|
||||
and the base point.
|
||||
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
|
||||
specifiers in requests
|
||||
(see below).
|
||||
===================== ============================ ===================================
|
||||
|
||||
Query parameters
|
||||
@@ -227,4 +230,48 @@ We store idempotency keys for 24 hours, so you should never retry a request afte
|
||||
All ``POST``, ``PUT``, ``PATCH``, or ``DELETE`` api calls support idempotency keys. Adding an idempotency key to a
|
||||
``GET``, ``HEAD``, or ``OPTIONS`` request has no effect.
|
||||
|
||||
|
||||
File upload
|
||||
-----------
|
||||
|
||||
In some places, the API supports working with files, for example when setting the picture of a product. In this case,
|
||||
you will first need to make a separate request to our file upload endpoint:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/upload HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k
|
||||
Content-Type: image/png
|
||||
Content-Disposition: attachment; filename="logo.png"
|
||||
Content-Length: 1234
|
||||
|
||||
<raw file content>
|
||||
|
||||
Note that the ``Content-Type`` and ``Content-Disposition`` headers are required. If the upload was successful, you will
|
||||
receive a JSON response with the ID of the file:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1"
|
||||
}
|
||||
|
||||
You can then use this file ID in the request you want to use it in. File IDs are currently valid for 24 hours and can only
|
||||
be used using the same authorization method and user that was used to upload them.
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/test/events/test/items/3/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"picture": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1"
|
||||
}
|
||||
|
||||
|
||||
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax
|
||||
|
||||
@@ -8,4 +8,5 @@ This part of the documentation contains how-to guides on some special use cases
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
order_lifecycle
|
||||
custom_checkout
|
||||
|
||||
56
doc/api/guides/order_lifecycle.rst
Normal file
56
doc/api/guides/order_lifecycle.rst
Normal file
@@ -0,0 +1,56 @@
|
||||
Understanding the life cycle of orders
|
||||
======================================
|
||||
|
||||
When integrating pretix with other systems, it is important that you understand how orders and related objects
|
||||
such as order positions, fees, payments, refunds, and invoices work together, in order to react to their changes
|
||||
properly and map them to processes in your system.
|
||||
|
||||
Order states
|
||||
------------
|
||||
|
||||
Generally, an order can be in six states. For compatibility reasons, the ``status`` field only allows four values
|
||||
and the two remaining states are modeled through the ``require_approval`` field and the number of positions within
|
||||
an order. The states and their allowed changes are shown in the following graph:
|
||||
|
||||
.. image:: /images/order_states.png
|
||||
|
||||
|
||||
Object types
|
||||
------------
|
||||
|
||||
Order
|
||||
One order represents one purchase. It's the main object you interact with and bundles all the other objects
|
||||
together. Orders can change in many ways during their lifetime, but will never be deleted (unless ``testmode``
|
||||
is set to ``true``).
|
||||
|
||||
Order position
|
||||
An order position represents one product contained in the order. Orders can usually have multiple positions.
|
||||
There might be a parent-child relation between order positions if one position is an add-on to another position.
|
||||
Order positions can change in many ways during their lifetime, and can also be removed or added to an order.
|
||||
|
||||
Order fees
|
||||
A fee represents a charge that is not related to a product. Examples include shipping fees, service fees, and
|
||||
cancellation fees.
|
||||
Order fees can change in many ways during their lifetime, and can also be removed or added to an order.
|
||||
|
||||
Order payment
|
||||
An order payment represents one payment attempt with a specific payment method and amount. An order can have
|
||||
multiple payments attached.
|
||||
Order payments have their own state diagram. Apart from their state and their meta information (e.g. used
|
||||
credit card, …) they usually don't change. They may be added at any time, but will never be deleted.
|
||||
|
||||
Order refund
|
||||
An order payment represents one refund attempt with a specific payment method and amount. An order can have
|
||||
multiple refunds attached.
|
||||
Order refunds have their own state diagram. Apart from their state and their meta information (e.g. used
|
||||
credit card, …) they usually don't change. They may be added at any time, but will never be deleted.
|
||||
|
||||
Invoice
|
||||
An invoice represents a legal document stating the contents of an order. While the backend technically allows
|
||||
to update an invoice in some situations, invoices are generally considered immutable. Once they are issued,
|
||||
they no longer change. If the order changes substantially (e.g. prices change), an invoice is canceled through
|
||||
creation of a new invoice with the opposite amount, plus the issuance of a new invoice.
|
||||
|
||||
Here's an example of how they all play together:
|
||||
|
||||
.. image:: /images/order_objects.png
|
||||
@@ -42,10 +42,6 @@ seat objects The assigned se
|
||||
└ seat_guid string Identifier of the seat within the seating plan
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.17
|
||||
|
||||
This resource has been added.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
This ``seat`` attribute has been added.
|
||||
|
||||
@@ -25,14 +25,6 @@ is_addon boolean If ``true``, it
|
||||
defining add-ons for other products.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.14
|
||||
|
||||
The operations POST, PATCH, PUT and DELETE have been added.
|
||||
|
||||
.. versionchanged:: 1.16
|
||||
|
||||
The field ``internal_name`` has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -36,22 +36,6 @@ rules object Custom check-in
|
||||
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.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.10
|
||||
|
||||
This resource has been added.
|
||||
|
||||
.. versionchanged:: 1.11
|
||||
|
||||
The ``positions`` endpoints have been added.
|
||||
|
||||
.. versionchanged:: 1.13
|
||||
|
||||
The ``include_pending`` field has been added.
|
||||
|
||||
.. versionchanged:: 3.2
|
||||
|
||||
The ``auto_checkin_sales_channels`` field has been added.
|
||||
|
||||
.. versionchanged:: 3.9
|
||||
|
||||
The ``subevent`` attribute may now be ``null`` inside event series. The ``allow_multiple_entries``,
|
||||
@@ -68,10 +52,6 @@ exit_all_at datetime Automatically c
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The ``../status/`` detail endpoint has been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/
|
||||
|
||||
Returns a list of all check-in lists within a given event.
|
||||
@@ -380,29 +360,6 @@ Endpoints
|
||||
Order position endpoints
|
||||
------------------------
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The order positions endpoint has been extended by the filter queries ``item__in``, ``variation__in``,
|
||||
``order__status__in``, ``subevent__in``, ``addon_to__in``, and ``search``. The search for attendee names and order
|
||||
codes is now case-insensitive.
|
||||
|
||||
The ``.../redeem/`` endpoint has been added.
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
The order positions endpoint has been extended by the filter queries ``voucher`` and ``voucher__code``.
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
|
||||
The resource now contains the new attributes ``require_attention`` and ``order__status`` and accepts the new
|
||||
``ignore_status`` filter. The ``attendee_name`` field is now "smart" (see below) and the redemption endpoint
|
||||
returns ``400`` instead of ``404`` on tickets which are known but not paid.
|
||||
|
||||
.. versionchanged:: 3.2
|
||||
|
||||
The ``checkins`` dict now also contains a ``auto_checked_in`` value to indicate if the check-in has been performed
|
||||
automatically by the system.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/
|
||||
|
||||
Returns a list of all order positions within a given event. The result is the same as
|
||||
|
||||
@@ -52,31 +52,6 @@ sales_channels list A list of sales
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
|
||||
The ``meta_data`` field has been added.
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The ``plugins`` field has been added.
|
||||
The operations POST, PATCH, PUT and DELETE have been added.
|
||||
|
||||
.. versionchanged:: 2.1
|
||||
|
||||
Filters have been added to the list of events.
|
||||
|
||||
.. versionchanged:: 2.5
|
||||
|
||||
The ``testmode`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.8
|
||||
|
||||
When cloning events, the ``testmode`` attribute will now be cloned, too.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The attributes ``geo_lat`` and ``geo_lon`` have been added.
|
||||
|
||||
@@ -46,24 +46,6 @@ internal_reference string Customer's refe
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 1.6
|
||||
|
||||
The attribute ``invoice_no`` has been dropped in favor of ``number`` which includes the number including the prefix,
|
||||
since the prefix can now vary. Also, invoices now need to be identified by their ``number`` instead of the raw
|
||||
number.
|
||||
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
|
||||
The attributes ``lines.tax_name``, ``foreign_currency_display``, ``foreign_currency_rate``, and
|
||||
``foreign_currency_rate_date`` have been added.
|
||||
|
||||
|
||||
.. versionchanged:: 1.9
|
||||
|
||||
The attribute ``internal_reference`` has been added.
|
||||
|
||||
|
||||
.. versionchanged:: 3.4
|
||||
|
||||
The attribute ``lines.number`` has been added.
|
||||
|
||||
@@ -28,10 +28,6 @@ multi_allowed boolean Adding the same
|
||||
price_included boolean Adding this add-on to the item is free
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
This resource has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
|
||||
@@ -30,10 +30,6 @@ designated_price money (string) Designated pric
|
||||
taxation. This is not added to the price.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2.6
|
||||
|
||||
This resource has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
|
||||
@@ -26,14 +26,6 @@ description multi-lingual string A public descri
|
||||
position integer An integer, used for sorting
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
|
||||
The attribute ``original_price`` has been added.
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
This resource has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ admission boolean ``true`` for it
|
||||
(such as primary tickets) and ``false`` for others
|
||||
(such as add-ons or merchandise).
|
||||
position integer An integer, used for sorting
|
||||
picture string A product picture to be displayed in the shop
|
||||
(read-only, can be ``null``).
|
||||
picture file A product picture to be displayed in the shop
|
||||
(can be ``null``).
|
||||
sales_channels list of strings Sales channels this product is available on, such as
|
||||
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
|
||||
available_from datetime The first date time at which this item can be bought
|
||||
@@ -118,44 +118,6 @@ bundles list of objects Definition of b
|
||||
meta_data object Values set for event-specific meta data parameters.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
|
||||
The attribute ``original_price`` has been added for ``variations``.
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
|
||||
The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility. The attribute
|
||||
``checkin_attention`` has been added.
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
|
||||
The attribute ``price_included`` has been added to ``addons``.
|
||||
|
||||
.. versionchanged:: 1.16
|
||||
|
||||
The ``internal_name`` and ``original_price`` fields have been added.
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
The field ``require_approval`` has been added.
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
The ``sales_channels`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.4
|
||||
|
||||
The ``generate_tickets`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.6
|
||||
|
||||
The ``bundles`` and ``require_bundling`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The ``show_quota_left``, ``allow_waitinglist``, and ``hidden_if_available`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 3.7
|
||||
|
||||
The attribute ``meta_data`` has been added.
|
||||
|
||||
@@ -94,60 +94,6 @@ last_modified datetime Last modificati
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 1.6
|
||||
|
||||
The ``invoice_address.country`` attribute contains a two-letter country code for all new orders. For old orders,
|
||||
a custom text might still be returned.
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
|
||||
The attributes ``invoice_address.vat_id_validated`` and ``invoice_address.is_business`` have been added.
|
||||
The attributes ``order.payment_fee``, ``order.payment_fee_tax_rate`` and ``order.payment_fee_tax_value`` have been
|
||||
deprecated in favor of the new ``fees`` attribute but will still be served and removed in 1.9.
|
||||
|
||||
.. versionchanged:: 1.9
|
||||
|
||||
First write operations (``…/mark_paid/``, ``…/mark_pending/``, ``…/mark_canceled/``, ``…/mark_expired/``) have been added.
|
||||
The attribute ``invoice_address.internal_reference`` has been added.
|
||||
|
||||
.. versionchanged:: 1.13
|
||||
|
||||
The field ``checkin_attention`` has been added.
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The attributes ``order.payment_fee``, ``order.payment_fee_tax_rate``, ``order.payment_fee_tax_value`` and
|
||||
``order.payment_fee_tax_rule`` have finally been removed.
|
||||
|
||||
.. versionchanged:: 1.16
|
||||
|
||||
The attributes ``order.last_modified`` as well as the corresponding filters to the resource have been added.
|
||||
An endpoint for order creation as well as ``…/mark_refunded/`` has been added.
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
The ``order.payment_date`` and ``order.payment_provider`` attributes have been deprecated in favor of the new
|
||||
nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. The ``require_approval``
|
||||
attribute has been added, as have been the ``…/approve/`` and ``…/deny/`` endpoints.
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
The ``sales_channel`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.4
|
||||
|
||||
``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and
|
||||
``…/mark_refunded/`` has been deprecated.
|
||||
|
||||
.. versionchanged:: 2.5
|
||||
|
||||
The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders.
|
||||
|
||||
.. versionchanged:: 3.1
|
||||
|
||||
The ``invoice_address.state`` and ``url`` attributes have been added. When creating orders through the API,
|
||||
vouchers are now supported and many fields are now optional.
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The ``order.fees.canceled`` attribute has been added.
|
||||
@@ -220,7 +166,7 @@ downloads list of objects List of ticket
|
||||
└ url string Download URL
|
||||
answers list of objects Answers to user-defined questions
|
||||
├ question integer Internal ID of the answered question
|
||||
├ answer string Text representation of the answer
|
||||
├ answer string Text representation of the answer (URL if answer is a file)
|
||||
├ question_identifier string The question's ``identifier`` field
|
||||
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
|
||||
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
|
||||
@@ -233,30 +179,6 @@ pdf_data object Data object req
|
||||
``pdf_data=true`` query parameter to your request.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
|
||||
The attribute ``tax_rule`` has been added.
|
||||
|
||||
.. versionchanged:: 1.11
|
||||
|
||||
The attribute ``checkins.list`` has been added.
|
||||
|
||||
.. versionchanged:: 1.14
|
||||
|
||||
The attributes ``answers.question_identifier`` and ``answers.option_identifiers`` have been added.
|
||||
|
||||
.. versionchanged:: 1.16
|
||||
|
||||
The attributes ``pseudonymization_id`` and ``pdf_data`` have been added.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The attribute ``seat`` has been added.
|
||||
|
||||
.. versionchanged:: 3.2
|
||||
|
||||
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The ``url`` of a ticket ``download`` can now also return a ``text/uri-list`` instead of a file. See
|
||||
@@ -274,6 +196,10 @@ pdf_data object Data object req
|
||||
|
||||
The ``checkin.type`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 3.16
|
||||
|
||||
Answers to file questions are now returned as an URL.
|
||||
|
||||
.. _order-payment-resource:
|
||||
|
||||
Order payment resource
|
||||
@@ -302,14 +228,6 @@ details object Payment-specifi
|
||||
the object is empty.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
This resource has been added.
|
||||
|
||||
.. versionchanged:: 3.1
|
||||
|
||||
The attributes ``payment_url`` and ``details`` have been added.
|
||||
|
||||
.. _order-refund-resource:
|
||||
|
||||
Order refund resource
|
||||
@@ -325,21 +243,14 @@ state string Payment state,
|
||||
source string How this refund has been created, one of ``buyer``, ``admin``, or ``external``
|
||||
amount money (string) Payment amount
|
||||
created datetime Date and time of creation of this payment
|
||||
payment_date datetime Date and time of completion of this payment (or ``null``)
|
||||
comment string Reason for refund (shown to the customer in some cases, can be ``null``).
|
||||
execution_date datetime Date and time of completion of this refund (or ``null``)
|
||||
provider string Identification string of the payment provider
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
This resource has been added.
|
||||
|
||||
List of all orders
|
||||
------------------
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
Filtering for emails or order codes is now case-insensitive.
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
|
||||
@@ -1445,21 +1356,6 @@ Sending e-mails
|
||||
List of all order positions
|
||||
---------------------------
|
||||
|
||||
.. versionchanged:: 1.15
|
||||
|
||||
The order positions endpoint has been extended by the filter queries ``item__in``, ``variation__in``,
|
||||
``order__status__in``, ``subevent__in``, ``addon_to__in`` and ``search``. The search for attendee names and order
|
||||
codes is now case-insensitive.
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
|
||||
``pseudonymization_id``.
|
||||
|
||||
.. versionchanged:: 3.2
|
||||
|
||||
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The ``include_canceled_positions`` and ``include_canceled_fees`` query parameters have been added.
|
||||
@@ -1706,6 +1602,67 @@ Order position ticket download
|
||||
Manipulating individual positions
|
||||
---------------------------------
|
||||
|
||||
.. versionchanged:: 3.15
|
||||
|
||||
The ``PATCH`` method has been added for individual positions.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
||||
|
||||
Updates specific fields on an order position. Currently, only the following fields are supported:
|
||||
|
||||
* ``attendee_email``
|
||||
|
||||
* ``attendee_name_parts`` or ``attendee_name``
|
||||
|
||||
* ``company``
|
||||
|
||||
* ``street``
|
||||
|
||||
* ``zipcode``
|
||||
|
||||
* ``city``
|
||||
|
||||
* ``country``
|
||||
|
||||
* ``state``
|
||||
|
||||
* ``answers``: If specified, you will need to provide **all** answers for this order position.
|
||||
Validation is handled the same way as when creating orders through the API. You are therefore
|
||||
expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier``
|
||||
and ``option_identifiers`` will be ignored.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"attendee_email": "other@example.org"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
(Full order resource, see above.)
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event
|
||||
:param event: The ``slug`` field of the event
|
||||
:param id: The ``id`` field of the order position to update
|
||||
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The order could not be updated due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
||||
|
||||
Deletes an order position, identified by its internal ID.
|
||||
@@ -1738,10 +1695,6 @@ Manipulating individual positions
|
||||
Order payment endpoints
|
||||
-----------------------
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
These endpoints have been added.
|
||||
|
||||
.. versionchanged:: 3.6
|
||||
|
||||
Payments can now be created through the API.
|
||||
@@ -2021,10 +1974,6 @@ Order payment endpoints
|
||||
Order refund endpoints
|
||||
----------------------
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
These endpoints have been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/
|
||||
|
||||
Returns a list of all refunds for an order.
|
||||
@@ -2058,6 +2007,7 @@ Order refund endpoints
|
||||
"payment": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"execution_date": "2017-12-04T12:13:12Z",
|
||||
"comment": "Cancellation",
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
]
|
||||
@@ -2100,6 +2050,7 @@ Order refund endpoints
|
||||
"payment": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"execution_date": "2017-12-04T12:13:12Z",
|
||||
"comment": "Cancellation",
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
|
||||
@@ -2134,6 +2085,7 @@ Order refund endpoints
|
||||
"amount": "23.00",
|
||||
"payment": 1,
|
||||
"execution_date": null,
|
||||
"comment": "Cancellation",
|
||||
"provider": "manual",
|
||||
"mark_canceled": false,
|
||||
"mark_pending": true
|
||||
@@ -2155,6 +2107,7 @@ Order refund endpoints
|
||||
"payment": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"execution_date": null,
|
||||
"comment": "Cancellation",
|
||||
"provider": "manual"
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,6 @@ identifier string An arbitrary st
|
||||
answer multi-lingual string The displayed value of this option
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
This resource has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
|
||||
@@ -75,28 +75,6 @@ dependency_value string An old version
|
||||
for one value. **Deprecated.**
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
The values ``D``, ``H``, and ``W`` for the field ``type`` are now allowed and the ``ask_during_checkin`` field has
|
||||
been added.
|
||||
|
||||
.. versionchanged:: 1.14
|
||||
|
||||
Write methods have been added. The attribute ``identifier`` has been added to both the resource itself and the
|
||||
options resource. The ``position`` attribute has been added to the options resource.
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
|
||||
The attribute ``hidden`` and the question type ``CC`` have been added.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The attribute ``dependency_values`` has been added.
|
||||
|
||||
.. versionchanged:: 3.1
|
||||
|
||||
The attribute ``print_on_invoice`` has been added.
|
||||
|
||||
.. versionchanged:: 3.5
|
||||
|
||||
The attribute ``help_text`` has been added.
|
||||
|
||||
@@ -30,14 +30,6 @@ release_after_exit boolean Whether the quo
|
||||
have been scanned at an exit.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.10
|
||||
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The attributes ``close_when_sold_out`` and ``closed`` have been added.
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The attribute ``release_after_exit`` has been added.
|
||||
|
||||
@@ -20,10 +20,6 @@ layout object JSON representa
|
||||
still evolves. The version in use can be found `here`_.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
This endpoint has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ date_to datetime The sub-event's
|
||||
date_admission datetime The sub-event's admission date (or ``null``)
|
||||
presale_start datetime The sub-date at which the ticket shop opens (or ``null``)
|
||||
presale_end datetime The sub-date at which the ticket shop closes (or ``null``)
|
||||
frontpage_text multi-lingual string The description of the event (or ``null``)
|
||||
location multi-lingual string The sub-event location (or ``null``)
|
||||
geo_lat float Latitude of the location (or ``null``)
|
||||
geo_lon float Longitude of the location (or ``null``)
|
||||
@@ -54,25 +55,6 @@ seat_category_mapping object An object mappi
|
||||
last_modified datetime Last modification of this object
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
|
||||
The ``meta_data`` field has been added.
|
||||
|
||||
.. versionchanged:: 2.1
|
||||
|
||||
The ``event`` field has been added, together with filters on the list of dates and an organizer-level list.
|
||||
|
||||
.. versionchanged:: 2.6
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
|
||||
The attribute ``is_public`` has been added.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
|
||||
|
||||
.. versionchanged:: 3.3
|
||||
|
||||
The attributes ``geo_lat`` and ``geo_lon`` have been added.
|
||||
|
||||
@@ -24,14 +24,6 @@ home_country string Merchant countr
|
||||
``null`` or empty string
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
|
||||
This resource has been added.
|
||||
|
||||
.. versionchanged:: 1.9
|
||||
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -46,14 +46,6 @@ show_hidden_items boolean Only if set to
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 1.9
|
||||
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The attribute ``show_hidden_items`` has been added.
|
||||
|
||||
.. versionchanged:: 3.4
|
||||
|
||||
The attribute ``seat`` has been added.
|
||||
|
||||
@@ -34,7 +34,7 @@ Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header
|
||||
:members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
@@ -79,7 +79,7 @@ Ticket designs
|
||||
""""""""""""""
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: layout_text_variables
|
||||
:members: layout_text_variables, layout_image_variables
|
||||
|
||||
.. automodule:: pretix.plugins.ticketoutputpdf.signals
|
||||
:members: override_layout
|
||||
|
||||
@@ -106,14 +106,22 @@ The provider class
|
||||
|
||||
.. automethod:: payment_control_render
|
||||
|
||||
.. automethod:: payment_control_render_short
|
||||
|
||||
.. automethod:: payment_refund_supported
|
||||
|
||||
.. automethod:: payment_partial_refund_supported
|
||||
|
||||
.. automethod:: payment_presale_render
|
||||
|
||||
.. automethod:: execute_refund
|
||||
|
||||
.. automethod:: refund_control_render
|
||||
|
||||
.. automethod:: new_refund_control_form_render
|
||||
|
||||
.. automethod:: new_refund_control_form_process
|
||||
|
||||
.. automethod:: api_payment_details
|
||||
|
||||
.. automethod:: matching_id
|
||||
|
||||
@@ -82,11 +82,15 @@ Orders
|
||||
^^^^^^
|
||||
|
||||
If a customer completes the checkout process, an **Order** will be created containing all the entered information.
|
||||
An order can be in one of currently four states that are listed in the diagram below:
|
||||
An order can be in one of currently six states that are listed in the diagram below:
|
||||
|
||||
.. image:: /images/order_states.png
|
||||
|
||||
There are additional "fake" states that are displayed like states but not represented as states in the system:
|
||||
The dotted lines represent status changes that usually do not happen as part of the regular process, but can be
|
||||
performed manually in the admin backend.
|
||||
|
||||
For historical reasons, there are only four valid values of the ``status`` field, and the two additional states are
|
||||
represented differently:
|
||||
|
||||
* An order is considered **canceled (with paid fee)** if it is in **paid** status but does not include any non-cancelled positions.
|
||||
|
||||
|
||||
BIN
doc/images/order_objects.png
Normal file
BIN
doc/images/order_objects.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
34
doc/images/order_objects.puml
Normal file
34
doc/images/order_objects.puml
Normal file
@@ -0,0 +1,34 @@
|
||||
@startuml
|
||||
|
||||
participant User
|
||||
collections "OrderPayment\nOrderRefund" as P
|
||||
collections "Order\nOrderPosition" as O
|
||||
collections "Invoice\nInvoiceLine" as I
|
||||
|
||||
User -> O: Order placed (€100)
|
||||
rnote over O #6DD96D: Order A1B2C\nstatus = **n**\ntotal = €100
|
||||
O -> P: Payment created
|
||||
O -> I: Invoice created\n(can also happen later)
|
||||
rnote over I #6DD96D: Invoice 00001\n€100
|
||||
rnote over P #6DD96D: OrderPayment A1B2C-P-1\nstate = **created**
|
||||
P -> User: Payment details (web, email)
|
||||
User -> P: Payment performed
|
||||
rnote over P #EFF46B: OrderPayment A1B2C-P-1\nstate = **confirmed**
|
||||
P -> O: Order marked as paid
|
||||
rnote over O #EFF46B: Order A1B2C\nstatus = **p**\ntotal = €100
|
||||
User -> O: Data change (e.g. invoice address)
|
||||
O -> I: Invoice reissued
|
||||
rnote over I #6DD96D: Invoice 00002\n€-100
|
||||
rnote over I #6DD96D: Invoice 00003\n€100
|
||||
rnote over O #EFF46B: Order A1B2C\nstatus = **p**\ntotal = €100
|
||||
User -> O: Order canceled
|
||||
rnote over O #EFF46B: Order A1B2C\nstatus = **c**
|
||||
O -> I: Invoice canceled
|
||||
rnote over I #6DD96D: Invoice 00004\n€-100
|
||||
O -> P: Refund started
|
||||
rnote over P #6DD96D: OrderRefund\nA1B2C-R-1\nstate = **created**
|
||||
P -> User: Money sent
|
||||
rnote over P #EFF46B: OrderRefund\nA1B2C-R-1\nstate = **done**
|
||||
|
||||
@enduml
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 93 KiB |
@@ -1,19 +1,39 @@
|
||||
@startuml
|
||||
|
||||
Pending: Order is expecting payment\nOrder reduces quotas
|
||||
Expired: Payment period is over\nOrder does not affect quotas
|
||||
Paid: Order was successful\nOrder reduces quotas
|
||||
Canceled: Order has been canceled\nOrder does not affect quotas
|
||||
state "Approval Pending" as AP
|
||||
state "Canceled (with paid fee)" as CP
|
||||
AP: status = "n"
|
||||
AP: require_approval = true
|
||||
Pending: status = "n"
|
||||
Pending: require_approval = false
|
||||
Pending: Tickets reserved: yes
|
||||
Expired: status = "e"
|
||||
Expired: Tickets reserved: no
|
||||
Paid: status = "p"
|
||||
Paid: count(positions | !canceled) > 0
|
||||
Paid: Tickets reserved: yes
|
||||
CP: status = "p"
|
||||
CP: count(positions | !canceled) = 0
|
||||
Canceled: status = "c"
|
||||
Canceled: Tickets reserved: no
|
||||
|
||||
[*] --> Pending: customer\nplaces order
|
||||
Pending --> Paid: successful payment
|
||||
Pending --> Expired: automatically\nor manually\non admin action
|
||||
Expired --> Paid: if payment is received\nonly if quota left
|
||||
Expired --> Canceled
|
||||
Expired --> Pending: manually\non admin action
|
||||
Paid --> Canceled: manually on\nadmin action\nor if an external\npayment provider\nnotifies about a\npayment refund
|
||||
Pending --> Canceled: on admin or\ncustomer action
|
||||
Paid -> Pending: manually on admin action
|
||||
[*] --> Paid: customer\nplaces free order
|
||||
|
||||
[*] -> Pending: order placed\ntotal > 0
|
||||
[*] -> Paid: order placed\ntotal = 0
|
||||
[*] -> AP: order placed\napproval required
|
||||
Pending --> Paid: order paid
|
||||
Pending --> Expired: after payment\ndeadline
|
||||
Expired --> Paid: order paid\n(only if quota left)
|
||||
Expired -[dashed]-> Canceled
|
||||
Expired -[dashed]-> Pending: order extended
|
||||
Paid --> Canceled: order canceled
|
||||
Pending --> Canceled: order canceled
|
||||
Paid -[dashed]-> Pending: refund
|
||||
AP --> Pending: order approved
|
||||
AP --> Canceled: order denied
|
||||
Paid --> CP: order canceled\n(with cancellation fee)
|
||||
Canceled -[dashed]-> Pending: order reactivated
|
||||
Canceled -[dashed]-> Paid: order reactivated
|
||||
CP -[dashed]-> Canceled: fee canceled
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -22,10 +22,6 @@ item_assignments list of objects Products this l
|
||||
└ item integer Item ID
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.16
|
||||
|
||||
This resource has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -24,14 +24,6 @@ item_assignments list of objects Products this l
|
||||
└ item integer Item ID
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.16
|
||||
|
||||
This resource has been added.
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
The ``item_assignments.sales_channel`` field has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -64,20 +64,35 @@ is valid in every text):
|
||||
Placeholder Description
|
||||
============================== ===============================================================================
|
||||
event The event name
|
||||
event_slug The event's short form
|
||||
code In case of the waiting list, the voucher code to redeem
|
||||
currency The currency used for the event (three-letter code)
|
||||
total The order's total value
|
||||
total_with_currency The order's total value with a localized currency sign
|
||||
currency The currency used for the event (three-letter code)
|
||||
refund_amount (For cancellation emails) The amount of money that will be refunded, including
|
||||
the currency
|
||||
payment_info Information text specific to the payment method (e.g. banking details)
|
||||
url An URL pointing to the download/status page of the order
|
||||
invoice_name The name field of the invoice address
|
||||
url_info_change An URL pointing to the page of the order that can be used to change ticket
|
||||
information
|
||||
url_products_change An URL pointing to the page of the order that can be used to change the products
|
||||
in the order
|
||||
url_cancel An URL pointing to the page of the order that can be used to cancel the order
|
||||
name, name_* Any name that can be used to address the recipient (e.g. name from invoice address,
|
||||
name from first ticket, …)
|
||||
invoice_name, invoice_name_* The name field of the invoice address
|
||||
invoice_company The company field of the invoice address
|
||||
attendee_name, attendee_name_* The name of the attendee represented by the ticket
|
||||
expire_date The order's expiration date
|
||||
comment When rejecting an order, this will contain the reason for the rejection
|
||||
date The same as ``expire_date``, but in a different e-mail (for backwards
|
||||
compatibility)
|
||||
orders A list of orders including links to their status pages, specific to the "resend
|
||||
link (requested by user)" e-mail
|
||||
code In case of the waiting list, the voucher code to redeem
|
||||
hours In case of the waiting list, the number of hours the voucher code is valid
|
||||
product In case of the waiting list, the product that has become available
|
||||
voucher_list When sending out vouchers in bulk, this will be replaced with the list of
|
||||
vouchers
|
||||
============================== ===============================================================================
|
||||
|
||||
The different e-mails are explained in the following:
|
||||
|
||||
@@ -88,6 +88,15 @@ website. If you confident to have a good reason for not using SSL, you can overr
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" skip-ssl-check></pretix-widget>
|
||||
|
||||
Always open a new tab
|
||||
---------------------
|
||||
|
||||
If you want the checkout process to always open a new tab regardless of screen size, you can pass the ``disable-iframe``
|
||||
attribute::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" disable-iframe></pretix-widget>
|
||||
|
||||
|
||||
Pre-selecting a voucher
|
||||
-----------------------
|
||||
|
||||
@@ -197,7 +206,10 @@ should be added to the cart. The syntax of this attribute is ``item_ITEMID=1,ite
|
||||
where ``ITEMID`` are the internal IDs of items to be added and ``VARID`` are the internal IDs of variations of those
|
||||
items, if the items have variations. If you omit the ``items`` attribute, the general start page will be presented.
|
||||
|
||||
Just as the widget, the button supports the optional attributes ``voucher`` and ``skip-ssl-check``.
|
||||
In case you are using an event-series, you will need to specify the subevent for which the item(s) should be put in the
|
||||
cart. This can be done by specifying the ``subevent``-attribute.
|
||||
|
||||
Just as the widget, the button supports the optional attributes ``voucher``, ``disable-iframe``, and ``skip-ssl-check``.
|
||||
|
||||
You can style the button using the ``pretix-button`` CSS class.
|
||||
|
||||
@@ -304,8 +316,92 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
|
||||
made through this widget will be counted towards this campaign.
|
||||
|
||||
* If you use the tracking plugin, you can pass a Google Analytics User ID to enable cross-domain tracking. This will
|
||||
require you to dynamically load the widget, like this::
|
||||
* If you use the tracking plugin, you can enable cross-domain tracking. To do so, you need to initialize the
|
||||
pretix-widget manually. Use the html code to embed the widget and add one the following code snippets. Make sure to
|
||||
replace all occurrences of <MEASUREMENT_ID> with your Google Analytics MEASUREMENT_ID (UA-XXXXXXX-X or G-XXXXXXXX)
|
||||
|
||||
Please also make sure to add the embedding website to your `Referral exclusions
|
||||
<https://support.google.com/analytics/answer/2795830>`_ in your Google Analytics settings.
|
||||
|
||||
If you use Google Analytics 4 (GA4 – G-XXXXXXXX)::
|
||||
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script>
|
||||
<script type="text/javascript">
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '<MEASUREMENT_ID>');
|
||||
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if (!window['google_tag_manager']) {
|
||||
window.PretixWidget.buildWidgets();
|
||||
return;
|
||||
}
|
||||
|
||||
var clientId;
|
||||
var sessionId;
|
||||
var loadingTimeout;
|
||||
function build() {
|
||||
// use loadingTimeout to make sure build() is only called once
|
||||
if (!loadingTimeout) return;
|
||||
window.clearTimeout(loadingTimeout);
|
||||
loadingTimeout = null;
|
||||
if (clientId) window.PretixWidget.widget_data["tracking-ga-id"] = clientId;
|
||||
if (sessionId) window.PretixWidget.widget_data["tracking-ga-sessid"] = sessionId;
|
||||
window.PretixWidget.buildWidgets();
|
||||
};
|
||||
// make sure to build pretix-widgets if gtag fails to load either client_id or session_id
|
||||
loadingTimeout = window.setTimeout(build, 2000);
|
||||
|
||||
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
|
||||
clientId = id;
|
||||
if (sessionId !== undefined) build();
|
||||
});
|
||||
gtag('get', '<MEASUREMENT_ID>', 'session_id', function(id) {
|
||||
sessionId = id;
|
||||
if (clientId !== undefined) build();
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
If you use Universal Analytics with ``gtag.js`` (UA-XXXXXXX-X)::
|
||||
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script>
|
||||
<script type="text/javascript">
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '<MEASUREMENT_ID>');
|
||||
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if (!window['google_tag_manager']) {
|
||||
window.PretixWidget.buildWidgets();
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure to build pretix-widgets if gtag fails to load client_id
|
||||
var loadingTimeout = window.setTimeout(function() {
|
||||
loadingTimeout = null;
|
||||
window.PretixWidget.buildWidgets();
|
||||
}, 1000);
|
||||
|
||||
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
|
||||
if (loadingTimeout) {
|
||||
window.clearTimeout(loadingTimeout);
|
||||
window.PretixWidget.widget_data["tracking-ga-id"] = id;
|
||||
window.PretixWidget.buildWidgets();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
If you use ```analytics.js` (Universal Analytics)::
|
||||
|
||||
<script>
|
||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||
@@ -313,32 +409,33 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||
|
||||
ga('create', 'UA-XXXXXX-1', 'auto');
|
||||
ga('create', '<MEASUREMENT_ID>', 'auto');
|
||||
ga('send', 'pageview');
|
||||
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if(window.ga && ga.create) {
|
||||
ga(function(tracker) {
|
||||
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
|
||||
window.PretixWidget.buildWidgets()
|
||||
});
|
||||
} else { // Tracking is probably blocked
|
||||
window.PretixWidget.buildWidgets()
|
||||
if (!window['ga'] || !ga.create) {
|
||||
// Tracking is probably blocked
|
||||
window.PretixWidget.buildWidgets()
|
||||
return;
|
||||
}
|
||||
|
||||
var loadingTimeout = window.setTimeout(function() {
|
||||
loadingTimeout = null;
|
||||
window.PretixWidget.buildWidgets();
|
||||
}, 1000);
|
||||
ga(function(tracker) {
|
||||
if (loadingTimeout) {
|
||||
window.clearTimeout(loadingTimeout);
|
||||
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
|
||||
window.PretixWidget.buildWidgets();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
In some combinations with Google Tag Manager, the widget does not load this way. In this case, try replacing
|
||||
``tracker.get('clientId')`` with ``ga.getAll()[0].get('clientId')``.
|
||||
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
Data passing options have been added in pretix 2.3. If you use a self-hosted version of pretix, they only work
|
||||
fully if you configured a redis server.
|
||||
|
||||
.. versionchanged:: 3.6
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.15.0.dev0"
|
||||
__version__ = "3.16.0"
|
||||
|
||||
@@ -41,7 +41,9 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:order-list'),
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +69,9 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:checkinlist-status'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
)
|
||||
|
||||
|
||||
@@ -95,7 +99,9 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'api-v1:order-list'),
|
||||
('GET', 'api-v1:order-detail'),
|
||||
('DELETE', 'api-v1:orderposition-detail'),
|
||||
('GET', 'api-v1:orderposition-pdf_image'),
|
||||
('POST', 'api-v1:order-mark_canceled'),
|
||||
('POST', 'api-v1:orderpayment-list'),
|
||||
('POST', 'api-v1:orderrefund-list'),
|
||||
('POST', 'api-v1:orderrefund-done'),
|
||||
('POST', 'api-v1:cartposition-list'),
|
||||
@@ -113,6 +119,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'plugins:pretix_seating:event.event.subevent'),
|
||||
('GET', 'plugins:pretix_seating:event.plan'),
|
||||
('GET', 'plugins:pretix_seating:selection.simple'),
|
||||
('POST', 'api-v1:upload'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -89,10 +89,38 @@ class EventCRUDPermission(EventPermission):
|
||||
class ProfilePermission(BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user.is_authenticated:
|
||||
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
|
||||
return False
|
||||
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
# If this logic is updated, make sure to also update the logic in pretix/control/middleware.py
|
||||
assert_session_valid(request)
|
||||
except SessionInvalid:
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
|
||||
if isinstance(request.auth, OAuthAccessToken):
|
||||
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class AnyAuthenticatedClientPermission(BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
|
||||
return False
|
||||
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
# If this logic is updated, make sure to also update the logic in pretix/control/middleware.py
|
||||
assert_session_valid(request)
|
||||
except SessionInvalid:
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.files import File
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy
|
||||
@@ -100,8 +101,15 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options')
|
||||
answ = cp.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
answ = cp.answers.create(**answ_data, answer='')
|
||||
answ.file.save(an.name, an, save=False)
|
||||
answ.answer = 'file://' + answ.file.name
|
||||
answ.save()
|
||||
else:
|
||||
answ = cp.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
return cp
|
||||
|
||||
def validate_cart_id(self, cid):
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _
|
||||
from django_countries.serializers import CountryFieldMixin
|
||||
from hierarkey.proxy import HierarkeyProxy
|
||||
from pytz import common_timezones
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import ChoiceField, Field
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import Event, TaxRule
|
||||
from pretix.api.serializers.settings import SettingsSerializer
|
||||
from pretix.base.models import Device, Event, TaxRule, TeamAPIToken
|
||||
from pretix.base.models.event import SubEvent
|
||||
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 DEFAULTS, validate_event_settings
|
||||
from pretix.base.settings import validate_event_settings
|
||||
from pretix.base.signals import api_event_settings_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetaDataField(Field):
|
||||
|
||||
@@ -170,9 +174,12 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
}
|
||||
|
||||
def validate_meta_data(self, value):
|
||||
for key in value['meta_data'].keys():
|
||||
for key, v in value['meta_data'].items():
|
||||
if key not in self.meta_properties:
|
||||
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
|
||||
if self.meta_properties[key].allowed_values:
|
||||
if v not in [_v.strip() for _v in self.meta_properties[key].allowed_values.splitlines()]:
|
||||
raise ValidationError(_('Meta data property \'{name}\' does not allow value \'{value}\'.').format(name=key, value=v))
|
||||
return value
|
||||
|
||||
@cached_property
|
||||
@@ -219,6 +226,14 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
|
||||
return value
|
||||
|
||||
@cached_property
|
||||
def ignored_meta_properties(self):
|
||||
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
|
||||
else self.context['request'].user)
|
||||
if perm_holder.has_organizer_permission('can_change_organizer_settings', request=self.context['request']):
|
||||
return []
|
||||
return [k for k, p in self.meta_properties.items() if p.protected]
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
@@ -234,10 +249,11 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
for key, value in meta_data.items():
|
||||
event.meta_values.create(
|
||||
property=self.meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
if key not in self.ignored_meta_properties:
|
||||
event.meta_values.create(
|
||||
property=self.meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
|
||||
# Item Meta properties
|
||||
if item_meta_properties is not None:
|
||||
@@ -275,19 +291,21 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
if meta_data is not None:
|
||||
current = {mv.property: mv for mv in event.meta_values.select_related('property')}
|
||||
for key, value in meta_data.items():
|
||||
prop = self.meta_properties.get(key)
|
||||
if prop in current:
|
||||
current[prop].value = value
|
||||
current[prop].save()
|
||||
else:
|
||||
event.meta_values.create(
|
||||
property=self.meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
if key not in self.ignored_meta_properties:
|
||||
prop = self.meta_properties.get(key)
|
||||
if prop in current:
|
||||
current[prop].value = value
|
||||
current[prop].save()
|
||||
else:
|
||||
event.meta_values.create(
|
||||
property=self.meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
|
||||
for prop, current_object in current.items():
|
||||
if prop.name not in meta_data:
|
||||
current_object.delete()
|
||||
if prop.name not in self.ignored_meta_properties:
|
||||
if prop.name not in meta_data:
|
||||
current_object.delete()
|
||||
|
||||
# Item Meta properties
|
||||
if item_meta_properties is not None:
|
||||
@@ -391,8 +409,8 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
model = SubEvent
|
||||
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
|
||||
'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public',
|
||||
'seating_plan', 'item_price_overrides', 'variation_price_overrides', 'meta_data',
|
||||
'seat_category_mapping', 'last_modified')
|
||||
'frontpage_text', 'seating_plan', 'item_price_overrides', 'variation_price_overrides',
|
||||
'meta_data', 'seat_category_mapping', 'last_modified')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -440,11 +458,22 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
}
|
||||
|
||||
def validate_meta_data(self, value):
|
||||
for key in value['meta_data'].keys():
|
||||
for key, v in value['meta_data'].items():
|
||||
if key not in self.meta_properties:
|
||||
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
|
||||
if self.meta_properties[key].allowed_values:
|
||||
if v not in [_v.strip() for _v in self.meta_properties[key].allowed_values.splitlines()]:
|
||||
raise ValidationError(_('Meta data property \'{name}\' does not allow value \'{value}\'.').format(name=key, value=v))
|
||||
return value
|
||||
|
||||
@cached_property
|
||||
def ignored_meta_properties(self):
|
||||
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
|
||||
else self.context['request'].user)
|
||||
if perm_holder.has_organizer_permission('can_change_organizer_settings', request=self.context['request']):
|
||||
return []
|
||||
return [k for k, p in self.meta_properties.items() if p.protected]
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
|
||||
@@ -461,10 +490,11 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
for key, value in meta_data.items():
|
||||
subevent.meta_values.create(
|
||||
property=self.meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
if key not in self.ignored_meta_properties:
|
||||
subevent.meta_values.create(
|
||||
property=self.meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
|
||||
# Seats
|
||||
if subevent.seating_plan:
|
||||
@@ -510,19 +540,21 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
if meta_data is not None:
|
||||
current = {mv.property: mv for mv in subevent.meta_values.select_related('property')}
|
||||
for key, value in meta_data.items():
|
||||
prop = self.meta_properties.get(key)
|
||||
if prop in current:
|
||||
current[prop].value = value
|
||||
current[prop].save()
|
||||
else:
|
||||
subevent.meta_values.create(
|
||||
property=self.meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
if key not in self.ignored_meta_properties:
|
||||
prop = self.meta_properties.get(key)
|
||||
if prop in current:
|
||||
current[prop].value = value
|
||||
current[prop].save()
|
||||
else:
|
||||
subevent.meta_values.create(
|
||||
property=self.meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
|
||||
for prop, current_object in current.items():
|
||||
if prop.name not in meta_data:
|
||||
current_object.delete()
|
||||
if prop.name not in self.ignored_meta_properties:
|
||||
if prop.name not in meta_data:
|
||||
current_object.delete()
|
||||
|
||||
# Seats
|
||||
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
|
||||
@@ -558,7 +590,7 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
|
||||
|
||||
|
||||
class EventSettingsSerializer(serializers.Serializer):
|
||||
class EventSettingsSerializer(SettingsSerializer):
|
||||
default_fields = [
|
||||
'imprint_url',
|
||||
'checkout_email_helptext',
|
||||
@@ -576,6 +608,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'locale',
|
||||
'region',
|
||||
'last_order_modification_date',
|
||||
'allow_modifications_after_checkin',
|
||||
'show_quota_left',
|
||||
'waiting_list_enabled',
|
||||
'waiting_list_hours',
|
||||
@@ -654,6 +687,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'invoice_additional_text',
|
||||
'invoice_footer_text',
|
||||
'invoice_eu_currencies',
|
||||
'invoice_logo_image',
|
||||
'cancel_allow_user',
|
||||
'cancel_allow_user_until',
|
||||
'cancel_allow_user_paid',
|
||||
@@ -663,6 +697,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'cancel_allow_user_paid_keep_percentage',
|
||||
'cancel_allow_user_paid_adjust_fees',
|
||||
'cancel_allow_user_paid_adjust_fees_explanation',
|
||||
'cancel_allow_user_paid_adjust_fees_step',
|
||||
'cancel_allow_user_paid_refund_as_giftcard',
|
||||
'cancel_allow_user_paid_require_approval',
|
||||
'change_allow_user_variation',
|
||||
@@ -674,45 +709,21 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'theme_color_background',
|
||||
'theme_round_borders',
|
||||
'primary_font',
|
||||
'logo_image',
|
||||
'logo_image_large',
|
||||
'logo_show_title',
|
||||
'og_image',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
self.changed_data = []
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.default_fields:
|
||||
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
|
||||
if callable(kwargs):
|
||||
kwargs = kwargs()
|
||||
kwargs.setdefault('required', False)
|
||||
kwargs.setdefault('allow_null', True)
|
||||
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
||||
if callable(form_kwargs):
|
||||
form_kwargs = form_kwargs()
|
||||
if 'serializer_class' not in DEFAULTS[fname]:
|
||||
raise ValidationError('{} has no serializer class'.format(fname))
|
||||
f = DEFAULTS[fname]['serializer_class'](
|
||||
**kwargs
|
||||
)
|
||||
f._label = form_kwargs.get('label', fname)
|
||||
f._help_text = form_kwargs.get('help_text')
|
||||
self.fields[fname] = f
|
||||
|
||||
for recv, resp in api_event_settings_fields.send(sender=self.event):
|
||||
for fname, field in resp.items():
|
||||
field.required = False
|
||||
self.fields[fname] = field
|
||||
|
||||
def update(self, instance: HierarkeyProxy, validated_data):
|
||||
for attr, value in validated_data.items():
|
||||
if value is None:
|
||||
instance.delete(attr)
|
||||
self.changed_data.append(attr)
|
||||
elif instance.get(attr, as_type=type(value)) != value:
|
||||
instance.set(attr, value)
|
||||
self.changed_data.append(attr)
|
||||
return instance
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
settings_dict = self.instance.freeze()
|
||||
@@ -720,6 +731,14 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
validate_event_settings(self.event, settings_dict)
|
||||
return data
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
nonce = get_random_string(length=8)
|
||||
fname = '%s/%s/%s.%s.%s' % (
|
||||
self.event.organizer.slug, self.event.slug, name.split('/')[-1], nonce, name.split('.')[-1]
|
||||
)
|
||||
# TODO: make sure pub is always correct
|
||||
return 'pub/' + fname
|
||||
|
||||
|
||||
class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
default_fields = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
@@ -27,3 +28,50 @@ class ListMultipleChoiceField(serializers.MultipleChoiceField):
|
||||
]
|
||||
|
||||
return remove_duplicates_from_list(representation_data)
|
||||
|
||||
|
||||
class UploadedFileField(serializers.Field):
|
||||
default_error_messages = {
|
||||
'required': 'No file was submitted.',
|
||||
'not_found': 'The submitted file ID was not found.',
|
||||
'invalid_type': 'The submitted file has a file type that is not allowed in this field.',
|
||||
'size': 'The submitted file is too large to be used in this field.',
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.allowed_types = kwargs.pop('allowed_types', None)
|
||||
self.max_size = kwargs.pop('max_size', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
from pretix.base.models import CachedFile
|
||||
|
||||
request = self.context.get('request', None)
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}',
|
||||
file__isnull=False,
|
||||
pk=data[len("file:"):],
|
||||
)
|
||||
except (ValidationError, IndexError): # invalid uuid
|
||||
self.fail('not_found')
|
||||
except CachedFile.DoesNotExist:
|
||||
self.fail('not_found')
|
||||
|
||||
if self.allowed_types and cf.type not in self.allowed_types:
|
||||
self.fail('invalid_type')
|
||||
if self.max_size and cf.file.size > self.max_size:
|
||||
self.fail('size')
|
||||
|
||||
return cf.file
|
||||
|
||||
def to_representation(self, value):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = value.url
|
||||
except AttributeError:
|
||||
return None
|
||||
request = self.context['request']
|
||||
return request.build_absolute_uri(url)
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.serializers.event import MetaDataField
|
||||
from pretix.api.serializers.fields import UploadedFileField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
|
||||
@@ -113,6 +114,9 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
), max_size=10 * 1024 * 1024)
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
@@ -123,7 +127,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
read_only_fields = ('has_variations',)
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections import Counter, defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
import pycountry
|
||||
from django.core.files import File
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy
|
||||
@@ -17,13 +18,14 @@ from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
|
||||
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher,
|
||||
CachedFile, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item,
|
||||
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat,
|
||||
SubEvent, TaxRule, Voucher,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret,
|
||||
)
|
||||
from pretix.base.pdf import get_variables
|
||||
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 get_price
|
||||
@@ -94,12 +96,9 @@ class AnswerQuestionIdentifierField(serializers.Field):
|
||||
|
||||
class AnswerQuestionOptionsIdentifierField(serializers.Field):
|
||||
def to_representation(self, instance: QuestionAnswer):
|
||||
return [o.identifier for o in instance.options.all()]
|
||||
|
||||
|
||||
class AnswerQuestionOptionsField(serializers.Field):
|
||||
def to_representation(self, instance: QuestionAnswer):
|
||||
return [o.pk for o in instance.options.all()]
|
||||
if isinstance(instance, WrappedModel) or instance.pk:
|
||||
return [o.identifier for o in instance.options.all()]
|
||||
return []
|
||||
|
||||
|
||||
class InlineSeatSerializer(I18nAwareModelSerializer):
|
||||
@@ -112,12 +111,102 @@ class InlineSeatSerializer(I18nAwareModelSerializer):
|
||||
class AnswerSerializer(I18nAwareModelSerializer):
|
||||
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
|
||||
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
|
||||
options = AnswerQuestionOptionsField(source='*', read_only=True)
|
||||
|
||||
def to_representation(self, instance):
|
||||
r = super().to_representation(instance)
|
||||
if r['answer'].startswith('file://') and instance.orderposition:
|
||||
r['answer'] = reverse('api-v1:orderposition-answer', kwargs={
|
||||
'organizer': instance.orderposition.order.event.organizer.slug,
|
||||
'event': instance.orderposition.order.event.slug,
|
||||
'pk': instance.orderposition.pk,
|
||||
'question': instance.question_id,
|
||||
}, request=self.context['request'])
|
||||
return r
|
||||
|
||||
class Meta:
|
||||
model = QuestionAnswer
|
||||
fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers')
|
||||
|
||||
def validate_question(self, q):
|
||||
if q.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
'The specified question does not belong to this event.'
|
||||
)
|
||||
return q
|
||||
|
||||
def _handle_file_upload(self, data):
|
||||
try:
|
||||
ao = self.context["request"].user or self.context["request"].auth
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(ao))}-{ao.pk}',
|
||||
file__isnull=False,
|
||||
pk=data['answer'][len("file:"):],
|
||||
)
|
||||
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))
|
||||
|
||||
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 > 10 * 1024 * 1024:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
data['options'] = []
|
||||
data['answer'] = cf.file
|
||||
return data
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('question').type == Question.TYPE_FILE:
|
||||
return self._handle_file_upload(data)
|
||||
elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
if not data.get('options'):
|
||||
raise ValidationError(
|
||||
'You need to specify options if the question is of a choice type.'
|
||||
)
|
||||
if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options')) > 1:
|
||||
raise ValidationError(
|
||||
'You can specify at most one option for this question.'
|
||||
)
|
||||
for o in data.get('options'):
|
||||
if o.question_id != data.get('question').pk:
|
||||
raise ValidationError(
|
||||
'The specified option does not belong to this question.'
|
||||
)
|
||||
|
||||
data['answer'] = ", ".join([str(o) for o in data.get('options')])
|
||||
|
||||
else:
|
||||
if data.get('options'):
|
||||
raise ValidationError(
|
||||
'You should not specify options if the question is not of a choice type.'
|
||||
)
|
||||
|
||||
if data.get('question').type == Question.TYPE_BOOLEAN:
|
||||
if data.get('answer') in ['true', 'True', '1', 'TRUE']:
|
||||
data['answer'] = 'True'
|
||||
elif data.get('answer') in ['false', 'False', '0', 'FALSE']:
|
||||
data['answer'] = 'False'
|
||||
else:
|
||||
raise ValidationError(
|
||||
'Please specify "true" or "false" for boolean questions.'
|
||||
)
|
||||
elif data.get('question').type == Question.TYPE_NUMBER:
|
||||
serializers.DecimalField(
|
||||
max_digits=50,
|
||||
decimal_places=25
|
||||
).to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_DATE:
|
||||
data['answer'] = serializers.DateField().to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_TIME:
|
||||
data['answer'] = serializers.TimeField().to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_DATETIME:
|
||||
data['answer'] = serializers.DateTimeField().to_internal_value(data.get('answer'))
|
||||
return data
|
||||
|
||||
|
||||
class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
@@ -187,6 +276,9 @@ class PdfDataSerializer(serializers.Field):
|
||||
if 'vars' not in self.context:
|
||||
self.context['vars'] = get_variables(self.context['request'].event)
|
||||
|
||||
if 'vars_images' not in self.context:
|
||||
self.context['vars_images'] = get_images(self.context['request'].event)
|
||||
|
||||
for k, f in self.context['vars'].items():
|
||||
res[k] = f['evaluate'](instance, instance.order, ev)
|
||||
|
||||
@@ -201,17 +293,39 @@ class PdfDataSerializer(serializers.Field):
|
||||
for k, v in instance.item._cached_meta_data.items():
|
||||
res['itemmeta:' + k] = v
|
||||
|
||||
return res
|
||||
res['images'] = {}
|
||||
|
||||
for k, f in self.context['vars_images'].items():
|
||||
if 'etag' in f:
|
||||
has_image = etag = f['etag'](instance, instance.order, ev)
|
||||
else:
|
||||
has_image = f['etag'](instance, instance.order, ev)
|
||||
etag = None
|
||||
if has_image:
|
||||
url = reverse('api-v1:orderposition-pdf_image', kwargs={
|
||||
'organizer': instance.order.event.organizer.slug,
|
||||
'event': instance.order.event.slug,
|
||||
'pk': instance.pk,
|
||||
'key': k,
|
||||
}, request=self.context['request'])
|
||||
if etag:
|
||||
url += f'#etag={etag}'
|
||||
res['images'][k] = url
|
||||
else:
|
||||
res['images'][k] = None
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
checkins = CheckinSerializer(many=True)
|
||||
checkins = CheckinSerializer(many=True, read_only=True)
|
||||
answers = AnswerSerializer(many=True)
|
||||
downloads = PositionDownloadsField(source='*')
|
||||
downloads = PositionDownloadsField(source='*', read_only=True)
|
||||
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
||||
pdf_data = PdfDataSerializer(source='*')
|
||||
pdf_data = PdfDataSerializer(source='*', read_only=True)
|
||||
seat = InlineSeatSerializer(read_only=True)
|
||||
country = CompatibleCountryField(source='*')
|
||||
attendee_name = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
@@ -219,12 +333,99 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
|
||||
read_only_fields = (
|
||||
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
|
||||
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
|
||||
'seat', 'canceled'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if 'request' in self.context and not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
|
||||
self.fields.pop('pdf_data')
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('attendee_name') and data.get('attendee_name_parts'):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
|
||||
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
|
||||
if data.get('country'):
|
||||
if not pycountry.countries.get(alpha_2=data.get('country').code):
|
||||
raise ValidationError(
|
||||
{'country': ['Invalid country code.']}
|
||||
)
|
||||
|
||||
if data.get('state'):
|
||||
cc = str(data.get('country') or self.instance.country or '')
|
||||
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
raise ValidationError(
|
||||
{'state': ['States are not supported in country "{}".'.format(cc)]}
|
||||
)
|
||||
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
|
||||
raise ValidationError(
|
||||
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
|
||||
)
|
||||
return data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
|
||||
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
|
||||
update_fields = [
|
||||
'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
|
||||
'state', 'attendee_email',
|
||||
]
|
||||
answers_data = validated_data.pop('answers', None)
|
||||
|
||||
name = validated_data.pop('attendee_name', '')
|
||||
if name and not validated_data.get('attendee_name_parts'):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': name
|
||||
}
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
if attr in update_fields:
|
||||
setattr(instance, attr, value)
|
||||
|
||||
instance.save(update_fields=update_fields)
|
||||
|
||||
if answers_data is not None:
|
||||
qs_seen = set()
|
||||
answercache = {
|
||||
a.question_id: a for a in instance.answers.all()
|
||||
}
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options', [])
|
||||
if answ_data['question'].pk in qs_seen:
|
||||
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
|
||||
if answ_data['question'].pk in answercache:
|
||||
a = answercache[answ_data['question'].pk]
|
||||
if isinstance(answ_data['answer'], File):
|
||||
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
|
||||
a.answer = 'file://' + a.file.name
|
||||
else:
|
||||
for attr, value in answ_data.items():
|
||||
setattr(a, attr, value)
|
||||
a.save()
|
||||
else:
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
a = instance.answers.create(**answ_data, answer='')
|
||||
a.file.save(an.name, an, save=False)
|
||||
a.answer = 'file://' + a.file.name
|
||||
a.save()
|
||||
else:
|
||||
a = instance.answers.create(**answ_data)
|
||||
a.options.set(options)
|
||||
qs_seen.add(a.question_id)
|
||||
for qid, a in answercache.items():
|
||||
if qid not in qs_seen:
|
||||
a.delete()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class RequireAttentionField(serializers.Field):
|
||||
def to_representation(self, instance: OrderPosition):
|
||||
@@ -336,7 +537,7 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = OrderRefund
|
||||
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider')
|
||||
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'comment', 'provider')
|
||||
|
||||
|
||||
class OrderURLField(serializers.URLField):
|
||||
@@ -425,7 +626,17 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
return instance
|
||||
|
||||
|
||||
class AnswerQuestionOptionsField(serializers.Field):
|
||||
def to_representation(self, instance: QuestionAnswer):
|
||||
return [o.pk for o in instance.options.all()]
|
||||
|
||||
|
||||
class SimulatedAnswerSerializer(AnswerSerializer):
|
||||
options = AnswerQuestionOptionsField(read_only=True, source='*')
|
||||
|
||||
|
||||
class SimulatedOrderPositionSerializer(OrderPositionSerializer):
|
||||
answers = SimulatedAnswerSerializer(many=True)
|
||||
addon_to = serializers.SlugRelatedField(read_only=True, slug_field='positionid')
|
||||
|
||||
|
||||
@@ -452,62 +663,8 @@ class PriceCalcSerializer(serializers.Serializer):
|
||||
del self.fields['subevent']
|
||||
|
||||
|
||||
class AnswerCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = QuestionAnswer
|
||||
fields = ('question', 'answer', 'options')
|
||||
|
||||
def validate_question(self, q):
|
||||
if q.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
'The specified question does not belong to this event.'
|
||||
)
|
||||
return q
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('question').type == Question.TYPE_FILE:
|
||||
raise ValidationError(
|
||||
'File uploads are currently not supported via the API.'
|
||||
)
|
||||
elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
if not data.get('options'):
|
||||
raise ValidationError(
|
||||
'You need to specify options if the question is of a choice type.'
|
||||
)
|
||||
if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options')) > 1:
|
||||
raise ValidationError(
|
||||
'You can specify at most one option for this question.'
|
||||
)
|
||||
data['answer'] = ", ".join([str(o) for o in data.get('options')])
|
||||
|
||||
else:
|
||||
if data.get('options'):
|
||||
raise ValidationError(
|
||||
'You should not specify options if the question is not of a choice type.'
|
||||
)
|
||||
|
||||
if data.get('question').type == Question.TYPE_BOOLEAN:
|
||||
if data.get('answer') in ['true', 'True', '1', 'TRUE']:
|
||||
data['answer'] = 'True'
|
||||
elif data.get('answer') in ['false', 'False', '0', 'FALSE']:
|
||||
data['answer'] = 'False'
|
||||
else:
|
||||
raise ValidationError(
|
||||
'Please specify "true" or "false" for boolean questions.'
|
||||
)
|
||||
elif data.get('question').type == Question.TYPE_NUMBER:
|
||||
serializers.DecimalField(
|
||||
max_digits=50,
|
||||
decimal_places=25
|
||||
).to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_DATE:
|
||||
data['answer'] = serializers.DateField().to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_TIME:
|
||||
data['answer'] = serializers.TimeField().to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_DATETIME:
|
||||
data['answer'] = serializers.DateTimeField().to_internal_value(data.get('answer'))
|
||||
return data
|
||||
class AnswerCreateSerializer(AnswerSerializer):
|
||||
pass
|
||||
|
||||
|
||||
class OrderFeeCreateSerializer(I18nAwareModelSerializer):
|
||||
@@ -1044,8 +1201,16 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
pos.save()
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options', [])
|
||||
answ = pos.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
answ = pos.answers.create(**answ_data, answer='')
|
||||
answ.file.save(an.name, an, save=False)
|
||||
answ.answer = 'file://' + answ.file.name
|
||||
answ.save()
|
||||
else:
|
||||
answ = pos.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
pos_map[pos.positionid] = pos
|
||||
|
||||
if not simulate:
|
||||
@@ -1194,7 +1359,7 @@ class OrderRefundCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = OrderRefund
|
||||
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info')
|
||||
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info', 'comment')
|
||||
|
||||
def create(self, validated_data):
|
||||
pid = validated_data.pop('payment', None)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from hierarkey.proxy import HierarkeyProxy
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import CompatibleJSONField
|
||||
from pretix.api.serializers.settings import SettingsSerializer
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.i18n import get_language_without_region
|
||||
from pretix.base.models import (
|
||||
@@ -16,9 +18,11 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.base.settings import DEFAULTS, validate_organizer_settings
|
||||
from pretix.base.settings import validate_organizer_settings
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrganizerSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
@@ -207,8 +211,10 @@ class TeamMemberSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class OrganizerSettingsSerializer(serializers.Serializer):
|
||||
class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
default_fields = [
|
||||
'contact_mail',
|
||||
'imprint_url',
|
||||
'organizer_info_text',
|
||||
'event_list_type',
|
||||
'event_list_availability',
|
||||
@@ -225,40 +231,13 @@ class OrganizerSettingsSerializer(serializers.Serializer):
|
||||
'theme_color_danger',
|
||||
'theme_color_background',
|
||||
'theme_round_borders',
|
||||
'primary_font'
|
||||
'primary_font',
|
||||
'organizer_logo_image'
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.organizer = kwargs.pop('organizer')
|
||||
self.changed_data = []
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.default_fields:
|
||||
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
|
||||
if callable(kwargs):
|
||||
kwargs = kwargs()
|
||||
kwargs.setdefault('required', False)
|
||||
kwargs.setdefault('allow_null', True)
|
||||
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
||||
if callable(form_kwargs):
|
||||
form_kwargs = form_kwargs()
|
||||
if 'serializer_class' not in DEFAULTS[fname]:
|
||||
raise ValidationError('{} has no serializer class'.format(fname))
|
||||
f = DEFAULTS[fname]['serializer_class'](
|
||||
**kwargs
|
||||
)
|
||||
f._label = form_kwargs.get('label', fname)
|
||||
f._help_text = form_kwargs.get('help_text')
|
||||
self.fields[fname] = f
|
||||
|
||||
def update(self, instance: HierarkeyProxy, validated_data):
|
||||
for attr, value in validated_data.items():
|
||||
if value is None:
|
||||
instance.delete(attr)
|
||||
self.changed_data.append(attr)
|
||||
elif instance.get(attr, as_type=type(value)) != value:
|
||||
instance.set(attr, value)
|
||||
self.changed_data.append(attr)
|
||||
return instance
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -266,3 +245,11 @@ class OrganizerSettingsSerializer(serializers.Serializer):
|
||||
settings_dict.update(data)
|
||||
validate_organizer_settings(self.organizer, settings_dict)
|
||||
return data
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
nonce = get_random_string(length=8)
|
||||
fname = '%s/%s.%s.%s' % (
|
||||
self.organizer.slug, name.split('/')[-1], nonce, name.split('.')[-1]
|
||||
)
|
||||
# TODO: make sure pub is always correct
|
||||
return 'pub/' + fname
|
||||
|
||||
77
src/pretix/api/serializers/settings.py
Normal file
77
src/pretix/api/serializers/settings.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import logging
|
||||
|
||||
from django.core.files import File
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.models.fields.files import FieldFile
|
||||
from hierarkey.proxy import HierarkeyProxy
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.fields import UploadedFileField
|
||||
from pretix.base.settings import DEFAULTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SettingsSerializer(serializers.Serializer):
|
||||
default_fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.changed_data = []
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.default_fields:
|
||||
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
|
||||
if callable(kwargs):
|
||||
kwargs = kwargs()
|
||||
kwargs.setdefault('required', False)
|
||||
kwargs.setdefault('allow_null', True)
|
||||
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
||||
if callable(form_kwargs):
|
||||
form_kwargs = form_kwargs()
|
||||
if 'serializer_class' not in DEFAULTS[fname]:
|
||||
raise ValidationError('{} has no serializer class'.format(fname))
|
||||
f = DEFAULTS[fname]['serializer_class'](
|
||||
**kwargs
|
||||
)
|
||||
f._label = form_kwargs.get('label', fname)
|
||||
f._help_text = form_kwargs.get('help_text')
|
||||
f.parent = self
|
||||
self.fields[fname] = f
|
||||
|
||||
def update(self, instance: HierarkeyProxy, validated_data):
|
||||
for attr, value in validated_data.items():
|
||||
if isinstance(value, FieldFile):
|
||||
# Delete old file
|
||||
fname = instance.get(attr, as_type=File)
|
||||
if fname:
|
||||
try:
|
||||
default_storage.delete(fname.name)
|
||||
except OSError: # pragma: no cover
|
||||
logger.error('Deleting file %s failed.' % fname.name)
|
||||
|
||||
# Create new file
|
||||
newname = default_storage.save(self.get_new_filename(value.name), value)
|
||||
instance.set(attr, File(file=value, name=newname))
|
||||
self.changed_data.append(attr)
|
||||
elif isinstance(self.fields[attr], UploadedFileField):
|
||||
if value is None:
|
||||
fname = instance.get(attr, as_type=File)
|
||||
if fname:
|
||||
try:
|
||||
default_storage.delete(fname.name)
|
||||
except OSError: # pragma: no cover
|
||||
logger.error('Deleting file %s failed.' % fname.name)
|
||||
instance.delete(attr)
|
||||
else:
|
||||
# file is unchanged
|
||||
continue
|
||||
elif value is None:
|
||||
instance.delete(attr)
|
||||
self.changed_data.append(attr)
|
||||
elif instance.get(attr, as_type=type(value)) != value:
|
||||
instance.set(attr, value)
|
||||
self.changed_data.append(attr)
|
||||
return instance
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
raise NotImplementedError()
|
||||
@@ -7,8 +7,8 @@ from rest_framework import routers
|
||||
from pretix.api.views import cart
|
||||
|
||||
from .views import (
|
||||
checkin, device, event, exporters, item, oauth, order, organizer, user,
|
||||
version, voucher, waitinglist, webhooks,
|
||||
checkin, device, event, exporters, item, oauth, order, organizer, upload,
|
||||
user, version, voucher, waitinglist, webhooks,
|
||||
)
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
@@ -95,6 +95,7 @@ urlpatterns = [
|
||||
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
|
||||
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
|
||||
url(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
|
||||
url(r"^upload$", upload.UploadView.as_view(), name="upload"),
|
||||
url(r"^me$", user.MeView.as_view(), name="user.me"),
|
||||
url(r"^version$", version.VersionView.as_view(), name="version"),
|
||||
]
|
||||
|
||||
@@ -22,7 +22,7 @@ from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Event, Order, OrderPosition,
|
||||
CachedFile, Checkin, CheckinList, Event, Order, OrderPosition, Question,
|
||||
)
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, perform_checkin,
|
||||
@@ -302,7 +302,10 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
for q in op.item.questions.filter(ask_during_checkin=True):
|
||||
if str(q.pk) in aws:
|
||||
try:
|
||||
given_answers[q] = q.clean_answer(aws[str(q.pk)])
|
||||
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
|
||||
|
||||
@@ -352,3 +355,25 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
'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:"):],
|
||||
)
|
||||
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))
|
||||
|
||||
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 > 10 * 1024 * 1024:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
return cf.file
|
||||
|
||||
@@ -365,9 +365,13 @@ class EventSettingsView(views.APIView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if isinstance(request.auth, Device):
|
||||
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
||||
'request': request
|
||||
})
|
||||
elif 'can_change_event_settings' in request.eventpermset:
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
||||
'request': request
|
||||
})
|
||||
else:
|
||||
raise PermissionDenied()
|
||||
if 'explain' in request.GET:
|
||||
@@ -382,7 +386,7 @@ class EventSettingsView(views.APIView):
|
||||
|
||||
def patch(self, request, *wargs, **kwargs):
|
||||
s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True,
|
||||
event=request.event)
|
||||
event=request.event, context={'request': request})
|
||||
s.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
s.save()
|
||||
@@ -392,6 +396,9 @@ class EventSettingsView(views.APIView):
|
||||
}
|
||||
)
|
||||
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
|
||||
regenerate_css.apply_async(args=(request.organizer.pk,))
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
regenerate_css.apply_async(args=(request.event.pk,))
|
||||
s = EventSettingsSerializer(
|
||||
instance=request.event.settings, event=request.event, context={
|
||||
'request': request
|
||||
})
|
||||
return Response(s.data)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import datetime
|
||||
import mimetypes
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
import django_filters
|
||||
@@ -12,6 +14,7 @@ from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext as _
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from PIL import Image
|
||||
from rest_framework import mixins, serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import (
|
||||
@@ -35,8 +38,9 @@ from pretix.base.models import (
|
||||
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
|
||||
TaxRule, TeamAPIToken, generate_secret,
|
||||
)
|
||||
from pretix.base.models.orders import RevokedTicketSecret
|
||||
from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.pdf import get_images
|
||||
from pretix.base.secrets import assign_ticket_secret
|
||||
from pretix.base.services import tickets
|
||||
from pretix.base.services.invoices import (
|
||||
@@ -763,7 +767,7 @@ with scopes_disabled():
|
||||
}
|
||||
|
||||
|
||||
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPositionSerializer
|
||||
queryset = OrderPosition.all.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
@@ -783,6 +787,11 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
},
|
||||
}
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
|
||||
qs = OrderPosition.all
|
||||
@@ -908,6 +917,62 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
'tax_rule': tr.pk if tr else None,
|
||||
})
|
||||
|
||||
@action(detail=True, url_name='answer', url_path=r'answer/(?P<question>\d+)')
|
||||
def answer(self, request, **kwargs):
|
||||
pos = self.get_object()
|
||||
answer = get_object_or_404(
|
||||
QuestionAnswer,
|
||||
orderposition=self.get_object(),
|
||||
question_id=kwargs.get('question')
|
||||
)
|
||||
if not answer.file:
|
||||
raise NotFound()
|
||||
|
||||
ftype, ignored = mimetypes.guess_type(answer.file.name)
|
||||
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format(
|
||||
self.request.event.slug.upper(),
|
||||
pos.order.code,
|
||||
pos.positionid,
|
||||
os.path.basename(answer.file.name).split('.', 1)[1]
|
||||
)
|
||||
return resp
|
||||
|
||||
@action(detail=True, url_name='pdf_image', url_path=r'pdf_image/(?P<key>[^/]+)')
|
||||
def pdf_image(self, request, key, **kwargs):
|
||||
pos = self.get_object()
|
||||
|
||||
image_vars = get_images(request.event)
|
||||
if key not in image_vars:
|
||||
raise NotFound('Unknown key')
|
||||
|
||||
image_file = image_vars[key]['evaluate'](pos, pos.order, pos.subevent or self.request.event)
|
||||
if image_file is None:
|
||||
raise NotFound('No image available')
|
||||
|
||||
if getattr(image_file, 'name', ''):
|
||||
ftype, ignored = mimetypes.guess_type(image_file.name)
|
||||
extension = os.path.basename(image_file.name).split('.')[-1]
|
||||
else:
|
||||
img = Image.open(image_file)
|
||||
ftype = Image.MIME[img.format]
|
||||
extensions = {
|
||||
'GIF': 'gif', 'TIFF': 'tif', 'BMP': 'bmp', 'JPEG': 'jpg', 'PNG': 'png'
|
||||
}
|
||||
extension = extensions.get(img.format, 'bin')
|
||||
if hasattr(image_file, 'seek'):
|
||||
image_file.seek(0)
|
||||
|
||||
resp = FileResponse(image_file, content_type=ftype or 'application/binary')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format(
|
||||
self.request.event.slug.upper(),
|
||||
pos.order.code,
|
||||
pos.positionid,
|
||||
key,
|
||||
extension,
|
||||
)
|
||||
return resp
|
||||
|
||||
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
|
||||
def download(self, request, output, **kwargs):
|
||||
provider = self._get_output_provider(output)
|
||||
@@ -951,6 +1016,44 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
except Quota.QuotaExceededException as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
partial = kwargs.get('partial', False)
|
||||
if not partial:
|
||||
return Response(
|
||||
{"detail": "Method \"PUT\" not allowed."},
|
||||
status=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
with transaction.atomic():
|
||||
old_data = self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data
|
||||
serializer.save()
|
||||
new_data = serializer.data
|
||||
|
||||
if old_data != new_data:
|
||||
log_data = self.request.data
|
||||
if 'answers' in log_data:
|
||||
for a in new_data['answers']:
|
||||
log_data[f'question_{a["question"]}'] = a["answer"]
|
||||
log_data.pop('answers', None)
|
||||
serializer.instance.order.log_action(
|
||||
'pretix.event.order.modified',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'data': [
|
||||
dict(
|
||||
position=serializer.instance.pk,
|
||||
**log_data
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
|
||||
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
|
||||
|
||||
|
||||
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPaymentSerializer
|
||||
|
||||
@@ -425,7 +425,9 @@ class OrganizerSettingsView(views.APIView):
|
||||
permission = 'can_change_organizer_settings'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer)
|
||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
||||
'request': request
|
||||
})
|
||||
if 'explain' in request.GET:
|
||||
return Response({
|
||||
fname: {
|
||||
@@ -439,7 +441,9 @@ class OrganizerSettingsView(views.APIView):
|
||||
def patch(self, request, *wargs, **kwargs):
|
||||
s = OrganizerSettingsSerializer(
|
||||
instance=request.organizer.settings, data=request.data, partial=True,
|
||||
organizer=request.organizer
|
||||
organizer=request.organizer, context={
|
||||
'request': request
|
||||
}
|
||||
)
|
||||
s.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
@@ -451,5 +455,7 @@ class OrganizerSettingsView(views.APIView):
|
||||
)
|
||||
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
|
||||
regenerate_organizer_css.apply_async(args=(request.organizer.pk,))
|
||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer)
|
||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
||||
'request': request
|
||||
})
|
||||
return Response(s.data)
|
||||
|
||||
55
src/pretix/api/views/upload.py
Normal file
55
src/pretix/api/views/upload.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import datetime
|
||||
|
||||
from django.utils.timezone import now
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.parsers import FileUploadParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from pretix.api.auth.device import DeviceTokenAuthentication
|
||||
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
|
||||
from pretix.api.auth.token import TeamTokenAuthentication
|
||||
from pretix.base.models import CachedFile
|
||||
|
||||
ALLOWED_TYPES = {
|
||||
'image/gif': {'.gif'},
|
||||
'image/jpeg': {'.jpg', '.jpeg'},
|
||||
'image/png': {'.png'},
|
||||
'application/pdf': {'.pdf'},
|
||||
}
|
||||
|
||||
|
||||
class UploadView(APIView):
|
||||
authentication_classes = (
|
||||
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
|
||||
)
|
||||
parser_classes = [FileUploadParser]
|
||||
permission_classes = [AnyAuthenticatedClientPermission]
|
||||
|
||||
def post(self, request):
|
||||
if 'file' not in request.data:
|
||||
raise ValidationError('No file has been submitted.')
|
||||
file_obj = request.data['file']
|
||||
content_type = file_obj.content_type.split(";")[0] # ignore e.g. "; charset=…"
|
||||
if content_type not in ALLOWED_TYPES:
|
||||
raise ValidationError('Content type "{type}" is not allowed'.format(type=content_type))
|
||||
if not any(file_obj.name.endswith(ext) for ext in ALLOWED_TYPES[content_type]):
|
||||
raise ValidationError('File name "{name}" has an invalid extension for type "{type}"'.format(
|
||||
name=file_obj.name,
|
||||
type=content_type
|
||||
))
|
||||
cf = CachedFile.objects.create(
|
||||
expires=now() + datetime.timedelta(days=1),
|
||||
date=now(),
|
||||
web_download=False,
|
||||
filename=file_obj.name,
|
||||
type=content_type,
|
||||
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}'
|
||||
)
|
||||
cf.file.save(file_obj.name, file_obj)
|
||||
cf.save()
|
||||
return Response({
|
||||
'id': f'file:{cf.pk}'
|
||||
}, status=201)
|
||||
@@ -6,6 +6,7 @@ from rest_framework.views import APIView
|
||||
|
||||
from pretix import __version__
|
||||
from pretix.api.auth.device import DeviceTokenAuthentication
|
||||
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
|
||||
from pretix.api.auth.token import TeamTokenAuthentication
|
||||
|
||||
|
||||
@@ -48,6 +49,7 @@ class VersionView(APIView):
|
||||
authentication_classes = (
|
||||
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
|
||||
)
|
||||
permission_classes = [AnyAuthenticatedClientPermission]
|
||||
|
||||
def get(self, request, format=None):
|
||||
return Response({
|
||||
|
||||
@@ -427,28 +427,30 @@ def base_placeholders(sender, **kwargs):
|
||||
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
order.full_code,
|
||||
build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash(),
|
||||
}),
|
||||
)
|
||||
for order in orders
|
||||
), lambda event: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
'{}-{}'.format(event.slug.upper(), order['code']),
|
||||
build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order['code'],
|
||||
'secret': order['secret']
|
||||
'secret': order['secret'],
|
||||
'hash': order['hash'],
|
||||
}),
|
||||
)
|
||||
for order in [
|
||||
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy'},
|
||||
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd'},
|
||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd'}
|
||||
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': 'abcdefghi'},
|
||||
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd', 'hash': 'jklmnopqr'},
|
||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
|
||||
]
|
||||
),
|
||||
),
|
||||
@@ -466,7 +468,8 @@ def base_placeholders(sender, **kwargs):
|
||||
'68CYU2H6ZTP3WLK5'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'voucher_list', ['voucher_list'], lambda voucher_list: '\n'.join(voucher_list),
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
|
||||
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
import re
|
||||
import tempfile
|
||||
from collections import OrderedDict, namedtuple
|
||||
from decimal import Decimal
|
||||
@@ -10,11 +11,21 @@ from django.db.models import QuerySet
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import KNOWN_TYPES
|
||||
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES
|
||||
|
||||
from pretix.base.models import Event
|
||||
|
||||
|
||||
def excel_safe(val):
|
||||
if not isinstance(val, KNOWN_TYPES):
|
||||
val = str(val)
|
||||
|
||||
if isinstance(val, str):
|
||||
val = re.sub(ILLEGAL_CHARACTERS_RE, '', val)
|
||||
|
||||
return val
|
||||
|
||||
|
||||
class BaseExporter:
|
||||
"""
|
||||
This is the base class for all data exporters
|
||||
@@ -181,7 +192,7 @@ class ListExporter(BaseExporter):
|
||||
total = line.total
|
||||
continue
|
||||
ws.append([
|
||||
str(val) if not isinstance(val, KNOWN_TYPES) else val
|
||||
excel_safe(val) if not isinstance(val, KNOWN_TYPES) else val
|
||||
for val in line
|
||||
])
|
||||
if total:
|
||||
@@ -242,7 +253,10 @@ class MultiSheetListExporter(ListExporter):
|
||||
pass
|
||||
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
raise NotImplementedError() # noqa
|
||||
if hasattr(self, 'iterate_' + sheet):
|
||||
yield from getattr(self, 'iterate_' + sheet)(form_data)
|
||||
else:
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
def _render_sheet_csv(self, form_data, sheet, output_file=None, **kwargs):
|
||||
total = 0
|
||||
@@ -288,6 +302,9 @@ class MultiSheetListExporter(ListExporter):
|
||||
n_sheets = len(self.sheets)
|
||||
for i_sheet, (s, l) in enumerate(self.sheets):
|
||||
ws = wb.create_sheet(str(l))
|
||||
if hasattr(self, 'prepare_xlsx_sheet_' + s):
|
||||
getattr(self, 'prepare_xlsx_sheet_' + s)(ws)
|
||||
|
||||
total = 0
|
||||
counter = 0
|
||||
for i, line in enumerate(self.iterate_sheet(form_data, sheet=s)):
|
||||
@@ -295,7 +312,7 @@ class MultiSheetListExporter(ListExporter):
|
||||
total = line.total
|
||||
continue
|
||||
ws.append([
|
||||
str(val) if not isinstance(val, KNOWN_TYPES) else val
|
||||
excel_safe(val)
|
||||
for val in line
|
||||
])
|
||||
if total:
|
||||
|
||||
@@ -66,7 +66,7 @@ class InvoiceExporterMixin:
|
||||
)
|
||||
|
||||
def invoices_queryset(self, form_data: dict):
|
||||
qs = Invoice.objects.filter(event__in=self.events)
|
||||
qs = Invoice.objects.filter(event__in=self.events).select_related('order')
|
||||
|
||||
if form_data.get('payment_provider'):
|
||||
qs = qs.annotate(
|
||||
@@ -112,13 +112,13 @@ class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
|
||||
invoice_pdf_task.apply(args=(i.pk,))
|
||||
i.refresh_from_db()
|
||||
i.file.open('rb')
|
||||
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
|
||||
zipf.writestr('{}-{}.pdf'.format(i.number, i.order.code), i.file.read())
|
||||
i.file.close()
|
||||
except FileNotFoundError:
|
||||
invoice_pdf_task.apply(args=(i.pk,))
|
||||
i.refresh_from_db()
|
||||
i.file.open('rb')
|
||||
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
|
||||
zipf.writestr('{}-{}.pdf'.format(i.number, i.order.code), i.file.read())
|
||||
i.file.close()
|
||||
counter += 1
|
||||
if total and counter % max(10, total // 100) == 0:
|
||||
|
||||
@@ -59,6 +59,12 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
initial=False,
|
||||
required=False
|
||||
)),
|
||||
('group_multiple_choice',
|
||||
forms.BooleanField(
|
||||
label=_('Show multiple choice answers grouped in one column'),
|
||||
initial=False,
|
||||
required=False
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -449,9 +455,14 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
for q in questions:
|
||||
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
options[q.pk] = []
|
||||
for o in q.options.all():
|
||||
headers.append(str(q.question) + ' – ' + str(o.answer))
|
||||
options[q.pk].append(o)
|
||||
if form_data['group_multiple_choice']:
|
||||
for o in q.options.all():
|
||||
options[q.pk].append(o)
|
||||
headers.append(str(q.question))
|
||||
else:
|
||||
for o in q.options.all():
|
||||
headers.append(str(q.question) + ' – ' + str(o.answer))
|
||||
options[q.pk].append(o)
|
||||
else:
|
||||
headers.append(str(q.question))
|
||||
headers += [
|
||||
@@ -551,8 +562,11 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
acache[a.question_id] = str(a)
|
||||
for q in questions:
|
||||
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
for o in options[q.pk]:
|
||||
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
|
||||
if form_data['group_multiple_choice']:
|
||||
row.append(", ".join(str(o.answer) for o in options[q.pk] if o.pk in acache.get(q.pk, set())))
|
||||
else:
|
||||
for o in options[q.pk]:
|
||||
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
|
||||
else:
|
||||
row.append(acache.get(q.pk, ''))
|
||||
|
||||
@@ -638,7 +652,7 @@ class PaymentListExporter(ListExporter):
|
||||
|
||||
headers = [
|
||||
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Status code'), _('Amount'), _('Payment method')
|
||||
_('Status code'), _('Amount'), _('Payment method'), _('Comment')
|
||||
]
|
||||
yield headers
|
||||
|
||||
@@ -660,7 +674,8 @@ class PaymentListExporter(ListExporter):
|
||||
obj.get_state_display(),
|
||||
obj.state,
|
||||
obj.amount * (-1 if isinstance(obj, OrderRefund) else 1),
|
||||
provider_names.get(obj.provider, obj.provider)
|
||||
provider_names.get(obj.provider, obj.provider),
|
||||
obj.comment if isinstance(obj, OrderRefund) else "",
|
||||
]
|
||||
yield row
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ import pycountry
|
||||
import pytz
|
||||
import vat_moss.errors
|
||||
import vat_moss.id
|
||||
from babel import Locale
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db.models import QuerySet
|
||||
from django.forms import Select
|
||||
from django.utils import translation
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -24,9 +26,7 @@ from django_countries import countries
|
||||
from django_countries.fields import Country, CountryField
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
from phonenumber_field.phonenumber import PhoneNumber
|
||||
from phonenumber_field.widgets import (
|
||||
PhoneNumberPrefixWidget, PhonePrefixSelect,
|
||||
)
|
||||
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
||||
from phonenumbers import NumberParseException, national_significant_number
|
||||
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
|
||||
|
||||
@@ -205,10 +205,39 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
return value
|
||||
|
||||
|
||||
class WrappedPhonePrefixSelect(PhonePrefixSelect):
|
||||
def __init__(self, *args, **kwargs):
|
||||
with language(get_babel_locale()):
|
||||
super().__init__(*args, **kwargs)
|
||||
class WrappedPhonePrefixSelect(Select):
|
||||
initial = None
|
||||
|
||||
def __init__(self, initial=None):
|
||||
choices = [("", "---------")]
|
||||
language = get_babel_locale() # changed from default implementation that used the django locale
|
||||
locale = Locale(translation.to_locale(language))
|
||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
prefix = "+%d" % prefix
|
||||
if initial and initial in values:
|
||||
self.initial = prefix
|
||||
for country_code in values:
|
||||
country_name = locale.territories.get(country_code)
|
||||
if country_name:
|
||||
choices.append((prefix, "{} {}".format(country_name, prefix)))
|
||||
super().__init__(choices=sorted(choices, key=lambda item: item[1]))
|
||||
|
||||
def render(self, name, value, *args, **kwargs):
|
||||
return super().render(name, value or self.initial, *args, **kwargs)
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
if value and self.choices[1][0] != value:
|
||||
matching_choices = len([1 for p, c in self.choices if p == value])
|
||||
if matching_choices > 1:
|
||||
# Some countries share a phone prefix, for example +1 is used all over the Americas.
|
||||
# This causes a UX problem: If the default value or the existing data is +12125552368,
|
||||
# the widget will just show the first <option> entry with value="+1" as selected,
|
||||
# which alphabetically is America Samoa, although most numbers statistically are from
|
||||
# the US. As a workaround, we detect this case and add an aditional choice value with
|
||||
# just <option value="+1">+1</option> without an explicit country.
|
||||
self.choices.insert(1, (value, value))
|
||||
context = super().get_context(name, value, attrs)
|
||||
return context
|
||||
|
||||
|
||||
class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||
@@ -415,6 +444,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
c = [('', pgettext_lazy('address', 'Select state'))]
|
||||
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
|
||||
cc = None
|
||||
state = None
|
||||
if fprefix + 'country' in self.data:
|
||||
cc = str(self.data[fprefix + 'country'])
|
||||
elif country:
|
||||
@@ -423,6 +453,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
|
||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
|
||||
state = (cartpos.state if cartpos else orderpos.state)
|
||||
elif fprefix + 'state' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'state']
|
||||
@@ -431,6 +462,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
label=pgettext_lazy('address', 'State'),
|
||||
required=False,
|
||||
choices=c,
|
||||
initial=state,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'address-level1',
|
||||
}),
|
||||
@@ -661,8 +693,9 @@ class BaseQuestionsForm(forms.Form):
|
||||
if not self.all_optional:
|
||||
for q in question_cache.values():
|
||||
answer = d.get('question_%d' % q.pk)
|
||||
if question_is_required(q) and not answer and answer != 0:
|
||||
raise ValidationError({'question_%d' % q.pk: [_('This field is required')]})
|
||||
field = self['question_%d' % q.pk]
|
||||
if question_is_required(q) and not answer and answer != 0 and not field.errors:
|
||||
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
|
||||
|
||||
return d
|
||||
|
||||
@@ -818,11 +851,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
) and len(data.get('name_parts', {})) == 1:
|
||||
# Do not save the country if it is the only field set -- we don't know the user even checked it!
|
||||
self.cleaned_data['country'] = ''
|
||||
|
||||
if data.get('vat_id') and is_eu_country(data.get('country')) and data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))):
|
||||
raise ValidationError(_('Your VAT ID does not match the selected country.'))
|
||||
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and is_eu_country(data.get('country')) and data.get('vat_id'):
|
||||
if data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))):
|
||||
raise ValidationError(_('Your VAT ID does not match the selected country.'))
|
||||
try:
|
||||
result = vat_moss.id.validate(data.get('vat_id'))
|
||||
if result:
|
||||
|
||||
@@ -16,12 +16,15 @@ class DatePickerWidget(forms.DateInput):
|
||||
date_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
|
||||
date_attrs['autocomplete'] = 'off'
|
||||
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
date_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
def placeholder():
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
|
||||
date_attrs['placeholder'] = lazy(placeholder, str)
|
||||
|
||||
forms.DateInput.__init__(self, date_attrs, date_format)
|
||||
|
||||
@@ -34,12 +37,15 @@ class TimePickerWidget(forms.TimeInput):
|
||||
time_attrs = dict(attrs)
|
||||
time_attrs.setdefault('class', 'form-control')
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
|
||||
time_attrs['autocomplete'] = 'off'
|
||||
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
time_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
def placeholder():
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
|
||||
time_attrs['placeholder'] = lazy(placeholder, str)
|
||||
|
||||
forms.TimeInput.__init__(self, time_attrs, time_format)
|
||||
|
||||
@@ -105,8 +111,8 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
time_attrs.setdefault('autocomplete', 'off')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
|
||||
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
|
||||
date_attrs['autocomplete'] = 'off'
|
||||
time_attrs['autocomplete'] = 'off'
|
||||
if min_date:
|
||||
date_attrs['data-min'] = (
|
||||
min_date if isinstance(min_date, date) else min_date.astimezone(get_current_timezone()).date()
|
||||
|
||||
@@ -12,12 +12,10 @@ from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import (
|
||||
get_language, gettext, gettext_lazy, pgettext,
|
||||
)
|
||||
from PIL.Image import BICUBIC
|
||||
from reportlab.lib import pagesizes
|
||||
from reportlab.lib.enums import TA_LEFT, TA_RIGHT
|
||||
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
@@ -31,6 +29,7 @@ from pretix.base.decimal import round_decimal
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -221,26 +220,6 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
return 'invoice.pdf', 'application/pdf', buffer.read()
|
||||
|
||||
|
||||
class ThumbnailingImageReader(ImageReader):
|
||||
def resize(self, width, height, dpi):
|
||||
if width is None:
|
||||
width = height * self._image.size[0] / self._image.size[1]
|
||||
if height is None:
|
||||
height = width * self._image.size[1] / self._image.size[0]
|
||||
self._image.thumbnail(
|
||||
size=(int(width * dpi / 72), int(height * dpi / 72)),
|
||||
resample=BICUBIC
|
||||
)
|
||||
self._data = None
|
||||
return width, height
|
||||
|
||||
def _jpeg_fh(self):
|
||||
# Bypass a reportlab-internal optimization that falls back to the original
|
||||
# file handle if the file is a JPEG, and therefore does not respect the
|
||||
# (smaller) size of the modified image.
|
||||
return None
|
||||
|
||||
|
||||
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
identifier = 'classic'
|
||||
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
|
||||
|
||||
@@ -30,7 +30,7 @@ class Command(BaseCommand):
|
||||
continue
|
||||
|
||||
if verbosity > 1:
|
||||
self.stdout.write(f'Running {name}…')
|
||||
self.stdout.write(f'INFO Running {name}…')
|
||||
t0 = time.time()
|
||||
try:
|
||||
r = receiver(signal=periodic_task, sender=self)
|
||||
@@ -40,13 +40,13 @@ class Command(BaseCommand):
|
||||
if settings.SENTRY_ENABLED:
|
||||
from sentry_sdk import capture_exception
|
||||
capture_exception(err)
|
||||
self.stdout.write(self.style.ERROR(f'FAIL: {str(err)}\n'))
|
||||
self.stdout.write(self.style.ERROR(f'ERROR runperiodic {str(err)}\n'))
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(f'FAIL: {str(err)}\n'))
|
||||
self.stdout.write(self.style.ERROR(f'ERROR runperiodic {str(err)}\n'))
|
||||
traceback.print_exc()
|
||||
else:
|
||||
if options.get('verbosity') > 1:
|
||||
if r is SKIPPED:
|
||||
self.stdout.write(self.style.SUCCESS(f'Skipped {name}'))
|
||||
self.stdout.write(self.style.SUCCESS(f'INFO Skipped {name}'))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(f'Completed {name} in {round(time.time() - t0, 3)}s'))
|
||||
self.stdout.write(self.style.SUCCESS(f'INFO Completed {name} in {round(time.time() - t0, 3)}s'))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from django.apps import apps
|
||||
@@ -6,6 +8,7 @@ from django.conf import settings
|
||||
from django.db import connection
|
||||
|
||||
from pretix.base.models import Event, Invoice, Order, OrderPosition, Organizer
|
||||
from pretix.celery_app import app
|
||||
|
||||
if settings.HAS_REDIS:
|
||||
import django_redis
|
||||
@@ -248,6 +251,19 @@ def metric_values():
|
||||
else:
|
||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m)
|
||||
|
||||
if settings.HAS_CELERY:
|
||||
client = app.broker_connection().channel().client
|
||||
for q in settings.CELERY_TASK_QUEUES:
|
||||
llen = client.llen(q.name)
|
||||
lfirst = client.lindex(q.name, -1)
|
||||
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = llen
|
||||
if lfirst:
|
||||
ldata = json.loads(lfirst)
|
||||
dt = time.time() - ldata.get('created', 0)
|
||||
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = dt
|
||||
else:
|
||||
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
|
||||
18
src/pretix/base/migrations/0175_orderrefund_comment.py
Normal file
18
src/pretix/base/migrations/0175_orderrefund_comment.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.11 on 2021-01-15 09:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0174_merge_20201222_1031'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='orderrefund',
|
||||
name='comment',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
]
|
||||
28
src/pretix/base/migrations/0176_auto_20210205_1512.py
Normal file
28
src/pretix/base/migrations/0176_auto_20210205_1512.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.0.11 on 2021-02-05 15:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0175_orderrefund_comment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='eventmetaproperty',
|
||||
name='allowed_values',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventmetaproperty',
|
||||
name='protected',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventmetaproperty',
|
||||
name='required',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -10,15 +10,18 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import (
|
||||
MaxValueValidator, MinLengthValidator, MinValueValidator, RegexValidator,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery, Value
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
@@ -353,8 +356,11 @@ class Event(EventMixin, LoggedModel):
|
||||
"remembered, but you can also choose to use a random value. "
|
||||
"This will be used in URLs, order codes, invoice numbers, and bank transfer references."),
|
||||
validators=[
|
||||
MinLengthValidator(
|
||||
limit_value=2,
|
||||
),
|
||||
RegexValidator(
|
||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*$",
|
||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$",
|
||||
message=_("The slug may only contain letters, numbers, dots and dashes."),
|
||||
),
|
||||
EventSlugBanlistValidator()
|
||||
@@ -393,10 +399,18 @@ class Event(EventMixin, LoggedModel):
|
||||
geo_lat = models.FloatField(
|
||||
verbose_name=_("Latitude"),
|
||||
null=True, blank=True,
|
||||
validators=[
|
||||
MinValueValidator(-90),
|
||||
MaxValueValidator(90),
|
||||
]
|
||||
)
|
||||
geo_lon = models.FloatField(
|
||||
verbose_name=_("Longitude"),
|
||||
null=True, blank=True,
|
||||
validators=[
|
||||
MinValueValidator(-180),
|
||||
MaxValueValidator(180),
|
||||
]
|
||||
)
|
||||
plugins = models.TextField(
|
||||
null=False, blank=True,
|
||||
@@ -848,7 +862,7 @@ class Event(EventMixin, LoggedModel):
|
||||
Returns the currently configured ticket secret generator.
|
||||
"""
|
||||
tsgs = self.ticket_secret_generators
|
||||
return tsgs[self.settings.ticket_secret_generator]
|
||||
return tsgs.get(self.settings.ticket_secret_generator, tsgs.get('random'))
|
||||
|
||||
def get_data_shredders(self) -> dict:
|
||||
"""
|
||||
@@ -940,6 +954,18 @@ class Event(EventMixin, LoggedModel):
|
||||
if not self.quotas.exists():
|
||||
issues.append(_('You need to configure at least one quota to sell anything.'))
|
||||
|
||||
for mp in self.organizer.meta_properties.all():
|
||||
if mp.required and not self.meta_data.get(mp.name):
|
||||
issues.append(
|
||||
('<a {a_attr}>' + gettext('You need to fill the meta parameter "{property}".') + '</a>').format(
|
||||
property=mp.name,
|
||||
a_attr='href="%s#id_prop-%d-value"' % (
|
||||
reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
|
||||
mp.pk
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
responses = event_live_issues.send(self)
|
||||
for receiver, response in sorted(responses, key=lambda r: str(r[0])):
|
||||
if response:
|
||||
@@ -1121,10 +1147,18 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
geo_lat = models.FloatField(
|
||||
verbose_name=_("Latitude"),
|
||||
null=True, blank=True,
|
||||
validators=[
|
||||
MinValueValidator(-90),
|
||||
MaxValueValidator(90),
|
||||
]
|
||||
)
|
||||
geo_lon = models.FloatField(
|
||||
verbose_name=_("Longitude"),
|
||||
null=True, blank=True
|
||||
null=True, blank=True,
|
||||
validators=[
|
||||
MinValueValidator(-180),
|
||||
MaxValueValidator(180),
|
||||
]
|
||||
)
|
||||
frontpage_text = I18nTextField(
|
||||
null=True, blank=True,
|
||||
@@ -1342,7 +1376,26 @@ class EventMetaProperty(LoggedModel):
|
||||
],
|
||||
verbose_name=_("Name"),
|
||||
)
|
||||
default = models.TextField(blank=True)
|
||||
default = models.TextField(blank=True, verbose_name=_("Default value"))
|
||||
protected = models.BooleanField(default=False,
|
||||
verbose_name=_("Can only be changed by organizer-level administrators"))
|
||||
required = models.BooleanField(
|
||||
default=False, verbose_name=_("Required for events"),
|
||||
help_text=_("If checked, an event can only be taken live if the property is set. In event series, its always "
|
||||
"optional to set a value for individual dates")
|
||||
)
|
||||
allowed_values = models.TextField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Valid values"),
|
||||
help_text=_("If you keep this empty, any value is allowed. Otherwise, enter one possible value per line.")
|
||||
)
|
||||
|
||||
def full_clean(self, exclude=None, validate_unique=True):
|
||||
super().full_clean(exclude, validate_unique)
|
||||
if self.default and self.required:
|
||||
raise ValidationError(_("A property can either be required or have a default value, not both."))
|
||||
if self.default and self.allowed_values and self.default not in self.allowed_values.splitlines():
|
||||
raise ValidationError(_("You cannot set a default value that is not a valid value."))
|
||||
|
||||
|
||||
class EventMetaValue(LoggedModel):
|
||||
|
||||
@@ -1026,7 +1026,7 @@ class Question(LoggedModel):
|
||||
(TYPE_PHONENUMBER, _("Phone number")),
|
||||
)
|
||||
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
|
||||
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE, TYPE_PHONENUMBER]
|
||||
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_PHONENUMBER]
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
@@ -1069,6 +1069,7 @@ class Question(LoggedModel):
|
||||
)
|
||||
ask_during_checkin = models.BooleanField(
|
||||
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
|
||||
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||
default=False
|
||||
)
|
||||
hidden = models.BooleanField(
|
||||
|
||||
@@ -2,7 +2,6 @@ import copy
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import string
|
||||
from collections import Counter
|
||||
from datetime import datetime, time, timedelta
|
||||
@@ -615,21 +614,26 @@ class Order(LockModel, LoggedModel):
|
||||
return proposals
|
||||
|
||||
@staticmethod
|
||||
def normalize_code(code):
|
||||
tr = str.maketrans({
|
||||
def normalize_code(code, is_fallback=False):
|
||||
d = {
|
||||
'2': 'Z',
|
||||
'4': 'A',
|
||||
'5': 'S',
|
||||
'6': 'G',
|
||||
})
|
||||
}
|
||||
if is_fallback:
|
||||
d['8'] = 'B'
|
||||
# 8 has been removed from the character set only in 2021, which means there are a lot of order codes
|
||||
# with an 8 in it around. We only want to replace this when this is used in a fallback.
|
||||
tr = str.maketrans(d)
|
||||
return code.upper().translate(tr)
|
||||
|
||||
def assign_code(self):
|
||||
# This omits some character pairs completely because they are hard to read even on screens (1/I and O/0)
|
||||
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
|
||||
# handwriting (2/Z, 4/A, 5/S, 6/G). This allows for better detection e.g. in incoming wire transfers that
|
||||
# handwriting (2/Z, 4/A, 5/S, 6/G, 8/B). This allows for better detection e.g. in incoming wire transfers that
|
||||
# might include OCR'd handwritten text
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ379')
|
||||
iteration = 0
|
||||
length = settings.ENTROPY['order_code']
|
||||
while True:
|
||||
@@ -661,6 +665,8 @@ class Order(LockModel, LoggedModel):
|
||||
related to the order. This checks order status and modification deadlines. It also
|
||||
returns ``False`` if there are no questions that can be answered.
|
||||
"""
|
||||
from .checkin import Checkin
|
||||
|
||||
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED):
|
||||
return False
|
||||
|
||||
@@ -676,10 +682,21 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
if modify_deadline is not None and now() > modify_deadline:
|
||||
return False
|
||||
|
||||
positions = list(
|
||||
self.positions.all().annotate(
|
||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
|
||||
).select_related('item').prefetch_related('item__questions')
|
||||
)
|
||||
if not self.event.settings.allow_modifications_after_checkin:
|
||||
for cp in positions:
|
||||
if cp.has_checkin:
|
||||
return False
|
||||
|
||||
if self.event.settings.get('invoice_address_asked', as_type=bool):
|
||||
return True
|
||||
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
|
||||
for cp in self.positions.all().prefetch_related('item__questions'):
|
||||
for cp in positions:
|
||||
if (cp.item.admission and ask_names) or cp.item.questions.all():
|
||||
return True
|
||||
|
||||
@@ -1219,6 +1236,9 @@ class AbstractPosition(models.Model):
|
||||
else self.variation.quotas.filter(subevent=self.subevent))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
if 'attendee_name_parts' in update_fields:
|
||||
update_fields.append('attendee_name_cached')
|
||||
self.attendee_name_cached = self.attendee_name
|
||||
if self.attendee_name_parts is None:
|
||||
self.attendee_name_parts = {}
|
||||
@@ -1484,7 +1504,7 @@ class OrderPayment(models.Model):
|
||||
OrderRefund.REFUND_STATE_CREATED)
|
||||
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||
if payment_sum - refund_sum < self.order.total:
|
||||
logger.info('Confirmed payment {} but payment sum is {} and refund sum is.'.format(
|
||||
logger.info('Confirmed payment {} but payment sum is {} and refund sum is {}.'.format(
|
||||
self.full_id, payment_sum, refund_sum
|
||||
))
|
||||
return
|
||||
@@ -1708,6 +1728,11 @@ class OrderRefund(models.Model):
|
||||
max_length=255,
|
||||
verbose_name=_("Payment provider")
|
||||
)
|
||||
comment = models.TextField(
|
||||
verbose_name=_("Refund reason"),
|
||||
help_text=_('May be shown to the end user or used e.g. as part of a payment reference.'),
|
||||
null=True, blank=True
|
||||
)
|
||||
info = models.TextField(
|
||||
verbose_name=_("Payment information"),
|
||||
null=True, blank=True
|
||||
@@ -1746,7 +1771,7 @@ class OrderRefund(models.Model):
|
||||
Marks the refund as complete. This does not modify the state of the order.
|
||||
|
||||
:param user: The user who performed the change
|
||||
:param user: The API auth token that performed the change
|
||||
:param auth: The API auth token that performed the change
|
||||
"""
|
||||
self.state = self.REFUND_STATE_DONE
|
||||
self.execution_date = self.execution_date or now()
|
||||
@@ -2307,7 +2332,6 @@ def cachedticket_name(instance, filename: str) -> str:
|
||||
no=instance.order_position.positionid,
|
||||
code=instance.order_position.order.code,
|
||||
secret=secret,
|
||||
ext=os.path.splitext(filename)[1]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import string
|
||||
from datetime import date, datetime, time
|
||||
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import MinLengthValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.urls import reverse
|
||||
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
|
||||
@@ -38,8 +39,11 @@ class Organizer(LoggedModel):
|
||||
"Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used "
|
||||
"once. This is being used in URLs to refer to your organizer accounts and your events."),
|
||||
validators=[
|
||||
MinLengthValidator(
|
||||
limit_value=2,
|
||||
),
|
||||
RegexValidator(
|
||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]+$",
|
||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$",
|
||||
message=_("The slug may only contain letters, numbers, dots and dashes.")
|
||||
),
|
||||
OrganizerSlugBanlistValidator()
|
||||
@@ -85,6 +89,15 @@ class Organizer(LoggedModel):
|
||||
|
||||
return ObjectRelatedCache(self)
|
||||
|
||||
@cached_property
|
||||
def all_logentries_link(self):
|
||||
return reverse(
|
||||
'control:organizer.log',
|
||||
kwargs={
|
||||
'organizer': self.slug,
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def has_gift_cards(self):
|
||||
return self.cache.get_or_set(
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytz
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||
from django.db import transaction
|
||||
from django.dispatch import receiver
|
||||
from django.forms import Form
|
||||
@@ -706,12 +706,24 @@ class BasePaymentProvider:
|
||||
It should return HTML code containing information regarding the current payment
|
||||
status and, if applicable, next steps.
|
||||
|
||||
The default implementation returns the verbose name of the payment provider.
|
||||
The default implementation returns an empty string.
|
||||
|
||||
:param order: The order object
|
||||
"""
|
||||
return ''
|
||||
|
||||
def payment_control_render_short(self, payment: OrderPayment) -> str:
|
||||
"""
|
||||
Will be called if the *event administrator* performs an action on the payment. Should
|
||||
return a very short version of the payment method. Usually, this should return e.g.
|
||||
a transaction ID or account identifier, but no information on status, dates, etc.
|
||||
|
||||
The default implementation falls back to ``payment_presale_render``.
|
||||
|
||||
:param order: The order object
|
||||
"""
|
||||
return self.payment_presale_render(payment)
|
||||
|
||||
def refund_control_render(self, request: HttpRequest, refund: OrderRefund) -> str:
|
||||
"""
|
||||
Will be called if the *event administrator* views the details of a refund.
|
||||
@@ -725,6 +737,19 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return ''
|
||||
|
||||
def payment_presale_render(self, payment: OrderPayment) -> str:
|
||||
"""
|
||||
Will be called if the *ticket customer* views the details of a payment. This is
|
||||
currently used e.g. when the customer requests a refund to show which payment
|
||||
method is used for the refund. This should only include very basic information
|
||||
about the payment, such as "VISA card ****9999", and never raw payment information.
|
||||
|
||||
The default implementation returns the public name of the payment provider.
|
||||
|
||||
:param order: The order object
|
||||
"""
|
||||
return self.public_name
|
||||
|
||||
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
"""
|
||||
Will be called to check if the provider supports automatic refunding for this
|
||||
@@ -760,6 +785,32 @@ class BasePaymentProvider:
|
||||
"""
|
||||
raise PaymentException(_('Automatic refunds are not supported by this payment provider.'))
|
||||
|
||||
def new_refund_control_form_render(self, request: HttpRequest, order: Order) -> str:
|
||||
"""
|
||||
Render a form that will be shown to backend users when trying to create a new refund.
|
||||
|
||||
Usually, refunds are created from an existing payment object, e.g. if there is a credit card
|
||||
payment and the credit card provider returns ``True`` from ``payment_refund_supported``, the system
|
||||
will automatically create an ``OrderRefund`` and call ``execute_refund`` on that payment. This method
|
||||
can and should not be used in that situation! Instead, by implementing this method you can add a refund
|
||||
flow for this payment provider that starts without an existing payment. For example, even though an order
|
||||
was paid by credit card, it could easily be refunded by SEPA bank transfer. In that case, the SEPA bank
|
||||
transfer provider would implement this method and return a form that asks for the IBAN.
|
||||
|
||||
This method should return HTML or ``None``. All form fields should have a globally unique name.
|
||||
"""
|
||||
return
|
||||
|
||||
def new_refund_control_form_process(self, request: HttpRequest, amount: Decimal, order: Order) -> OrderRefund:
|
||||
"""
|
||||
Process a backend user's request to initiate a new refund with an amount of ``amount`` for ``order``.
|
||||
|
||||
This method should parse the input provided to the form created and either raise ``ValidationError``
|
||||
or return an ``OrderRefund`` object in ``created`` state that has not yet been saved to the database.
|
||||
The system will then call ``execute_refund`` on that object.
|
||||
"""
|
||||
raise ValidationError('Not implemented')
|
||||
|
||||
def shred_payment_info(self, obj: Union[OrderPayment, OrderRefund]):
|
||||
"""
|
||||
When personal data is removed from an event, this method is called to scrub payment-related data
|
||||
@@ -899,7 +950,7 @@ class ManualPayment(BasePaymentProvider):
|
||||
|
||||
@property
|
||||
def public_name(self):
|
||||
return str(self.settings.get('public_name', as_type=LazyI18nString))
|
||||
return str(self.settings.get('public_name', as_type=LazyI18nString) or _('Manual payment'))
|
||||
|
||||
@property
|
||||
def settings_form_fields(self):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
@@ -35,9 +36,9 @@ from reportlab.platypus import Paragraph
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.invoice import ThumbnailingImageReader
|
||||
from pretix.base.models import Order, OrderPosition
|
||||
from pretix.base.models import Order, OrderPosition, Question
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import layout_text_variables
|
||||
from pretix.base.signals import layout_image_variables, layout_text_variables
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.phone_format import phone_format
|
||||
from pretix.presale.style import get_fonts
|
||||
@@ -154,6 +155,11 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("Sample event name"),
|
||||
"evaluate": lambda op, order, ev: str(ev.name)
|
||||
}),
|
||||
("event_series_name", {
|
||||
"label": _("Event series"),
|
||||
"editor_sample": _("Sample event name"),
|
||||
"evaluate": lambda op, order, ev: str(order.event.name)
|
||||
}),
|
||||
("event_date", {
|
||||
"label": _("Event date"),
|
||||
"editor_sample": _("May 31st, 2017"),
|
||||
@@ -339,6 +345,47 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "")
|
||||
}),
|
||||
))
|
||||
DEFAULT_IMAGES = OrderedDict([])
|
||||
|
||||
|
||||
@receiver(layout_image_variables, dispatch_uid="pretix_base_layout_image_variables_questions")
|
||||
def images_from_questions(sender, *args, **kwargs):
|
||||
def get_answer(op, order, event, question_id, etag):
|
||||
a = None
|
||||
if op.addon_to:
|
||||
if 'answers' in getattr(op.addon_to, '_prefetched_objects_cache', {}):
|
||||
try:
|
||||
a = [a for a in op.addon_to.answers.all() if a.question_id == question_id][0]
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
a = op.addon_to.answers.filter(question_id=question_id).first()
|
||||
|
||||
if 'answers' in getattr(op, '_prefetched_objects_cache', {}):
|
||||
try:
|
||||
a = [a for a in op.answers.all() if a.question_id == question_id][0]
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
a = op.answers.filter(question_id=question_id).first()
|
||||
|
||||
if not a or not a.file or not any(a.file.name.lower().endswith(e) for e in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff")):
|
||||
return None
|
||||
else:
|
||||
if etag:
|
||||
return hashlib.sha1(a.file.name.encode()).hexdigest()
|
||||
return a.file
|
||||
|
||||
d = {}
|
||||
for q in sender.questions.all():
|
||||
if q.type != Question.TYPE_FILE:
|
||||
continue
|
||||
d['question_{}'.format(q.identifier)] = {
|
||||
'label': _('Question: {question}').format(question=q.question),
|
||||
'evaluate': partial(get_answer, question_id=q.pk, etag=False),
|
||||
'etag': partial(get_answer, question_id=q.pk, etag=True),
|
||||
}
|
||||
return d
|
||||
|
||||
|
||||
@receiver(layout_text_variables, dispatch_uid="pretix_base_layout_text_variables_questions")
|
||||
@@ -369,6 +416,8 @@ def variables_from_questions(sender, *args, **kwargs):
|
||||
|
||||
d = {}
|
||||
for q in sender.questions.all():
|
||||
if q.type == Question.TYPE_FILE:
|
||||
continue
|
||||
d['question_{}'.format(q.pk)] = {
|
||||
'label': _('Question: {question}').format(question=q.question),
|
||||
'editor_sample': _('<Answer: {question}>').format(question=q.question),
|
||||
@@ -387,6 +436,15 @@ def _get_ia_name_part(key, op, order, ev):
|
||||
return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
||||
|
||||
|
||||
def get_images(event):
|
||||
v = copy.copy(DEFAULT_IMAGES)
|
||||
|
||||
for recv, res in layout_image_variables.send(sender=event):
|
||||
v.update(res)
|
||||
|
||||
return v
|
||||
|
||||
|
||||
def get_variables(event):
|
||||
v = copy.copy(DEFAULT_VARIABLES)
|
||||
|
||||
@@ -427,6 +485,7 @@ class Renderer:
|
||||
self.layout = layout
|
||||
self.background_file = background_file
|
||||
self.variables = get_variables(event)
|
||||
self.images = get_images(event)
|
||||
self.event = event
|
||||
if self.background_file:
|
||||
self.bg_bytes = self.background_file.read()
|
||||
@@ -514,6 +573,47 @@ class Renderer:
|
||||
return '(error)'
|
||||
return ''
|
||||
|
||||
def _draw_imagearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
||||
ev = self._get_ev(op, order)
|
||||
if not o['content'] or o['content'] not in self.images:
|
||||
image_file = None
|
||||
else:
|
||||
try:
|
||||
image_file = self.images[o['content']]['evaluate'](op, order, ev)
|
||||
except:
|
||||
logger.exception('Failed to process variable.')
|
||||
image_file = None
|
||||
|
||||
if image_file:
|
||||
ir = ThumbnailingImageReader(image_file)
|
||||
try:
|
||||
ir.resize(float(o['width']) * mm, float(o['height']) * mm, 300)
|
||||
except:
|
||||
logger.exception("Can not resize image")
|
||||
pass
|
||||
canvas.drawImage(
|
||||
image=ir,
|
||||
x=float(o['left']) * mm,
|
||||
y=float(o['bottom']) * mm,
|
||||
width=float(o['width']) * mm,
|
||||
height=float(o['height']) * mm,
|
||||
preserveAspectRatio=True,
|
||||
anchor='c', # centered in frame
|
||||
mask='auto'
|
||||
)
|
||||
else:
|
||||
canvas.saveState()
|
||||
canvas.setFillColorRGB(.8, .8, .8, alpha=1)
|
||||
canvas.rect(
|
||||
x=float(o['left']) * mm,
|
||||
y=float(o['bottom']) * mm,
|
||||
width=float(o['width']) * mm,
|
||||
height=float(o['height']) * mm,
|
||||
stroke=0,
|
||||
fill=1,
|
||||
)
|
||||
canvas.restoreState()
|
||||
|
||||
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
||||
font = o['fontfamily']
|
||||
if o['bold']:
|
||||
@@ -572,6 +672,8 @@ class Renderer:
|
||||
for o in self.layout:
|
||||
if o['type'] == "barcodearea":
|
||||
self._draw_barcodearea(canvas, op, o)
|
||||
elif o['type'] == "imagearea":
|
||||
self._draw_imagearea(canvas, op, order, o)
|
||||
elif o['type'] == "textarea":
|
||||
self._draw_textarea(canvas, op, order, o)
|
||||
elif o['type'] == "poweredby":
|
||||
|
||||
@@ -4,7 +4,7 @@ import struct
|
||||
|
||||
from cryptography.hazmat.backends.openssl.backend import Backend
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization.base import (
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key,
|
||||
load_pem_public_key,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, IntegerField, OuterRef, Subquery
|
||||
from django.utils.translation import gettext
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
@@ -195,7 +196,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
if auto_refund:
|
||||
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions)
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||
comment=gettext('Event canceled'))
|
||||
finally:
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
|
||||
@@ -252,7 +254,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
if auto_refund:
|
||||
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions)
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||
comment=gettext('Event canceled'))
|
||||
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import dateutil
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.dispatch import receiver
|
||||
@@ -23,7 +24,7 @@ def get_logic_environment(ev):
|
||||
elif t == 'date_from':
|
||||
return ev.date_from
|
||||
elif t == 'date_to':
|
||||
return ev.date_to
|
||||
return ev.date_to or ev.date_from
|
||||
elif t == 'date_admission':
|
||||
return ev.date_admission or ev.date_from
|
||||
|
||||
@@ -101,9 +102,11 @@ class RequiredQuestionsError(Exception):
|
||||
|
||||
|
||||
def _save_answers(op, answers, given_answers):
|
||||
written = False
|
||||
for q, a in given_answers.items():
|
||||
if not a:
|
||||
if q in answers:
|
||||
written = True
|
||||
answers[q].delete()
|
||||
else:
|
||||
continue
|
||||
@@ -112,6 +115,7 @@ def _save_answers(op, answers, given_answers):
|
||||
qa = answers[q]
|
||||
qa.answer = str(a.answer)
|
||||
qa.save()
|
||||
written = True
|
||||
qa.options.clear()
|
||||
else:
|
||||
qa = op.answers.create(question=q, answer=str(a.answer))
|
||||
@@ -121,10 +125,20 @@ def _save_answers(op, answers, given_answers):
|
||||
qa = answers[q]
|
||||
qa.answer = ", ".join([str(o) for o in a])
|
||||
qa.save()
|
||||
written = True
|
||||
qa.options.clear()
|
||||
else:
|
||||
qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a]))
|
||||
qa.options.add(*a)
|
||||
elif isinstance(a, File):
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
else:
|
||||
qa = op.answers.create(question=q, answer=str(a))
|
||||
qa.file.save(a.name, a, save=False)
|
||||
qa.answer = 'file://' + qa.file.name
|
||||
qa.save()
|
||||
written = True
|
||||
else:
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
@@ -132,9 +146,14 @@ def _save_answers(op, answers, given_answers):
|
||||
qa.save()
|
||||
else:
|
||||
op.answers.create(question=q, answer=str(a))
|
||||
written = True
|
||||
|
||||
if written:
|
||||
prefetched_objects_cache = getattr(op, '_prefetched_objects_cache', {})
|
||||
if 'answers' in prefetched_objects_cache:
|
||||
del prefetched_objects_cache['answers']
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
|
||||
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
|
||||
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY):
|
||||
@@ -154,18 +173,16 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
"""
|
||||
dt = datetime or now()
|
||||
|
||||
# Lock order positions
|
||||
op = OrderPosition.all.select_for_update().get(pk=op.pk)
|
||||
checkin_questions = list(
|
||||
clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id])
|
||||
)
|
||||
|
||||
if op.canceled or op.order.status not in (Order.STATUS_PAID, Order.STATUS_PENDING):
|
||||
raise CheckInError(
|
||||
_('This order position has been canceled.'),
|
||||
'canceled' if canceled_supported else 'unpaid'
|
||||
)
|
||||
|
||||
# Do this outside of transaction so it is saved even if the checkin fails for some other reason
|
||||
checkin_questions = list(
|
||||
clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id])
|
||||
)
|
||||
require_answers = []
|
||||
if checkin_questions:
|
||||
answers = {a.question: a for a in op.answers.all()}
|
||||
@@ -175,81 +192,85 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
|
||||
_save_answers(op, answers, given_answers)
|
||||
|
||||
if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]:
|
||||
raise CheckInError(
|
||||
_('This order position has an invalid product for this check-in list.'),
|
||||
'product'
|
||||
)
|
||||
elif clist.subevent_id and op.subevent_id != clist.subevent_id:
|
||||
raise CheckInError(
|
||||
_('This order position has an invalid date for this check-in list.'),
|
||||
'product'
|
||||
)
|
||||
elif op.order.status != Order.STATUS_PAID and not force and not (
|
||||
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
|
||||
):
|
||||
raise CheckInError(
|
||||
_('This order is not marked as paid.'),
|
||||
'unpaid'
|
||||
)
|
||||
elif require_answers and not force and questions_supported:
|
||||
raise RequiredQuestionsError(
|
||||
_('You need to answer questions to complete this check-in.'),
|
||||
'incomplete',
|
||||
require_answers
|
||||
)
|
||||
with transaction.atomic():
|
||||
# Lock order positions
|
||||
op = OrderPosition.all.select_for_update().get(pk=op.pk)
|
||||
|
||||
if type == Checkin.TYPE_ENTRY and clist.rules and not force:
|
||||
rule_data = LazyRuleVars(op, clist, dt)
|
||||
logic = get_logic_environment(op.subevent or clist.event)
|
||||
if not logic.apply(clist.rules, rule_data):
|
||||
if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]:
|
||||
raise CheckInError(
|
||||
_('This entry is not permitted due to custom rules.'),
|
||||
'rules'
|
||||
_('This order position has an invalid product for this check-in list.'),
|
||||
'product'
|
||||
)
|
||||
elif clist.subevent_id and op.subevent_id != clist.subevent_id:
|
||||
raise CheckInError(
|
||||
_('This order position has an invalid date for this check-in list.'),
|
||||
'product'
|
||||
)
|
||||
elif op.order.status != Order.STATUS_PAID and not force and not (
|
||||
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
|
||||
):
|
||||
raise CheckInError(
|
||||
_('This order is not marked as paid.'),
|
||||
'unpaid'
|
||||
)
|
||||
elif require_answers and not force and questions_supported:
|
||||
raise RequiredQuestionsError(
|
||||
_('You need to answer questions to complete this check-in.'),
|
||||
'incomplete',
|
||||
require_answers
|
||||
)
|
||||
|
||||
device = None
|
||||
if isinstance(auth, Device):
|
||||
device = auth
|
||||
if type == Checkin.TYPE_ENTRY and clist.rules and not force:
|
||||
rule_data = LazyRuleVars(op, clist, dt)
|
||||
logic = get_logic_environment(op.subevent or clist.event)
|
||||
if not logic.apply(clist.rules, rule_data):
|
||||
raise CheckInError(
|
||||
_('This entry is not permitted due to custom rules.'),
|
||||
'rules'
|
||||
)
|
||||
|
||||
last_ci = op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce').first()
|
||||
entry_allowed = (
|
||||
type == Checkin.TYPE_EXIT or
|
||||
clist.allow_multiple_entries or
|
||||
last_ci is None or
|
||||
(clist.allow_entry_after_exit and last_ci.type == Checkin.TYPE_EXIT)
|
||||
)
|
||||
device = None
|
||||
if isinstance(auth, Device):
|
||||
device = auth
|
||||
|
||||
if nonce and ((last_ci and last_ci.nonce == nonce) or op.checkins.filter(type=type, list=clist, device=device, nonce=nonce).exists()):
|
||||
return
|
||||
|
||||
if entry_allowed or force:
|
||||
ci = Checkin.objects.create(
|
||||
position=op,
|
||||
type=type,
|
||||
list=clist,
|
||||
datetime=dt,
|
||||
device=device,
|
||||
gate=device.gate if device else None,
|
||||
nonce=nonce,
|
||||
forced=force and not entry_allowed,
|
||||
)
|
||||
op.order.log_action('pretix.event.checkin', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'first': True,
|
||||
'forced': force or op.order.status != Order.STATUS_PAID,
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': clist.pk
|
||||
}, user=user, auth=auth)
|
||||
checkin_created.send(op.order.event, checkin=ci)
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('This ticket has already been redeemed.'),
|
||||
'already_redeemed',
|
||||
last_ci = op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce').first()
|
||||
entry_allowed = (
|
||||
type == Checkin.TYPE_EXIT or
|
||||
clist.allow_multiple_entries or
|
||||
last_ci is None or
|
||||
(clist.allow_entry_after_exit and last_ci.type == Checkin.TYPE_EXIT)
|
||||
)
|
||||
|
||||
if nonce and ((last_ci and last_ci.nonce == nonce) or op.checkins.filter(type=type, list=clist, device=device, nonce=nonce).exists()):
|
||||
return
|
||||
|
||||
if entry_allowed or force:
|
||||
ci = Checkin.objects.create(
|
||||
position=op,
|
||||
type=type,
|
||||
list=clist,
|
||||
datetime=dt,
|
||||
device=device,
|
||||
gate=device.gate if device else None,
|
||||
nonce=nonce,
|
||||
forced=force and not entry_allowed,
|
||||
)
|
||||
op.order.log_action('pretix.event.checkin', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'first': True,
|
||||
'forced': force or op.order.status != Order.STATUS_PAID,
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': clist.pk
|
||||
}, user=user, auth=auth)
|
||||
checkin_created.send(op.order.event, checkin=ci)
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('This ticket has already been redeemed.'),
|
||||
'already_redeemed',
|
||||
)
|
||||
|
||||
|
||||
@receiver(order_placed, dispatch_uid="autocheckin_order_placed")
|
||||
def order_placed(sender, **kwargs):
|
||||
|
||||
@@ -2034,7 +2034,7 @@ _unset = object()
|
||||
|
||||
|
||||
def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None):
|
||||
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None, comment=None):
|
||||
notify_admin = False
|
||||
error = False
|
||||
if isinstance(order, int):
|
||||
@@ -2059,6 +2059,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
||||
order=order,
|
||||
payment=None,
|
||||
source=source,
|
||||
comment=comment,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
execution_date=now(),
|
||||
amount=can_auto_refund_sum,
|
||||
@@ -2096,6 +2097,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
||||
source=source,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
comment=comment,
|
||||
provider=p.provider
|
||||
)
|
||||
order.log_action('pretix.event.order.refund.created', {
|
||||
@@ -2125,6 +2127,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
||||
with transaction.atomic():
|
||||
r = order.refunds.create(
|
||||
source=source,
|
||||
comment=comment,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=refund_amount - can_auto_refund_sum,
|
||||
provider='manual'
|
||||
@@ -2149,13 +2152,14 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
@scopes_disabled()
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False):
|
||||
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False, comment=None):
|
||||
try:
|
||||
try:
|
||||
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||
cancellation_fee)
|
||||
if try_auto_refund:
|
||||
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard)
|
||||
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
|
||||
comment=comment)
|
||||
return ret
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
|
||||
@@ -36,7 +36,7 @@ def validate_plan_change(event, subevent, plan):
|
||||
'already sold.'), leftovers[0])
|
||||
|
||||
|
||||
def generate_seats(event, subevent, plan, mapping):
|
||||
def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
|
||||
current_seats = {}
|
||||
for s in event.seats.select_related('product').annotate(
|
||||
has_op=Count('orderposition'), has_v=Count('vouchers')
|
||||
@@ -68,7 +68,10 @@ def generate_seats(event, subevent, plan, mapping):
|
||||
update(seat, 'seat_label', ss.seat_label),
|
||||
update(seat, 'x', ss.x),
|
||||
update(seat, 'y', ss.y),
|
||||
])
|
||||
] + (
|
||||
[update(seat, 'blocked', ss.guid in blocked_guids)]
|
||||
if blocked_guids else []
|
||||
))
|
||||
if updated:
|
||||
seat.save()
|
||||
else:
|
||||
@@ -84,6 +87,7 @@ def generate_seats(event, subevent, plan, mapping):
|
||||
seat_label=ss.seat_label,
|
||||
x=ss.x,
|
||||
y=ss.y,
|
||||
blocked=bool(blocked_guids and ss.guid in blocked_guids),
|
||||
product=p,
|
||||
))
|
||||
|
||||
|
||||
@@ -75,16 +75,19 @@ def shred(event: Event, fileid: str, confirm_code: str) -> None:
|
||||
indexdata = json.loads(zipfile.read('index.json').decode())
|
||||
if indexdata['organizer'] != event.organizer.slug or indexdata['event'] != event.slug:
|
||||
raise ShredError(_("This file is from a different event."))
|
||||
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'])):
|
||||
raise ShredError(_("Something happened in your event after the export, please try again."))
|
||||
|
||||
shredders = []
|
||||
for s in indexdata['shredders']:
|
||||
shredder = known_shredders.get(s)
|
||||
if not shredder:
|
||||
continue
|
||||
shredders.append(shredder)
|
||||
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'])):
|
||||
raise ShredError(_("Something happened in your event after the export, please try again."))
|
||||
|
||||
for shredder in shredders:
|
||||
shredder.shred_data()
|
||||
|
||||
cf.file.delete(save=False)
|
||||
|
||||
@@ -45,7 +45,8 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
||||
continue
|
||||
if wle.subevent and not wle.subevent.presale_is_running:
|
||||
continue
|
||||
if not wle.item.active or (wle.variation and not wle.variation.active):
|
||||
if not wle.item.is_available():
|
||||
gone.add((wle.item, wle.variation, wle.subevent))
|
||||
continue
|
||||
|
||||
quotas = (wle.variation.quotas.filter(subevent=wle.subevent)
|
||||
|
||||
@@ -21,7 +21,9 @@ from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.serializers.fields import ListMultipleChoiceField
|
||||
from pretix.api.serializers.fields import (
|
||||
ListMultipleChoiceField, UploadedFileField,
|
||||
)
|
||||
from pretix.api.serializers.i18n import I18nField
|
||||
from pretix.base.models.tax import TaxRule
|
||||
from pretix.base.reldate import (
|
||||
@@ -29,7 +31,7 @@ from pretix.base.reldate import (
|
||||
SerializerRelativeDateField, SerializerRelativeDateTimeField,
|
||||
)
|
||||
from pretix.control.forms import (
|
||||
FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
|
||||
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
|
||||
)
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
|
||||
@@ -1080,6 +1082,15 @@ DEFAULTS = {
|
||||
help_text=_('If your event series has more than 50 dates in the future, only the month or week calendar can be used.')
|
||||
),
|
||||
},
|
||||
'allow_modifications_after_checkin': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Allow customers to modify their information after they checked in."),
|
||||
)
|
||||
},
|
||||
'last_order_modification_date': {
|
||||
'default': None,
|
||||
'type': RelativeDateWrapper,
|
||||
@@ -1223,6 +1234,21 @@ DEFAULTS = {
|
||||
"e.g. to explain choosing a lower refund will help your organization.")
|
||||
)
|
||||
},
|
||||
'cancel_allow_user_paid_adjust_fees_step': {
|
||||
'default': None,
|
||||
'type': Decimal,
|
||||
'form_class': forms.DecimalField,
|
||||
'serializer_class': serializers.DecimalField,
|
||||
'serializer_kwargs': dict(
|
||||
max_digits=10, decimal_places=2
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
max_digits=10, decimal_places=2,
|
||||
label=_("Step size for reduction amount"),
|
||||
help_text=_('By default, customers can choose an arbitrary amount for you to keep. If you set this to e.g. '
|
||||
'10, they will only be able to choose values in increments of 10.')
|
||||
)
|
||||
},
|
||||
'cancel_allow_user_paid_require_approval': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
@@ -1784,19 +1810,66 @@ Your {event} team"""))
|
||||
},
|
||||
'logo_image': {
|
||||
'default': None,
|
||||
'type': File
|
||||
'type': File,
|
||||
'form_class': ExtFileField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Header image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('If you provide a logo image, we will by default not show your event name and date '
|
||||
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
||||
'can increase the size with the setting below. We recommend not using small details on the picture '
|
||||
'as it will be resized on smaller screens.')
|
||||
),
|
||||
'serializer_class': UploadedFileField,
|
||||
'serializer_kwargs': dict(
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
|
||||
},
|
||||
'logo_image_large': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Use header image in its full size'),
|
||||
help_text=_('We recommend to upload a picture at least 1170 pixels wide.'),
|
||||
)
|
||||
},
|
||||
'logo_show_title': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Show event title even if a header image is present'),
|
||||
help_text=_('The title will only be shown on the event front page.'),
|
||||
)
|
||||
},
|
||||
'organizer_logo_image': {
|
||||
'default': None,
|
||||
'type': File
|
||||
'type': File,
|
||||
'form_class': ExtFileField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Header image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('If you provide a logo image, we will by default not show your organization name '
|
||||
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
||||
'can increase the size with the setting below. We recommend not using small details on the picture '
|
||||
'as it will be resized on smaller screens.')
|
||||
),
|
||||
'serializer_class': UploadedFileField,
|
||||
'serializer_kwargs': dict(
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
},
|
||||
'organizer_logo_image_large': {
|
||||
'default': 'False',
|
||||
@@ -1810,11 +1883,43 @@ Your {event} team"""))
|
||||
},
|
||||
'og_image': {
|
||||
'default': None,
|
||||
'type': File
|
||||
'type': File,
|
||||
'form_class': ExtFileField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Social media image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. '
|
||||
'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like '
|
||||
'WhatsApp and Reddit only show a square preview, so we recommend to make sure it still looks good '
|
||||
'only the center square is shown. If you do not fill this, we will use the logo given above.')
|
||||
),
|
||||
'serializer_class': UploadedFileField,
|
||||
'serializer_kwargs': dict(
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
},
|
||||
'invoice_logo_image': {
|
||||
'default': None,
|
||||
'type': File
|
||||
'type': File,
|
||||
'form_class': ExtFileField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Logo image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
|
||||
),
|
||||
'serializer_class': UploadedFileField,
|
||||
'serializer_kwargs': dict(
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
},
|
||||
'frontpage_text': {
|
||||
'default': '',
|
||||
@@ -2271,6 +2376,22 @@ PERSON_NAME_SCHEMES = OrderedDict([
|
||||
'_scheme': 'full_transcription',
|
||||
},
|
||||
}),
|
||||
('salutation_given_family', {
|
||||
'fields': (
|
||||
('salutation', pgettext_lazy('person_name', 'Salutation'), 1),
|
||||
('given_name', _('Given name'), 2),
|
||||
('family_name', _('Family name'), 2),
|
||||
),
|
||||
'concatenation': lambda d: ' '.join(
|
||||
str(p) for p in (d.get(key, '') for key in ["given_name", "family_name"]) if p
|
||||
),
|
||||
'sample': {
|
||||
'salutation': pgettext_lazy('person_name_sample', 'Mr'),
|
||||
'given_name': pgettext_lazy('person_name_sample', 'John'),
|
||||
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
|
||||
'_scheme': 'salutation_given_family',
|
||||
},
|
||||
}),
|
||||
('salutation_title_given_family', {
|
||||
'fields': (
|
||||
('salutation', pgettext_lazy('person_name', 'Salutation'), 1),
|
||||
|
||||
@@ -82,6 +82,14 @@ class BaseDataShredder:
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def require_download_confirmation(self):
|
||||
"""
|
||||
Indicates whether the data of this shredder needs to be downloaded, before it is actually shredded. By default
|
||||
this value is equal to the tax relevant flag.
|
||||
"""
|
||||
return self.tax_relevant
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -613,6 +613,7 @@ If the email is associated with a specific user, e.g. a notification email, the
|
||||
well, otherwise it will be ``None``.
|
||||
"""
|
||||
|
||||
|
||||
layout_text_variables = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to collect variables that can be used to display text in ticket-related PDF layouts.
|
||||
@@ -627,11 +628,35 @@ dictionaries as values that contain keys like in the following example::
|
||||
}
|
||||
}
|
||||
|
||||
The evaluate member will be called with the order position, order and event as arguments. The event might
|
||||
The ``evaluate`` member will be called with the order position, order and event as arguments. The event might
|
||||
also be a subevent, if applicable.
|
||||
"""
|
||||
|
||||
|
||||
layout_image_variables = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to collect variables that can be used to display dynamic images in ticket-related PDF layouts.
|
||||
Receivers are expected to return a dictionary with globally unique identifiers as keys and more
|
||||
dictionaries as values that contain keys like in the following example::
|
||||
|
||||
return {
|
||||
"profile": {
|
||||
"label": _("Profile picture"),
|
||||
"evaluate": lambda orderposition, order, event: ContentFile(b"some-image-data"),
|
||||
"etag": lambda orderposition, order, event: hash(b"some-image-data")
|
||||
}
|
||||
}
|
||||
|
||||
The ``evaluate`` member will be called with the order position, order and event as arguments. The event might
|
||||
also be a subevent, if applicable. The return value of ``evaluate`` should be an instance of Django's ``File``
|
||||
class and point to a valid JPEG or PNG file. If no image is available, ``evaluate`` should return ``None``.
|
||||
|
||||
The ``etag`` member will be called with the same arguments as ``evaluate`` but should return a ``str`` value
|
||||
uniquely identifying the version of the file. This can be a hash of the file, but can also be something else.
|
||||
If no image is available, ``etag`` should return ``None``. In some cases, this can speed up the implementation.
|
||||
"""
|
||||
|
||||
|
||||
timeline_events = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to collect events for the time line shown on event dashboards. You are passed
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
|
||||
@@ -100,8 +100,10 @@ def truelink_callback(attrs, new=False):
|
||||
|
||||
<a href="https://maps.google.com/location/foo">https://maps.google.com</a>
|
||||
"""
|
||||
text = re.sub('[^a-zA-Z0-9.-/_]', '', attrs.get('_text')) # clean up link text
|
||||
if URL_RE.match(text):
|
||||
text = re.sub(r'[^a-zA-Z0-9.\-/_]', '', attrs.get('_text')) # clean up link text
|
||||
url = attrs.get((None, 'href'), '/')
|
||||
href_url = urllib.parse.urlparse(url)
|
||||
if URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
|
||||
# link text looks like a url
|
||||
if text.startswith('//'):
|
||||
text = 'https:' + text
|
||||
@@ -109,7 +111,6 @@ def truelink_callback(attrs, new=False):
|
||||
text = 'https://' + text
|
||||
|
||||
text_url = urllib.parse.urlparse(text)
|
||||
href_url = urllib.parse.urlparse(attrs[None, 'href'])
|
||||
if text_url.netloc != href_url.netloc or not href_url.path.startswith(href_url.path):
|
||||
# link text contains an URL that has a different base than the actual URL
|
||||
attrs['_text'] = attrs[None, 'href']
|
||||
|
||||
@@ -28,6 +28,13 @@ class NextTimeField(forms.TimeField):
|
||||
return result
|
||||
|
||||
|
||||
class NextTimeInput(forms.TimeInput):
|
||||
def format_value(self, value):
|
||||
if isinstance(value, datetime):
|
||||
value = value.astimezone(get_current_timezone()).time()
|
||||
return super().format_value(value)
|
||||
|
||||
|
||||
class CheckinListForm(forms.ModelForm):
|
||||
def __init__(self, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
@@ -89,7 +96,7 @@ class CheckinListForm(forms.ModelForm):
|
||||
'class': 'scrolling-multiple-choice'
|
||||
}),
|
||||
'auto_checkin_sales_channels': forms.CheckboxSelectMultiple(),
|
||||
'exit_all_at': forms.TimeInput(attrs={'class': 'timepickerfield'}),
|
||||
'exit_all_at': NextTimeInput(attrs={'class': 'timepickerfield'}),
|
||||
}
|
||||
field_classes = {
|
||||
'limit_products': SafeModelMultipleChoiceField,
|
||||
|
||||
@@ -27,7 +27,7 @@ from pretix.base.settings import (
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
||||
)
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
||||
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
||||
SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.control.forms.widgets import Select2
|
||||
@@ -35,7 +35,6 @@ from pretix.helpers.countries import CachedCountries
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.plugins.banktransfer.payment import BankTransfer
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
|
||||
class EventWizardFoundationForm(forms.Form):
|
||||
@@ -266,15 +265,32 @@ class EventMetaValueForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.property = kwargs.pop('property')
|
||||
self.disabled = kwargs.pop('disabled')
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.property.allowed_values:
|
||||
self.fields['value'] = forms.ChoiceField(
|
||||
label=self.property.name,
|
||||
choices=[
|
||||
('', _('Default ({value})').format(value=self.property.default) if self.property.default else ''),
|
||||
] + [(a.strip(), a.strip()) for a in self.property.allowed_values.splitlines()],
|
||||
)
|
||||
else:
|
||||
self.fields['value'].label = self.property.name
|
||||
self.fields['value'].widget.attrs['placeholder'] = self.property.default
|
||||
self.fields['value'].widget.attrs['data-typeahead-url'] = (
|
||||
reverse('control:events.meta.typeahead') + '?' + urlencode({
|
||||
'property': self.property.name,
|
||||
'organizer': self.property.organizer.slug,
|
||||
})
|
||||
)
|
||||
self.fields['value'].required = False
|
||||
self.fields['value'].widget.attrs['placeholder'] = self.property.default
|
||||
self.fields['value'].widget.attrs['data-typeahead-url'] = (
|
||||
reverse('control:events.meta.typeahead') + '?' + urlencode({
|
||||
'property': self.property.name,
|
||||
'organizer': self.property.organizer.slug,
|
||||
})
|
||||
)
|
||||
if self.disabled:
|
||||
self.fields['value'].widget.attrs['readonly'] = 'readonly'
|
||||
|
||||
def clean_slug(self):
|
||||
if self.disabled:
|
||||
return self.instance.value if self.instance else None
|
||||
return self.cleaned_data['slug']
|
||||
|
||||
class Meta:
|
||||
model = EventMetaValue
|
||||
@@ -392,7 +408,7 @@ class EventUpdateForm(I18nModelForm):
|
||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
|
||||
'presale_start': SplitDateTimePickerWidget(),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
|
||||
'sales_channels': CheckboxSelectMultiple()
|
||||
'sales_channels': CheckboxSelectMultiple(),
|
||||
}
|
||||
|
||||
|
||||
@@ -413,36 +429,6 @@ class EventSettingsForm(SettingsForm):
|
||||
"restrict the set of selectable titles."),
|
||||
required=False,
|
||||
)
|
||||
logo_image = ExtFileField(
|
||||
label=_('Header image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('If you provide a logo image, we will by default not show your event name and date '
|
||||
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
||||
'can increase the size with the setting below. We recommend not using small details on the picture '
|
||||
'as it will be resized on smaller screens.')
|
||||
)
|
||||
logo_image_large = forms.BooleanField(
|
||||
label=_('Use header image in its full size'),
|
||||
help_text=_('We recommend to upload a picture at least 1170 pixels wide.'),
|
||||
required=False,
|
||||
)
|
||||
logo_show_title = forms.BooleanField(
|
||||
label=_('Show event title even if a header image is present'),
|
||||
help_text=_('The title will only be shown on the event front page.'),
|
||||
required=False,
|
||||
)
|
||||
og_image = ExtFileField(
|
||||
label=_('Social media image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. '
|
||||
'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like '
|
||||
'WhatsApp and Reddit only show a square preview, so we recommend to make sure it still looks good '
|
||||
'only the center square is shown. If you do not fill this, we will use the logo given above.')
|
||||
)
|
||||
|
||||
auto_fields = [
|
||||
'imprint_url',
|
||||
@@ -488,6 +474,7 @@ class EventSettingsForm(SettingsForm):
|
||||
'banner_text_bottom',
|
||||
'order_email_asked_twice',
|
||||
'last_order_modification_date',
|
||||
'allow_modifications_after_checkin',
|
||||
'checkout_show_copy_answers_button',
|
||||
'primary_color',
|
||||
'theme_color_success',
|
||||
@@ -495,6 +482,10 @@ class EventSettingsForm(SettingsForm):
|
||||
'theme_color_background',
|
||||
'theme_round_borders',
|
||||
'primary_font',
|
||||
'logo_image',
|
||||
'logo_image_large',
|
||||
'logo_show_title',
|
||||
'og_image',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
@@ -549,9 +540,6 @@ class EventSettingsForm(SettingsForm):
|
||||
if not self.event.has_subevents:
|
||||
del self.fields['frontpage_subevent_ordering']
|
||||
del self.fields['event_list_type']
|
||||
self.fields['primary_font'].choices += [
|
||||
(a, {"title": a, "data": v}) for a, v in get_fonts().items()
|
||||
]
|
||||
|
||||
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
|
||||
self.virtual_keys = []
|
||||
@@ -598,6 +586,7 @@ class CancelSettingsForm(SettingsForm):
|
||||
'cancel_allow_user_paid_keep_percentage',
|
||||
'cancel_allow_user_paid_adjust_fees',
|
||||
'cancel_allow_user_paid_adjust_fees_explanation',
|
||||
'cancel_allow_user_paid_adjust_fees_step',
|
||||
'cancel_allow_user_paid_refund_as_giftcard',
|
||||
'cancel_allow_user_paid_require_approval',
|
||||
'change_allow_user_variation',
|
||||
@@ -733,6 +722,7 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
'invoice_additional_text',
|
||||
'invoice_footer_text',
|
||||
'invoice_eu_currencies',
|
||||
'invoice_logo_image',
|
||||
]
|
||||
|
||||
invoice_generate_sales_channels = forms.MultipleChoiceField(
|
||||
@@ -752,13 +742,6 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
label=_("Invoice language"),
|
||||
choices=[('__user__', _('The user\'s language'))] + settings.LANGUAGES,
|
||||
)
|
||||
invoice_logo_image = ExtFileField(
|
||||
label=_('Logo image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs.get('obj')
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from datetime import datetime, time
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db.models import Exists, F, Model, OuterRef, Q, QuerySet
|
||||
from django.db.models import Exists, F, Max, Model, OuterRef, Q, QuerySet
|
||||
from django.db.models.functions import Coalesce, ExtractWeekDay
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.formats import date_format, localize
|
||||
@@ -239,6 +239,10 @@ class OrderFilterForm(FilterForm):
|
||||
elif s == 'rc':
|
||||
qs = qs.filter(
|
||||
cancellation_requests__isnull=False
|
||||
).annotate(
|
||||
cancellation_request_time=Max('cancellation_requests__created')
|
||||
).order_by(
|
||||
'-cancellation_request_time'
|
||||
)
|
||||
elif s == 'pendingpaid':
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
@@ -458,6 +462,16 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
||||
required=False,
|
||||
label=_('Total amount'),
|
||||
)
|
||||
payment_sum_min = forms.DecimalField(
|
||||
localize=True,
|
||||
required=False,
|
||||
label=_('Minimal sum of payments and refunds'),
|
||||
)
|
||||
payment_sum_max = forms.DecimalField(
|
||||
localize=True,
|
||||
required=False,
|
||||
label=_('Maximal sum of payments and refunds'),
|
||||
)
|
||||
sales_channel = forms.ChoiceField(
|
||||
label=_('Sales channel'),
|
||||
required=False,
|
||||
@@ -584,6 +598,16 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
||||
qs = qs.filter(email_known_to_work=fdata.get('email_known_to_work'))
|
||||
if fdata.get('locale'):
|
||||
qs = qs.filter(locale=fdata.get('locale'))
|
||||
if fdata.get('payment_sum_min') is not None:
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
computed_payment_refund_sum__gte=fdata['payment_sum_min'],
|
||||
)
|
||||
if fdata.get('payment_sum_max') is not None:
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
computed_payment_refund_sum__lte=fdata['payment_sum_max'],
|
||||
)
|
||||
if fdata.get('invoice_address_company'):
|
||||
qs = qs.filter(invoice_address__company__icontains=fdata.get('invoice_address_company'))
|
||||
if fdata.get('invoice_address_name'):
|
||||
@@ -742,10 +766,15 @@ class SubEventFilterForm(FilterForm):
|
||||
),
|
||||
required=False
|
||||
)
|
||||
date = forms.DateField(
|
||||
label=_('Date'),
|
||||
date_from = forms.DateField(
|
||||
label=_('Date from'),
|
||||
required=False,
|
||||
widget=DatePickerWidget
|
||||
widget=DatePickerWidget,
|
||||
)
|
||||
date_until = forms.DateField(
|
||||
label=_('Date until'),
|
||||
required=False,
|
||||
widget=DatePickerWidget,
|
||||
)
|
||||
weekday = forms.ChoiceField(
|
||||
label=_('Weekday'),
|
||||
@@ -772,7 +801,8 @@ class SubEventFilterForm(FilterForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['date'].widget = DatePickerWidget()
|
||||
self.fields['date_from'].widget = DatePickerWidget()
|
||||
self.fields['date_until'].widget = DatePickerWidget()
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
@@ -814,19 +844,21 @@ class SubEventFilterForm(FilterForm):
|
||||
Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query)
|
||||
)
|
||||
|
||||
if fdata.get('date'):
|
||||
date_start = make_aware(datetime.combine(
|
||||
fdata.get('date'),
|
||||
if fdata.get('date_until'):
|
||||
date_end = make_aware(datetime.combine(
|
||||
fdata.get('date_until') + timedelta(days=1),
|
||||
time(hour=0, minute=0, second=0, microsecond=0)
|
||||
), get_current_timezone())
|
||||
date_end = make_aware(datetime.combine(
|
||||
fdata.get('date'),
|
||||
time(hour=23, minute=59, second=59, microsecond=999999)
|
||||
), get_current_timezone())
|
||||
qs = qs.filter(
|
||||
Q(date_to__isnull=True, date_from__gte=date_start, date_from__lte=date_end) |
|
||||
Q(date_to__isnull=False, date_from__lte=date_end, date_to__gte=date_start)
|
||||
Q(date_to__isnull=True, date_from__lt=date_end) |
|
||||
Q(date_to__isnull=False, date_to__lt=date_end)
|
||||
)
|
||||
if fdata.get('date_from'):
|
||||
date_start = make_aware(datetime.combine(
|
||||
fdata.get('date_from'),
|
||||
time(hour=0, minute=0, second=0, microsecond=0)
|
||||
), get_current_timezone())
|
||||
qs = qs.filter(date_from__gte=date_start)
|
||||
|
||||
if fdata.get('ordering'):
|
||||
qs = qs.order_by(self.get_order_by())
|
||||
@@ -1486,6 +1518,9 @@ class VoucherTagFilterForm(FilterForm):
|
||||
|
||||
|
||||
class RefundFilterForm(FilterForm):
|
||||
orders = {'provider': 'provider', 'state': 'state', 'order': 'order__code',
|
||||
'source': 'source', 'amount': 'amount', 'created': 'created'}
|
||||
|
||||
provider = forms.ChoiceField(
|
||||
label=_('Payment provider'),
|
||||
choices=[
|
||||
@@ -1522,6 +1557,10 @@ class RefundFilterForm(FilterForm):
|
||||
qs = qs.filter(state__in=[OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT,
|
||||
OrderRefund.REFUND_STATE_EXTERNAL])
|
||||
|
||||
if fdata.get('ordering'):
|
||||
qs = qs.order_by(self.get_order_by())
|
||||
else:
|
||||
qs = qs.order_by('-created')
|
||||
return qs
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class ConfirmPaymentForm(forms.Form):
|
||||
class CancelForm(ConfirmPaymentForm):
|
||||
send_email = forms.BooleanField(
|
||||
required=False,
|
||||
label=_('Notify user by e-mail'),
|
||||
label=_('Notify customer by email'),
|
||||
initial=True
|
||||
)
|
||||
cancellation_fee = forms.DecimalField(
|
||||
@@ -139,6 +139,11 @@ class CancelForm(ConfirmPaymentForm):
|
||||
|
||||
|
||||
class MarkPaidForm(ConfirmPaymentForm):
|
||||
send_email = forms.BooleanField(
|
||||
required=False,
|
||||
label=_('Notify customer by email'),
|
||||
initial=True
|
||||
)
|
||||
amount = forms.DecimalField(
|
||||
required=True,
|
||||
max_digits=10, decimal_places=2,
|
||||
@@ -616,7 +621,7 @@ class EventCancelForm(forms.Form):
|
||||
required=False
|
||||
)
|
||||
manual_refund = forms.BooleanField(
|
||||
label=_('Create manual refund if the payment method odes not support automatic refunds'),
|
||||
label=_('Create manual refund if the payment method does not support automatic refunds'),
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_auto_refund'}),
|
||||
initial=True,
|
||||
required=False,
|
||||
|
||||
@@ -13,7 +13,9 @@ from pretix.api.models import WebHook
|
||||
from pretix.api.webhooks import get_all_webhook_events
|
||||
from pretix.base.forms import I18nModelForm, SettingsForm
|
||||
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
||||
from pretix.base.models import Device, Gate, GiftCard, Organizer, Team
|
||||
from pretix.base.models import (
|
||||
Device, EventMetaProperty, Gate, GiftCard, Organizer, Team,
|
||||
)
|
||||
from pretix.control.forms import ExtFileField, SplitDateTimeField
|
||||
from pretix.control.forms.event import SafeEventMultipleChoiceField
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
@@ -125,7 +127,8 @@ class OrganizerUpdateForm(OrganizerForm):
|
||||
|
||||
class EventMetaPropertyForm(forms.ModelForm):
|
||||
class Meta:
|
||||
fields = ['name', 'default']
|
||||
model = EventMetaProperty
|
||||
fields = ['name', 'default', 'required', 'protected', 'allowed_values']
|
||||
widgets = {
|
||||
'default': forms.TextInput()
|
||||
}
|
||||
@@ -214,6 +217,8 @@ class DeviceForm(forms.ModelForm):
|
||||
|
||||
class OrganizerSettingsForm(SettingsForm):
|
||||
auto_fields = [
|
||||
'contact_mail',
|
||||
'imprint_url',
|
||||
'organizer_info_text',
|
||||
'event_list_type',
|
||||
'event_list_availability',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from bootstrap3.renderers import FieldRenderer
|
||||
from bootstrap3.renderers import FieldRenderer, InlineFieldRenderer
|
||||
from bootstrap3.text import text_value
|
||||
from django.forms import CheckboxInput
|
||||
from django.forms.utils import flatatt
|
||||
@@ -58,3 +58,40 @@ class ControlFieldRenderer(FieldRenderer):
|
||||
optional=not required and not isinstance(self.widget, CheckboxInput)
|
||||
) + html
|
||||
return html
|
||||
|
||||
|
||||
class BulkEditMixin:
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['layout'] = self.layout
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def wrap_field(self, html):
|
||||
field_class = self.get_field_class()
|
||||
name = '{}{}'.format(self.field.form.prefix, self.field.name)
|
||||
checked = self.field.form.data and name in self.field.form.data.getlist('_bulk')
|
||||
html = (
|
||||
'<div class="{klass} bulk-edit-field-group">'
|
||||
'<label class="field-toggle">'
|
||||
'<input type="checkbox" name="_bulk" value="{name}" {checked}> {label}'
|
||||
'</label>'
|
||||
'<div class="field-content">'
|
||||
'{html}'
|
||||
'</div>'
|
||||
'</div>'
|
||||
).format(
|
||||
klass=field_class or '',
|
||||
name=name,
|
||||
label=pgettext('form_bulk', 'change'),
|
||||
checked='checked' if checked else '',
|
||||
html=html
|
||||
)
|
||||
return html
|
||||
|
||||
|
||||
class BulkEditFieldRenderer(BulkEditMixin, FieldRenderer):
|
||||
layout = 'horizontal'
|
||||
|
||||
|
||||
class InlineBulkEditFieldRenderer(BulkEditMixin, InlineFieldRenderer):
|
||||
layout = 'inline'
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django import forms
|
||||
from django.forms import formset_factory
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.urls import reverse
|
||||
from django.utils.dates import MONTHS, WEEKDAYS
|
||||
from django.utils.functional import cached_property
|
||||
@@ -11,6 +12,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from i18nfield.forms import I18nInlineFormSet
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
from pretix.base.forms.widgets import DatePickerWidget, TimePickerWidget
|
||||
from pretix.base.models.event import SubEvent, SubEventMetaValue
|
||||
from pretix.base.models.items import SubEventItem
|
||||
from pretix.base.reldate import RelativeDateTimeField
|
||||
@@ -22,6 +24,12 @@ from pretix.helpers.money import change_decimal_field
|
||||
class SubEventForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs['event']
|
||||
instance = kwargs.get('instance')
|
||||
if instance and not instance.pk:
|
||||
kwargs['initial'].setdefault('name', self.event.name)
|
||||
kwargs['initial'].setdefault('location', self.event.location)
|
||||
kwargs['initial'].setdefault('geo_lat', self.event.geo_lat)
|
||||
kwargs['initial'].setdefault('geo_lon', self.event.geo_lon)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['location'].widget.attrs['rows'] = '3'
|
||||
|
||||
@@ -82,6 +90,142 @@ class SubEventBulkForm(SubEventForm):
|
||||
del self.fields['date_admission']
|
||||
|
||||
|
||||
class NullBooleanSelect(forms.NullBooleanSelect):
|
||||
def __init__(self, attrs=None):
|
||||
choices = (
|
||||
('unknown', _('Keep the current values')),
|
||||
('true', _('Yes')),
|
||||
('false', _('No')),
|
||||
)
|
||||
super(forms.NullBooleanSelect, self).__init__(attrs, choices)
|
||||
|
||||
|
||||
class SubEventBulkEditForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.mixed_values = kwargs.pop('mixed_values')
|
||||
self.queryset = kwargs.pop('queryset')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['location'].widget.attrs['rows'] = '3'
|
||||
|
||||
for k in ('name', 'location', 'frontpage_text'):
|
||||
# i18n fields
|
||||
if k in self.mixed_values:
|
||||
self.fields[k].widget.attrs['placeholder'] = '[{}]'.format(_('Selection contains various values'))
|
||||
else:
|
||||
self.fields[k].widget.attrs['placeholder'] = ''
|
||||
self.fields[k].one_required = False
|
||||
|
||||
for k in ('geo_lat', 'geo_lon'):
|
||||
# scalar fields
|
||||
if k in self.mixed_values:
|
||||
self.fields[k].widget.attrs['placeholder'] = '[{}]'.format(_('Selection contains various values'))
|
||||
else:
|
||||
self.fields[k].widget.attrs['placeholder'] = ''
|
||||
self.fields[k].widget.is_required = False
|
||||
self.fields[k].required = False
|
||||
|
||||
for k in ('date_from', 'date_to', 'date_admission', 'presale_start', 'presale_end'):
|
||||
self.fields[k + '_day'] = forms.DateField(
|
||||
label=self._meta.model._meta.get_field(k).verbose_name,
|
||||
help_text=self._meta.model._meta.get_field(k).help_text,
|
||||
widget=DatePickerWidget(),
|
||||
required=False,
|
||||
)
|
||||
self.fields[k + '_time'] = forms.TimeField(
|
||||
label=self._meta.model._meta.get_field(k).verbose_name,
|
||||
help_text=self._meta.model._meta.get_field(k).help_text,
|
||||
widget=TimePickerWidget(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'name',
|
||||
'location',
|
||||
'frontpage_text',
|
||||
'geo_lat',
|
||||
'geo_lon',
|
||||
'is_public',
|
||||
'active',
|
||||
]
|
||||
field_classes = {
|
||||
}
|
||||
widgets = {
|
||||
}
|
||||
|
||||
def save(self, commit=True):
|
||||
objs = list(self.queryset)
|
||||
fields = set()
|
||||
|
||||
check_map = {
|
||||
'geo_lat': '__geo',
|
||||
'geo_lon': '__geo',
|
||||
}
|
||||
for k in self.fields:
|
||||
cb_val = self.prefix + check_map.get(k, k)
|
||||
if cb_val not in self.data.getlist('_bulk'):
|
||||
continue
|
||||
|
||||
if k.endswith('_day'):
|
||||
for obj in objs:
|
||||
oldval = getattr(obj, k.replace('_day', ''))
|
||||
cval = self.cleaned_data[k]
|
||||
if cval is None:
|
||||
newval = None
|
||||
if not self._meta.model._meta.get_field(k.replace('_day', '')).null:
|
||||
continue
|
||||
elif oldval:
|
||||
oldval = oldval.astimezone(self.event.timezone)
|
||||
newval = oldval.replace(
|
||||
year=cval.year,
|
||||
month=cval.month,
|
||||
day=cval.day,
|
||||
)
|
||||
else:
|
||||
# If there is no previous date/time set, we'll just set to midnight
|
||||
# If the user also selected a time, this will be overridden anyways
|
||||
newval = datetime(
|
||||
year=cval.year,
|
||||
month=cval.month,
|
||||
day=cval.day,
|
||||
tzinfo=self.event.timezone
|
||||
)
|
||||
setattr(obj, k.replace('_day', ''), newval)
|
||||
fields.add(k.replace('_day', ''))
|
||||
elif k.endswith('_time'):
|
||||
for obj in objs:
|
||||
# If there is no previous date/time set and only a time is changed not the
|
||||
# date, we instead use the date of the event
|
||||
oldval = getattr(obj, k.replace('_time', '')) or obj.date_from
|
||||
cval = self.cleaned_data[k]
|
||||
if cval is None:
|
||||
continue
|
||||
oldval = oldval.astimezone(self.event.timezone)
|
||||
newval = oldval.replace(
|
||||
hour=cval.hour,
|
||||
minute=cval.minute,
|
||||
second=cval.second,
|
||||
)
|
||||
setattr(obj, k.replace('_time', ''), newval)
|
||||
fields.add(k.replace('_time', ''))
|
||||
else:
|
||||
fields.add(k)
|
||||
for obj in objs:
|
||||
setattr(obj, k, self.cleaned_data[k])
|
||||
|
||||
if fields:
|
||||
SubEvent.objects.bulk_update(objs, fields, 200)
|
||||
|
||||
def full_clean(self):
|
||||
if len(self.data) == 0:
|
||||
# form wasn't submitted
|
||||
self._errors = ErrorDict()
|
||||
return
|
||||
super().full_clean()
|
||||
|
||||
|
||||
class SubEventItemOrVariationFormMixin:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.item = kwargs.pop('item')
|
||||
@@ -156,15 +300,32 @@ class SubEventMetaValueForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.property = kwargs.pop('property')
|
||||
self.default = kwargs.pop('default', None)
|
||||
self.disabled = kwargs.pop('disabled', False)
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.property.allowed_values:
|
||||
self.fields['value'] = forms.ChoiceField(
|
||||
label=self.property.name,
|
||||
choices=[
|
||||
('', _('Default ({value})').format(value=self.default or self.property.default) if self.default or self.property.default else ''),
|
||||
] + [(a.strip(), a.strip()) for a in self.property.allowed_values.splitlines()],
|
||||
)
|
||||
else:
|
||||
self.fields['value'].label = self.property.name
|
||||
self.fields['value'].widget.attrs['placeholder'] = self.default or self.property.default
|
||||
self.fields['value'].widget.attrs['data-typeahead-url'] = (
|
||||
reverse('control:events.meta.typeahead') + '?' + urlencode({
|
||||
'property': self.property.name,
|
||||
'organizer': self.property.organizer.slug,
|
||||
})
|
||||
)
|
||||
self.fields['value'].required = False
|
||||
self.fields['value'].widget.attrs['placeholder'] = self.default or self.property.default
|
||||
self.fields['value'].widget.attrs['data-typeahead-url'] = (
|
||||
reverse('control:events.meta.typeahead') + '?' + urlencode({
|
||||
'property': self.property.name,
|
||||
'organizer': self.property.organizer.slug,
|
||||
})
|
||||
)
|
||||
if self.disabled:
|
||||
self.fields['value'].widget.attrs['readonly'] = 'readonly'
|
||||
|
||||
def clean_slug(self):
|
||||
if self.disabled:
|
||||
return self.instance.value if self.instance else None
|
||||
return self.cleaned_data['slug']
|
||||
|
||||
class Meta:
|
||||
model = SubEventMetaValue
|
||||
|
||||
@@ -393,7 +393,7 @@ class VoucherBulkForm(VoucherForm):
|
||||
data['bulk'] = True
|
||||
del data['codes']
|
||||
objs.append(obj)
|
||||
Voucher.objects.bulk_create(objs)
|
||||
Voucher.objects.bulk_create(objs, batch_size=200)
|
||||
objs = []
|
||||
for v in event.vouchers.filter(code__in=self.cleaned_data['codes']):
|
||||
# We need to query them again as bulk_create does not fill in .pk values on databases
|
||||
|
||||
@@ -273,8 +273,15 @@ def _display_checkin(event, logentry):
|
||||
def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
plains = {
|
||||
'pretix.object.cloned': _('This object has been created by cloning.'),
|
||||
'pretix.organizer.changed': _('The organizer has been changed.'),
|
||||
'pretix.organizer.settings': _('The organizer settings have been changed.'),
|
||||
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
|
||||
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
|
||||
'pretix.webhook.created': _('The webhook has been created.'),
|
||||
'pretix.webhook.changed': _('The webhook has been changed.'),
|
||||
'pretix.event.comment': _('The event\'s internal comment has been updated.'),
|
||||
'pretix.event.canceled': _('The event has been canceled.'),
|
||||
'pretix.event.deleted': _('An event has been deleted.'),
|
||||
'pretix.event.order.modified': _('The order details have been changed.'),
|
||||
'pretix.event.order.unpaid': _('The order has been marked as unpaid.'),
|
||||
'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'),
|
||||
@@ -357,6 +364,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'account.'),
|
||||
'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'),
|
||||
'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
|
||||
'pretix.control.auth.user.forgot_password.denied.repeated': _('A repeated password reset has been denied, as '
|
||||
'the last request was less than 24 hours ago.'),
|
||||
'pretix.organizer.deleted': _('The organizer "{name}" has been deleted.'),
|
||||
'pretix.voucher.added': _('The voucher has been created.'),
|
||||
'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'),
|
||||
|
||||
@@ -427,6 +427,13 @@ def get_organizer_navigation(request):
|
||||
}),
|
||||
'active': url.url_name == 'organizer.edit',
|
||||
},
|
||||
{
|
||||
'label': _('Event metadata'),
|
||||
'url': reverse('control:organizer.properties', kwargs={
|
||||
'organizer': request.organizer.slug
|
||||
}),
|
||||
'active': url.url_name.startswith('organizer.propert'),
|
||||
},
|
||||
]
|
||||
})
|
||||
if 'can_change_teams' in request.orgapermset:
|
||||
|
||||
@@ -6,6 +6,13 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
def current_url(request):
|
||||
if len(request.GET):
|
||||
return request.path + '?' + request.GET.urlencode()
|
||||
else:
|
||||
return request.path
|
||||
|
||||
|
||||
def event_permission_required(permission):
|
||||
"""
|
||||
This view decorator rejects all requests with a 403 response which are not from
|
||||
@@ -94,7 +101,7 @@ def administrator_permission_required():
|
||||
raise PermissionDenied()
|
||||
if not request.user.has_active_staff_session(request.session.session_key):
|
||||
if request.user.is_staff:
|
||||
return redirect(reverse('control:user.sudo') + '?next=' + quote(request.path))
|
||||
return redirect(reverse('control:user.sudo') + '?next=' + quote(current_url(request)))
|
||||
raise PermissionDenied(_('You do not have permission to view this content.'))
|
||||
return function(request, *args, **kw)
|
||||
return wrapper
|
||||
|
||||
@@ -291,7 +291,7 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
|
||||
"""
|
||||
|
||||
subevent_forms = EventPluginSignal(
|
||||
providing_args=['request', 'subevent']
|
||||
providing_args=['request', 'subevent', 'copy_from']
|
||||
)
|
||||
"""
|
||||
This signal allows you to return additional forms that should be rendered on the subevent creation
|
||||
@@ -301,7 +301,8 @@ as part of the standard validation and rendering cycle and rendered using defaul
|
||||
styles. It is advisable to set a prefix for your form to avoid clashes with other plugins.
|
||||
|
||||
``subevent`` can be ``None`` during creation. Before ``save()`` is called, a ``subevent`` property of
|
||||
your form instance will automatically being set to the subevent that has just been created.
|
||||
your form instance will automatically being set to the subevent that has just been created. During
|
||||
creation, ``copy_from`` can be a subevent that is being copied from.
|
||||
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user