forked from CGM_Public/pretix_original
Compare commits
341 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d1fc31cd | ||
|
|
597211d83a | ||
|
|
17679d4304 | ||
|
|
0fb70c78a9 | ||
|
|
e254e90e49 | ||
|
|
9c6e5f025d | ||
|
|
3c86532218 | ||
|
|
3834ae566f | ||
|
|
6766b2b19e | ||
|
|
b6d2f67c7c | ||
|
|
e70f593a94 | ||
|
|
ed5726fc0c | ||
|
|
5400d26c60 | ||
|
|
0bb6104532 | ||
|
|
16aa403735 | ||
|
|
1c279a92a7 | ||
|
|
35985dcb11 | ||
|
|
b0dcbe31fa | ||
|
|
b3c3ee3b22 | ||
|
|
aab340fd87 | ||
|
|
1871324ef4 | ||
|
|
d799d560b7 | ||
|
|
01e2851a76 | ||
|
|
ef2a4244ed | ||
|
|
55539dc8e5 | ||
|
|
ef303bfcc4 | ||
|
|
fff9ac04a9 | ||
|
|
76d27fbfaa | ||
|
|
2b1123b487 | ||
|
|
3607d8706d | ||
|
|
31fdf8721b | ||
|
|
128a1f349a | ||
|
|
7d432f0639 | ||
|
|
1ffc799c4d | ||
|
|
25dd8f2e2f | ||
|
|
b121596e4b | ||
|
|
cf835df62e | ||
|
|
7a3b7d4f02 | ||
|
|
b151d8f455 | ||
|
|
06de74d877 | ||
|
|
2ae9e3e0d9 | ||
|
|
0c0fe58bbf | ||
|
|
7b1e1a48ef | ||
|
|
c7dd50de0d | ||
|
|
a1caa65776 | ||
|
|
260973345d | ||
|
|
2c9b2620ea | ||
|
|
909c80e710 | ||
|
|
5a218ae6a9 | ||
|
|
b498d45621 | ||
|
|
b02196434b | ||
|
|
c0edce7760 | ||
|
|
cc46d55f5e | ||
|
|
ea8abb8dab | ||
|
|
f765d094b4 | ||
|
|
86f222870d | ||
|
|
19b5270d76 | ||
|
|
db76b9b0ef | ||
|
|
d23e53873f | ||
|
|
c116a4b998 | ||
|
|
2471d4bca5 | ||
|
|
8e04dbdcca | ||
|
|
0928358396 | ||
|
|
23f783c15c | ||
|
|
edae96c84f | ||
|
|
242ebdfae9 | ||
|
|
0ee502abec | ||
|
|
29cb1e93d8 | ||
|
|
c89242855c | ||
|
|
61a1368ed2 | ||
|
|
ac3e00fa03 | ||
|
|
d9d0f7b6f3 | ||
|
|
ad5e2df3be | ||
|
|
ec34561815 | ||
|
|
e1540b1648 | ||
|
|
a6b265455d | ||
|
|
8a6334bd86 | ||
|
|
173a23722a | ||
|
|
ab8eb2a34d | ||
|
|
30dcda616b | ||
|
|
3eafec9d6e | ||
|
|
a5910016fd | ||
|
|
0a49b93b26 | ||
|
|
7449bea836 | ||
|
|
0fc4478332 | ||
|
|
0df4a6e7ed | ||
|
|
a37cd380c8 | ||
|
|
11b2bd8887 | ||
|
|
8986db0975 | ||
|
|
2921611cb1 | ||
|
|
785fb29513 | ||
|
|
81c3d7fa17 | ||
|
|
8ff963698d | ||
|
|
6da63e0169 | ||
|
|
f84903ae27 | ||
|
|
a0a7859b33 | ||
|
|
af23d6e4bf | ||
|
|
7e9c9beace | ||
|
|
ac2fc2de5c | ||
|
|
45e548873e | ||
|
|
f484eb65df | ||
|
|
027a785ab5 | ||
|
|
25b80cbb57 | ||
|
|
589fa0f9de | ||
|
|
6d2989d15a | ||
|
|
5bb27b29ae | ||
|
|
d17f8a71e6 | ||
|
|
b664cc712a | ||
|
|
d61e8a9204 | ||
|
|
f00012a63e | ||
|
|
bd238f76ce | ||
|
|
703ae97820 | ||
|
|
1a60c5ea64 | ||
|
|
1d3ac5f02f | ||
|
|
8d23d75dfd | ||
|
|
9a32668ee1 | ||
|
|
ca0407a133 | ||
|
|
1de77b0784 | ||
|
|
d0907d3dcf | ||
|
|
81cc4bd768 | ||
|
|
262639e063 | ||
|
|
dedd93fb89 | ||
|
|
45f94aee03 | ||
|
|
d36e7d033f | ||
|
|
b94bd277bf | ||
|
|
e5095185d9 | ||
|
|
d76ce47597 | ||
|
|
58717850c2 | ||
|
|
29d52d4fe5 | ||
|
|
34c9c40ddc | ||
|
|
39d05a6c40 | ||
|
|
b664222c62 | ||
|
|
1ee48a10b5 | ||
|
|
2431a8b767 | ||
|
|
af84354e51 | ||
|
|
b04de880fc | ||
|
|
11f3057f76 | ||
|
|
ba164c16f6 | ||
|
|
7ef766ddfa | ||
|
|
bcafcc7dd8 | ||
|
|
e5b7102abc | ||
|
|
3601dd6bee | ||
|
|
a1d5854fbf | ||
|
|
09544a688d | ||
|
|
58a5892cc0 | ||
|
|
c9af76b46e | ||
|
|
91753935cf | ||
|
|
23a52eb12a | ||
|
|
79ecb231f2 | ||
|
|
08de7f59a3 | ||
|
|
0de3c33bab | ||
|
|
a4ae8b0e66 | ||
|
|
be1bf81298 | ||
|
|
b7528ae1cf | ||
|
|
4f6712ccbe | ||
|
|
939335f94b | ||
|
|
c849276a35 | ||
|
|
8e9f0f07a1 | ||
|
|
389884d191 | ||
|
|
d8c2c82da7 | ||
|
|
c3ed3d4899 | ||
|
|
e9235cd433 | ||
|
|
975b6d800a | ||
|
|
ee260c8231 | ||
|
|
f7fddc05dd | ||
|
|
eaa61c7795 | ||
|
|
d4994258e6 | ||
|
|
9b50ec2d74 | ||
|
|
447b6b7fee | ||
|
|
40f763c999 | ||
|
|
6a3d05be9e | ||
|
|
766447f021 | ||
|
|
5fbeb90f00 | ||
|
|
c01cc85eda | ||
|
|
4a054da6ee | ||
|
|
583a2b6572 | ||
|
|
fbe025afb2 | ||
|
|
66e6191122 | ||
|
|
0f26f0787c | ||
|
|
62a86c9b4a | ||
|
|
07318be4c9 | ||
|
|
3c8ef2c620 | ||
|
|
a858f47220 | ||
|
|
381fa5e1cd | ||
|
|
1539eea664 | ||
|
|
3d41d1331a | ||
|
|
00848b3339 | ||
|
|
d174b11c6a | ||
|
|
a501ce496a | ||
|
|
de277cc959 | ||
|
|
12b3ae81d6 | ||
|
|
fcdb40dda0 | ||
|
|
f65cf8e86a | ||
|
|
12540238b7 | ||
|
|
398a30e33a | ||
|
|
3410640618 | ||
|
|
7b45cfccc2 | ||
|
|
33f503aea1 | ||
|
|
3fd650081b | ||
|
|
b622854be6 | ||
|
|
6d00daa9ee | ||
|
|
f27148998a | ||
|
|
4a0369cc37 | ||
|
|
76aaf61e19 | ||
|
|
dd1e5fa929 | ||
|
|
4a2516e303 | ||
|
|
cf06712eca | ||
|
|
6185d675f0 | ||
|
|
a53cd3abce | ||
|
|
ebe86a17fb | ||
|
|
d189b16ee7 | ||
|
|
d70ce4491a | ||
|
|
607ff48d70 | ||
|
|
4bab44ca85 | ||
|
|
a5cdb485d0 | ||
|
|
282ef792c4 | ||
|
|
6cd888a1dc | ||
|
|
2e5b80c83c | ||
|
|
4511110069 | ||
|
|
1af1d8c658 | ||
|
|
9f6a3f9a6a | ||
|
|
1c03d5d305 | ||
|
|
69a1fccd20 | ||
|
|
2a54aa2d83 | ||
|
|
2269c8dee0 | ||
|
|
46295ea887 | ||
|
|
e41863229b | ||
|
|
ca5a6ddba1 | ||
|
|
4d4dafb5dd | ||
|
|
9c2af952b7 | ||
|
|
dc6e425c2a | ||
|
|
5f65b9528f | ||
|
|
8957c2f106 | ||
|
|
2bbbc88a9c | ||
|
|
162b7c1b52 | ||
|
|
755f3d53b6 | ||
|
|
f6db62d6ce | ||
|
|
aa1ffc402c | ||
|
|
2c9b96f0c5 | ||
|
|
16599e242d | ||
|
|
19c13d7f38 | ||
|
|
65db8cd583 | ||
|
|
d0794d7b94 | ||
|
|
a770f5a8e7 | ||
|
|
80a3063799 | ||
|
|
34ec11ecfa | ||
|
|
a1da2eafdc | ||
|
|
6bc2175ea9 | ||
|
|
21dcb4f43d | ||
|
|
e9722bcdbd | ||
|
|
e7eb8e3111 | ||
|
|
a895d83764 | ||
|
|
b6697b838b | ||
|
|
0d8c4271a9 | ||
|
|
d226bbda5c | ||
|
|
38d0198dea | ||
|
|
0a920ac21c | ||
|
|
7acee9458d | ||
|
|
82e40ce664 | ||
|
|
4632269ac3 | ||
|
|
6d7e1ef53d | ||
|
|
3ea4cdc3b3 | ||
|
|
e4619eeca3 | ||
|
|
bb5c7c5ad7 | ||
|
|
9984fe97ba | ||
|
|
242dd24caa | ||
|
|
2482d9390a | ||
|
|
3b4923ccae | ||
|
|
8a2e4385ff | ||
|
|
e83b8ac218 | ||
|
|
b387fba5f4 | ||
|
|
da68cb618e | ||
|
|
eb11dac21e | ||
|
|
6e531ee067 | ||
|
|
c8e6daa7a1 | ||
|
|
b3e3d427cb | ||
|
|
6e88054af7 | ||
|
|
22dfa0e61d | ||
|
|
833cd32578 | ||
|
|
fd1c964c92 | ||
|
|
87b10ef055 | ||
|
|
734f65b10b | ||
|
|
0f826a6f76 | ||
|
|
35e521cc55 | ||
|
|
63c845574f | ||
|
|
5a675cc75d | ||
|
|
994dc9bf76 | ||
|
|
cc4a07e3b0 | ||
|
|
2ca88d5328 | ||
|
|
0bca9b9bf1 | ||
|
|
742d2f11be | ||
|
|
5ea5b82994 | ||
|
|
81245cf125 | ||
|
|
c6bcd05404 | ||
|
|
1999a25095 | ||
|
|
62f7c5ba0f | ||
|
|
d11b0e92f1 | ||
|
|
662bdea45b | ||
|
|
d37cc4f641 | ||
|
|
6b2bc71be9 | ||
|
|
f267940562 | ||
|
|
7140406f35 | ||
|
|
e275e2e240 | ||
|
|
75c0920f5e | ||
|
|
b6efe9ae1e | ||
|
|
a28378bac9 | ||
|
|
a940fa9eb7 | ||
|
|
332fba6168 | ||
|
|
41655532e9 | ||
|
|
6cc9801fe1 | ||
|
|
29ff5b9416 | ||
|
|
889dd651ef | ||
|
|
8c7d7a3055 | ||
|
|
ff67931c04 | ||
|
|
faa6f0e0a3 | ||
|
|
68ec37605f | ||
|
|
2ef8b89da0 | ||
|
|
dfc746ea7a | ||
|
|
661546f130 | ||
|
|
5e61342ff5 | ||
|
|
4eadfdeec2 | ||
|
|
8284a9de44 | ||
|
|
ae2e70245f | ||
|
|
a92e283a66 | ||
|
|
9e2c0d8152 | ||
|
|
57453a5b00 | ||
|
|
1ccf677ea2 | ||
|
|
0a9daf0d3a | ||
|
|
934217ee4f | ||
|
|
deff282a63 | ||
|
|
bcd687764c | ||
|
|
8d7224fecc | ||
|
|
3fff3378c0 | ||
|
|
91ae89d463 | ||
|
|
5c0d112def | ||
|
|
f7ae90811e | ||
|
|
6ec8c33ecc | ||
|
|
f991d5434f | ||
|
|
7cf1688de5 | ||
|
|
298b3c3660 | ||
|
|
5ea1c96e19 |
18
.travis.yml
18
.travis.yml
@@ -13,24 +13,24 @@ services:
|
||||
- postgresql
|
||||
matrix:
|
||||
include:
|
||||
- python: 3.7
|
||||
- python: 3.8
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
|
||||
- python: 3.7
|
||||
- python: 3.8
|
||||
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.7
|
||||
- python: 3.8
|
||||
env: JOB=style
|
||||
- python: 3.7
|
||||
- python: 3.8
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.7
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.5
|
||||
- python: 3.8
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.7
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.8
|
||||
env: JOB=doc-spelling
|
||||
- python: 3.7
|
||||
- python: 3.8
|
||||
env: JOB=translation-spelling
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
postgresql: "10"
|
||||
mariadb: '10.3'
|
||||
apt:
|
||||
packages:
|
||||
|
||||
@@ -4,7 +4,7 @@ pid /var/run/nginx.pid;
|
||||
daemon off;
|
||||
|
||||
events {
|
||||
worker_connections 768;
|
||||
worker_connections 4096;
|
||||
}
|
||||
|
||||
http {
|
||||
@@ -39,7 +39,7 @@ http {
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen 80 backlog=4096 default_server;
|
||||
listen [::]:80 ipv6only=on default_server;
|
||||
server_name _;
|
||||
index index.php index.html;
|
||||
|
||||
@@ -182,6 +182,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
|
||||
-v /var/pretix-data:/data \
|
||||
-v /etc/pretix:/etc/pretix \
|
||||
-v /var/run/redis:/var/run/redis \
|
||||
--sysctl net.core.somaxconn=4096 \
|
||||
pretix/standalone:stable all
|
||||
ExecStop=/usr/bin/docker stop %n
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ solution with many things readily set-up, look at :ref:`dockersmallscale`.
|
||||
get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service
|
||||
offers at `pretix.eu`_.
|
||||
|
||||
We tested this guide on the Linux distribution **Debian 8.0** but it should work very similar on other
|
||||
We tested this guide on the Linux distribution **Debian 10.0** but it should work very similar on other
|
||||
modern distributions, especially on all systemd-based ones.
|
||||
|
||||
Requirements
|
||||
@@ -133,7 +133,7 @@ command if you're running MySQL::
|
||||
|
||||
(venv)$ pip3 install "pretix[postgres]" gunicorn
|
||||
|
||||
Note that you need Python 3.5 or newer. You can find out your Python version using ``python -V``.
|
||||
Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
|
||||
|
||||
We also need to create a data directory::
|
||||
|
||||
|
||||
@@ -170,6 +170,19 @@ Date String in ISO 8601 format ``2017-12-27``
|
||||
Multi-lingual string Object of strings ``{"en": "red", "de": "rot", "de_Informal": "rot"}``
|
||||
Money String with decimal number ``"23.42"``
|
||||
Currency String with ISO 4217 code ``"EUR"``, ``"USD"``
|
||||
Relative datetime *either* String in ISO 8601 ``"2017-12-27T10:00:00.596934Z"``,
|
||||
format *or* specification of ``"RELDATE/3/12:00:00/presale_start/"``
|
||||
a relative datetime,
|
||||
constructed from a number of
|
||||
days before the base point,
|
||||
a time of day, and the base
|
||||
point.
|
||||
Relative date *either* String in ISO 8601 ``"2017-12-27"``,
|
||||
format *or* specification of ``"RELDATE/3/-/presale_start/"``
|
||||
a relative date,
|
||||
constructed from a number of
|
||||
days before the base point
|
||||
and the base point.
|
||||
===================== ============================ ===================================
|
||||
|
||||
Query parameters
|
||||
|
||||
@@ -61,7 +61,7 @@ access to the API. The ``token`` endpoint expects you to authenticate using `HTT
|
||||
ID as a username and your client secret as a password. You are also required to again supply the same ``redirect_uri``
|
||||
parameter that you used for the authorization.
|
||||
|
||||
.. http:get:: /api/v1/oauth/token
|
||||
.. http:post:: /api/v1/oauth/token
|
||||
|
||||
Request a new access token
|
||||
|
||||
|
||||
148
doc/api/resources/billing_var.rst
Normal file
148
doc/api/resources/billing_var.rst
Normal file
@@ -0,0 +1,148 @@
|
||||
pretix Hosted reseller API
|
||||
==========================
|
||||
|
||||
This API is only accessible to our `value-added reseller partners`_ on pretix Hosted.
|
||||
|
||||
.. note:: This API is only accessible with user-level permissions, not with API tokens. Therefore, you will need to
|
||||
create an :ref:`OAuth application <rest-oauth>` and obtain an OAuth access token for a user account that has
|
||||
permission to your reseller account.
|
||||
|
||||
Reseller account resource
|
||||
-------------------------
|
||||
|
||||
The resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Your reseller ID
|
||||
name string Internal name of your reseller account
|
||||
public_name string Public name of your reseller account
|
||||
public_url string Public URL of your company
|
||||
support_email string Your support email address
|
||||
support_phone string Your support phone number
|
||||
communication_language string Language code we use to communicate with you
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/var/
|
||||
|
||||
Returns a list of all reseller accounts you have access to.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/var/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "ticketshop.live Ltd & Co. KG",
|
||||
"public_name": "ticketshop.live",
|
||||
"public_url": "https://ticketshop.live",
|
||||
"support_email": "support@ticketshop.live",
|
||||
"support_phone": "+4962213217750",
|
||||
"communication_language": "de"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
.. http:get:: /api/v1/var/(id)/
|
||||
|
||||
Returns information on one reseller account, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/var/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "ticketshop.live Ltd & Co. KG",
|
||||
"public_name": "ticketshop.live",
|
||||
"public_url": "https://ticketshop.live",
|
||||
"support_email": "support@ticketshop.live",
|
||||
"support_phone": "+4962213217750",
|
||||
"communication_language": "de"
|
||||
}
|
||||
|
||||
:param id: The ``id`` field of the reseller account to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 404: The requested account does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:post:: /api/v1/var/(id)/create_organizer/
|
||||
|
||||
Creates a new organizer account that will be associated with a given reseller account.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/var/1/create_organizer/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 123
|
||||
|
||||
{
|
||||
"name": "My new client",
|
||||
"slug": "New client"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "My new client",
|
||||
"slug": "New client"
|
||||
}
|
||||
|
||||
:param id: The ``id`` field of the reseller account to fetch
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: Invalid request body, usually the slug is invalid or already taken.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 404: The requested account does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. _value-added reseller partners: https://pretix.eu/about/en/var
|
||||
@@ -43,6 +43,7 @@ seating_plan integer If reserved sea
|
||||
seat_category_mapping object An object mapping categories of the seating plan
|
||||
(strings) to items in the event (integers or ``null``).
|
||||
timezone string Event timezone name
|
||||
item_meta_properties object Item-specific meta data parameters and default values.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -79,6 +80,10 @@ timezone string Event timezone
|
||||
|
||||
The attribute ``timezone`` has been added.
|
||||
|
||||
.. versionchanged:: 3.7
|
||||
|
||||
The attribute ``item_meta_properties`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -133,6 +138,7 @@ Endpoints
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"item_meta_properties": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer"
|
||||
"pretix.plugins.stripe"
|
||||
@@ -204,6 +210,7 @@ Endpoints
|
||||
"seat_category_mapping": {},
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"item_meta_properties": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer"
|
||||
"pretix.plugins.stripe"
|
||||
@@ -256,6 +263,7 @@ Endpoints
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"item_meta_properties": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
@@ -290,6 +298,7 @@ Endpoints
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"item_meta_properties": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
@@ -344,6 +353,7 @@ Endpoints
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"item_meta_properties": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
@@ -378,6 +388,7 @@ Endpoints
|
||||
"seat_category_mapping": {},
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"item_meta_properties": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
"pretix.plugins.paypal"
|
||||
@@ -444,6 +455,7 @@ Endpoints
|
||||
"seat_category_mapping": {},
|
||||
"meta_data": {},
|
||||
"timezone": "Europe/Berlin",
|
||||
"item_meta_properties": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer",
|
||||
"pretix.plugins.stripe",
|
||||
@@ -486,3 +498,123 @@ Endpoints
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
|
||||
Event settings
|
||||
--------------
|
||||
|
||||
pretix events have lots and lots of parameters of different types that are stored in a key-value store on our system.
|
||||
Since many of these settings depend on each other in complex ways, we can not give direct access to all of these
|
||||
settings through the API. However, we do expose many of the simple and useful flags through the API.
|
||||
|
||||
Please note that the available settings flags change between pretix versions and also between events, depending on the
|
||||
installed plugins, and we do not give a guarantee on backwards-compatibility like with other parts of the API.
|
||||
Therefore, we're also not including a list of the options here, but instead recommend to look at the endpoint output
|
||||
to see available options. The ``explain=true`` flag enables a verbose mode that provides you with human-readable
|
||||
information about the properties.
|
||||
|
||||
.. note:: Please note that this is not a complete representation of all event settings. You will find more settings
|
||||
in the web interface.
|
||||
|
||||
.. warning:: This API is intended for advanced users. Even though we take care to validate your input, you will be
|
||||
able to break your event using this API by creating situations of conflicting settings. Please take care.
|
||||
|
||||
.. versionchanged:: 3.6
|
||||
|
||||
Initial support for settings has been added to the API.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/settings/
|
||||
|
||||
Get current values of event settings.
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/settings/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example standard response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"imprint_url": "https://pretix.eu",
|
||||
…
|
||||
}
|
||||
|
||||
**Example verbose response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"imprint_url":
|
||||
{
|
||||
"value": "https://pretix.eu",
|
||||
"label": "Imprint URL",
|
||||
"help_text": "This should point e.g. to a part of your website that has your contact details and legal information."
|
||||
}
|
||||
},
|
||||
…
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to access
|
||||
:param event: The ``slug`` field of the event to access
|
||||
:query explain: Set to ``true`` to enable verbose response mode
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/settings/
|
||||
|
||||
Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``.
|
||||
|
||||
.. warning::
|
||||
|
||||
Settings can be stored at different levels in pretix. If a value is not set on event level, a default setting
|
||||
from a higher level (organizer, global) will be returned. If you explicitly set a setting on event level, it
|
||||
will no longer be inherited from the higher levels. Therefore, we recommend you to send only settings that you
|
||||
explicitly want to set on event level. To unset a settings, pass ``null``.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/settings/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"imprint_url": "https://example.org/imprint/"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"imprint_url": "https://example.org/imprint/",
|
||||
…
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to update
|
||||
:param event: The ``slug`` field of the event to update
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The event 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 create this resource.
|
||||
|
||||
@@ -59,6 +59,9 @@ Endpoints
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string secret: Only show gift cards with the given secret.
|
||||
:query boolean testmode: Filter for gift cards that are (not) in test mode.
|
||||
:query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
@@ -94,6 +97,7 @@ Endpoints
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param id: The ``id`` field of the gift card to fetch
|
||||
:query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer.
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
@@ -227,6 +231,7 @@ Endpoints
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the gift card to modify
|
||||
:query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer.
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The gift card could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
@@ -23,6 +23,8 @@ Resources and endpoints
|
||||
waitinglist
|
||||
giftcards
|
||||
carts
|
||||
teams
|
||||
webhooks
|
||||
seatingplans
|
||||
billing_invoices
|
||||
billing_var
|
||||
|
||||
@@ -114,6 +114,7 @@ bundles list of objects Definition of b
|
||||
└ designated_price money (string) Designated price of the bundled product. This will be
|
||||
used to split the price of the base item e.g. for mixed
|
||||
taxation. This is not added to the price.
|
||||
meta_data object Values set for event-specific meta data parameters.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
@@ -154,6 +155,10 @@ bundles list of objects Definition of b
|
||||
|
||||
The ``show_quota_left``, ``allow_waitinglist``, and ``hidden_if_available`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 3.7
|
||||
|
||||
The attribute ``meta_data`` has been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
|
||||
@@ -208,6 +213,7 @@ Endpoints
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
@@ -303,6 +309,7 @@ Endpoints
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
@@ -379,6 +386,7 @@ Endpoints
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
@@ -442,6 +450,7 @@ Endpoints
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
@@ -537,6 +546,7 @@ Endpoints
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
|
||||
@@ -151,6 +151,10 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``order.fees.canceled`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 3.8
|
||||
|
||||
The ``reactivate`` operation has been added.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -173,6 +177,13 @@ price money (string) Price of this p
|
||||
attendee_name string Specified attendee name for this position (or ``null``)
|
||||
attendee_name_parts object of strings Decomposition of attendee name (i.e. given name, family name)
|
||||
attendee_email string Specified attendee email address for this position (or ``null``)
|
||||
company string Attendee company name (or ``null``)
|
||||
street string Attendee street (or ``null``)
|
||||
zipcode string Attendee ZIP code (or ``null``)
|
||||
city string Attendee city (or ``null``)
|
||||
country string Attendee country code (or ``null``)
|
||||
state string Attendee state (ISO 3166-2 code). Only supported in
|
||||
AU, BR, CA, CN, MY, MX, and US, otherwise ``null``.
|
||||
voucher integer Internal ID of the voucher used for this position (or ``null``)
|
||||
tax_rate decimal (string) VAT rate applied for this position
|
||||
tax_value money (string) VAT included in this position
|
||||
@@ -236,6 +247,10 @@ pdf_data object Data object req
|
||||
|
||||
The attribute ``canceled`` has been added.
|
||||
|
||||
.. versionchanged:: 3.8
|
||||
|
||||
The attributes ``company``, ``street``, ``zipcode``, ``city``, ``country``, and ``state`` have been added.
|
||||
|
||||
.. _order-payment-resource:
|
||||
|
||||
Order payment resource
|
||||
@@ -380,6 +395,12 @@ List of all orders
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"company": "Sample company",
|
||||
"street": "Test street 12",
|
||||
"zipcode": "12345",
|
||||
"city": "Testington",
|
||||
"country": "DE",
|
||||
"state": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_value": "0.00",
|
||||
@@ -536,6 +557,12 @@ Fetching individual orders
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"company": "Sample company",
|
||||
"street": "Test street 12",
|
||||
"zipcode": "12345",
|
||||
"city": "Testington",
|
||||
"country": "DE",
|
||||
"state": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": null,
|
||||
@@ -816,9 +843,9 @@ Creating orders
|
||||
* ``consume_carts`` (optional) – A list of cart IDs. All cart positions with these IDs will be deleted if the
|
||||
order creation is successful. Any quotas or seats that become free by this operation will be credited to your order
|
||||
creation.
|
||||
* ``email``
|
||||
* ``email`` (optional)
|
||||
* ``locale``
|
||||
* ``sales_channel``
|
||||
* ``sales_channel`` (optional)
|
||||
* ``payment_provider`` (optional) – The identifier of the payment provider set for this order. This needs to be an
|
||||
existing payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"``
|
||||
for all orders you create as paid. This field is optional when the order status is ``"n"`` or the order total is
|
||||
@@ -851,15 +878,21 @@ Creating orders
|
||||
|
||||
* ``positionid`` (optional, see below)
|
||||
* ``item``
|
||||
* ``variation``
|
||||
* ``variation`` (optional)
|
||||
* ``price`` (optional, if set to ``null`` or missing the price will be computed from the given product)
|
||||
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
|
||||
* ``attendee_name`` **or** ``attendee_name_parts``
|
||||
* ``attendee_name`` **or** ``attendee_name_parts`` (optional)
|
||||
* ``voucher`` (optional, the ``code`` attribute of a valid voucher)
|
||||
* ``attendee_email``
|
||||
* ``attendee_email`` (optional)
|
||||
* ``company`` (optional)
|
||||
* ``street`` (optional)
|
||||
* ``zipcode`` (optional)
|
||||
* ``city`` (optional)
|
||||
* ``country`` (optional)
|
||||
* ``state`` (optional)
|
||||
* ``secret`` (optional)
|
||||
* ``addon_to`` (optional, see below)
|
||||
* ``subevent``
|
||||
* ``subevent`` (optional)
|
||||
* ``answers``
|
||||
|
||||
* ``question``
|
||||
@@ -891,6 +924,13 @@ Creating orders
|
||||
IDs in the ``addon_to`` field of another position. Note that all add_ons for a specific position need to come
|
||||
immediately after the position itself.
|
||||
|
||||
Starting with pretix 3.7, you can add ``"simulate": true`` to the body to do a "dry run" of your order. This will
|
||||
validate your order and return you an order object with the resulting prices, but will not create an actual order.
|
||||
You can use this for testing or to look up prices. In this case, some attributes are ignored, such as whether
|
||||
to send an email or what payment provider will be used. Note that some returned fields will contain empty values
|
||||
(e.g. all ``id`` fields of positions will be zero) and some will contain fake values (e.g. the order code will
|
||||
always be ``PREVIEW``). pretix plugins will not be triggered, so some special behavior might be missing as well.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -1050,6 +1090,42 @@ Order state operations
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/reactivate/
|
||||
|
||||
Reactivates a canceled order. This will set the order to pending or paid state. Only possible if all products are
|
||||
still available.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/reactivate/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"code": "ABC12",
|
||||
"status": "n",
|
||||
...
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param code: The ``code`` field of the order to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The order cannot be reactivated
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_pending/
|
||||
|
||||
Marks a paid order as unpaid.
|
||||
@@ -1621,6 +1697,10 @@ Order payment endpoints
|
||||
|
||||
These endpoints have been added.
|
||||
|
||||
.. versionchanged:: 3.6
|
||||
|
||||
Payments can now be created through the API.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/
|
||||
|
||||
Returns a list of all payments for an order.
|
||||
@@ -1829,6 +1909,61 @@ Order payment endpoints
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order or payment does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/
|
||||
|
||||
Creates a new payment.
|
||||
|
||||
Be careful with the ``info`` parameter: You can pass a nested JSON object that will be set as the internal ``info``
|
||||
value of the payment object that will be created. How this value is handled is up to the payment provider and you
|
||||
should only use this if you know the specific payment provider in detail. Please keep in mind that the payment
|
||||
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
|
||||
charge will be created), this is just informative in case you *handled the payment already*.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"state": "confirmed",
|
||||
"amount": "23.00",
|
||||
"payment_date": "2017-12-04T12:13:12Z",
|
||||
"info": {},
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"local_id": 1,
|
||||
"state": "confirmed",
|
||||
"amount": "23.00",
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"payment_date": "2017-12-04T12:13:12Z",
|
||||
"payment_url": null,
|
||||
"details": {},
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to access
|
||||
:param event: The ``slug`` field of the event to access
|
||||
:param order: The ``code`` field of the order to access
|
||||
:statuscode 201: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
|
||||
Order refund endpoints
|
||||
----------------------
|
||||
@@ -1947,7 +2082,8 @@ Order refund endpoints
|
||||
"payment": 1,
|
||||
"execution_date": null,
|
||||
"provider": "manual",
|
||||
"mark_canceled": false
|
||||
"mark_canceled": false,
|
||||
"mark_pending": true
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
671
doc/api/resources/teams.rst
Normal file
671
doc/api/resources/teams.rst
Normal file
@@ -0,0 +1,671 @@
|
||||
.. spelling:: fullname
|
||||
|
||||
.. _`rest-teams`:
|
||||
|
||||
Teams
|
||||
=====
|
||||
|
||||
.. warning:: Unlike our user interface, the team API **does** allow you to lock yourself out by deleting or modifying
|
||||
the team your user or API key belongs to. Be careful around here!
|
||||
|
||||
Team resource
|
||||
-------------
|
||||
|
||||
The team resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the team
|
||||
name string Team name
|
||||
all_events boolean Whether this team has access to all events
|
||||
limit_events list List of event slugs this team has access to
|
||||
can_create_events boolean
|
||||
can_change_teams boolean
|
||||
can_change_organizer_settings boolean
|
||||
can_manage_gift_cards boolean
|
||||
can_change_event_settings boolean
|
||||
can_change_items boolean
|
||||
can_view_orders boolean
|
||||
can_change_orders boolean
|
||||
can_view_vouchers boolean
|
||||
can_change_vouchers boolean
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Team member resource
|
||||
--------------------
|
||||
|
||||
The team member resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the user
|
||||
email string The user's email address
|
||||
fullname string The user's full name (or ``null``)
|
||||
require_2fa boolean Whether this user uses two-factor-authentication
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Team invite resource
|
||||
--------------------
|
||||
|
||||
The team invite resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the invite
|
||||
email string The invitee's email address
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Team API token resource
|
||||
-----------------------
|
||||
|
||||
The team API token resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the invite
|
||||
name string Name of this API token
|
||||
active boolean Whether this API token is active (can never be set to
|
||||
``true`` again once ``false``)
|
||||
token string The actual API token. Will only be sent back during
|
||||
token creation.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Team endpoints
|
||||
--------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/teams/
|
||||
|
||||
Returns a list of all teams within a given organizer.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/teams/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/teams/(id)/
|
||||
|
||||
Returns information on one team, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/teams/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param id: The ``id`` field of the team to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/teams/
|
||||
|
||||
Creates a new team
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/teams/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a team for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The team could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/teams/(id)/
|
||||
|
||||
Update a team. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/teams/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"can_create_events": true
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the team to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The team could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/teams/(id)/
|
||||
|
||||
Deletes a team.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/teams/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the team to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
|
||||
|
||||
Team member endpoints
|
||||
---------------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/members/
|
||||
|
||||
Returns a list of all members of a team.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/teams/1/members/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"fullname": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"require_2fa": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param team: The ``id`` field of the team to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested team does not exist
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/members/(id)/
|
||||
|
||||
Returns information on one team member, identified by their ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/teams/1/members/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"fullname": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"require_2fa": true
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param team: The ``id`` field of the team to fetch
|
||||
:param id: The ``id`` field of the member to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested team or member does not exist
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/teams/(team)/members/(id)/
|
||||
|
||||
Removes a member from the team.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/teams/1/members/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param team: The ``id`` field of the team to modify
|
||||
:param id: The ``id`` field of the member to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
:statuscode 404: The requested team or member does not exist
|
||||
|
||||
Team invite endpoints
|
||||
---------------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/invites/
|
||||
|
||||
Returns a list of all invitations to a team.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/teams/1/invites/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"email": "john@example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param team: The ``id`` field of the team to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested team does not exist
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/invites/(id)/
|
||||
|
||||
Returns information on one invite, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/teams/1/invites/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"email": "john@example.org"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param team: The ``id`` field of the team to fetch
|
||||
:param id: The ``id`` field of the invite to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested team or invite does not exist
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/teams/(team)/invites/
|
||||
|
||||
Invites someone into the team. Note that if the user already has a pretix account, you will receive a response without
|
||||
an ``id`` and instead of an invite being created, the user will be directly added to the team.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/teams/1/invites/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"email": "mark@example.org"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"email": "mark@example.org"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param team: The ``id`` field of the team to modify
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
:statuscode 404: The requested team does not exist
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/teams/(team)/invites/(id)/
|
||||
|
||||
Revokes an invite.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/teams/1/invites/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param team: The ``id`` field of the team to modify
|
||||
:param id: The ``id`` field of the invite to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
:statuscode 404: The requested team or invite does not exist
|
||||
|
||||
Team API token endpoints
|
||||
------------------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/tokens/
|
||||
|
||||
Returns a list of all API tokens of a team.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/teams/1/tokens/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"active": true,
|
||||
"name": "Test token"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param team: The ``id`` field of the team to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested team does not exist
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/tokens/(id)/
|
||||
|
||||
Returns information on one token, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/teams/1/tokens/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"active": true,
|
||||
"name": "Test token"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param team: The ``id`` field of the team to fetch
|
||||
:param id: The ``id`` field of the token to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested team or token does not exist
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/teams/(team)/tokens/
|
||||
|
||||
Creates a new token.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/teams/1/tokens/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"name": "New token"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"name": "New token",
|
||||
"active": true,
|
||||
"token": "",
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param team: The ``id`` field of the team to create a token for
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
:statuscode 404: The requested team does not exist
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/teams/(team)/tokens/(id)/
|
||||
|
||||
Disables a token.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/teams/1/tokens/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "My token",
|
||||
"active": false
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param team: The ``id`` field of the team to modify
|
||||
:param id: The ``id`` field of the token to delete
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
:statuscode 404: The requested team or token does not exist
|
||||
@@ -66,7 +66,7 @@ event-related views, there is also a signal that allows you to add the view to t
|
||||
|
||||
from django.urls import resolve, reverse
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from pretix.control.signals import nav_event
|
||||
|
||||
|
||||
|
||||
@@ -20,17 +20,24 @@ Order events
|
||||
There are multiple signals that will be sent out in the ordering cycle:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: validate_cart, validate_cart_addons, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text
|
||||
:members: validate_cart, validate_cart_addons, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text
|
||||
|
||||
Check-ins
|
||||
"""""""""
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: checkin_created
|
||||
|
||||
|
||||
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, 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, item_description
|
||||
: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, 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
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: order_info, order_meta_from_request
|
||||
:members: order_info, order_info_top, order_meta_from_request
|
||||
|
||||
Request flow
|
||||
""""""""""""
|
||||
@@ -81,3 +88,9 @@ Ticket designs
|
||||
|
||||
.. automodule:: pretix.plugins.ticketoutputpdf.signals
|
||||
:members: override_layout
|
||||
|
||||
API
|
||||
---
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: validate_event_settings, api_event_settings_fields
|
||||
|
||||
@@ -114,6 +114,8 @@ The provider class
|
||||
|
||||
.. automethod:: api_payment_details
|
||||
|
||||
.. automethod:: matching_id
|
||||
|
||||
.. automethod:: shred_payment_info
|
||||
|
||||
.. automethod:: cancel_payment
|
||||
|
||||
@@ -46,6 +46,9 @@ name string The human-readable name of your plugin
|
||||
author string Your name
|
||||
version string A human-readable version code of your plugin
|
||||
description string A more verbose description of what your plugin does.
|
||||
category string Category of a plugin. Either one of ``"FEATURE"``, ``"PAYMENT"``,
|
||||
``"INTEGRATION"``, ``"CUSTOMIZATION"``, ``"FORMAT"``, or ``"API"``,
|
||||
or any other string.
|
||||
visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated.
|
||||
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
|
||||
for an event by system administrators / superusers.
|
||||
@@ -58,7 +61,7 @@ A working example would be::
|
||||
from pretix.base.plugins import PluginConfig
|
||||
except ImportError:
|
||||
raise RuntimeError("Please use pretix 2.7 or above to run this plugin!")
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class PaypalApp(PluginConfig):
|
||||
@@ -69,6 +72,7 @@ A working example would be::
|
||||
name = _("PayPal")
|
||||
author = _("the pretix team")
|
||||
version = '1.0.0'
|
||||
category = 'PAYMENT
|
||||
visible = True
|
||||
restricted = False
|
||||
description = _("This plugin allows you to receive payments via PayPal")
|
||||
|
||||
@@ -69,7 +69,7 @@ We now need a way to translate the action codes like ``pretix.event.changed`` in
|
||||
strings. The :py:attr:`pretix.base.signals.logentry_display` signals allows you to do so. A simple
|
||||
implementation could look like::
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from pretix.base.signals import logentry_display
|
||||
|
||||
@receiver(signal=logentry_display)
|
||||
|
||||
224
doc/plugins/campaigns.rst
Normal file
224
doc/plugins/campaigns.rst
Normal file
@@ -0,0 +1,224 @@
|
||||
Campaigns
|
||||
=========
|
||||
|
||||
The campaigns plugin provides a HTTP API that allows you to create new campaigns.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The campaign resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal campaign ID
|
||||
code string The URL component of the campaign, e.g. with code ``BAR``
|
||||
the campaign URL would to be ``https://<server>/<organizer>/<event>/c/BAR/``.
|
||||
This value needs to be *globally unique* and we do not
|
||||
recommend setting it manually. If you omit it, a random
|
||||
value will be chosen.
|
||||
description string An internal, human-readable name of the campaign.
|
||||
external_target string An URL to redirect to from the tracking link. To redirect to
|
||||
the ticket shop, use an empty string.
|
||||
order_count integer Number of orders tracked on this campaign (read-only)
|
||||
click_count integer Number of clicks tracked on this campaign (read-only)
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/campaigns/
|
||||
|
||||
Returns a list of all campaigns configured for an event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/campaigns/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"code": "wZnL11fjq",
|
||||
"description": "Facebook",
|
||||
"external_target": "",
|
||||
"order_count:" 0,
|
||||
"click_count:" 0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/campaigns/(id)/
|
||||
|
||||
Returns information on one campaign, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/campaigns/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"code": "wZnL11fjq",
|
||||
"description": "Facebook",
|
||||
"external_target": "",
|
||||
"order_count:" 0,
|
||||
"click_count:" 0
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the campaign to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/campaign does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/campaigns/
|
||||
|
||||
Create a new campaign.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/campaigns/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 166
|
||||
|
||||
{
|
||||
"description": "Twitter"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"code": "IfVJQzSBL",
|
||||
"description": "Twitter",
|
||||
"external_target": "",
|
||||
"order_count:" 0,
|
||||
"click_count:" 0
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a campaign for
|
||||
:param event: The ``slug`` field of the event to create a campaign for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The campaign could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create campaigns.
|
||||
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/campaigns/(id)/
|
||||
|
||||
Update a campaign. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/campaigns/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 34
|
||||
|
||||
{
|
||||
"external_target": "https://mywebsite.com"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"code": "IfVJQzSBL",
|
||||
"description": "Twitter",
|
||||
"external_target": "https://mywebsite.com",
|
||||
"order_count:" 0,
|
||||
"click_count:" 0
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the campaign to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The campaign could not be modified due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/campaign does not exist **or** you have no permission to change it.
|
||||
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/campaigns/(id)/
|
||||
|
||||
Delete a campaign and all associated data.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/campaigns/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the campaign to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/campaign does not exist **or** you have no permission to change it
|
||||
277
doc/plugins/digital.rst
Normal file
277
doc/plugins/digital.rst
Normal file
@@ -0,0 +1,277 @@
|
||||
Digital content
|
||||
===============
|
||||
|
||||
The digital content plugin provides a HTTP API that allows you to create new digital content for your ticket holders,
|
||||
such as live streams, videos, or material downloads.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The digital content resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal content ID
|
||||
title multi-lingual string The content title (required)
|
||||
content_type string The type of content, valid values are ``webinar``, ``video``, ``livestream``, ``link``, ``file``
|
||||
url string The location of the digital content
|
||||
description multi-lingual string A public description of the item. May contain Markdown
|
||||
syntax and is not required.
|
||||
available_from datetime The first date time at which this content will be shown
|
||||
(or ``null``).
|
||||
available_until datetime The last date time at which this content will b e shown
|
||||
(or ``null``).
|
||||
all_products boolean If ``true``, the content is available to all buyers of tickets for this event. The ``limit_products`` field is ignored in this case.
|
||||
limit_products list of integers List of product/item IDs. This content is only shown to buyers of these ticket types.
|
||||
position integer An integer, used for sorting
|
||||
subevent integer Date in an event series this content should be shown for. Should be ``null`` if this is not an event series or if this should be shown to all customers.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/
|
||||
|
||||
Returns a list of all digital content configured for an event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"subevent": null,
|
||||
"title": {
|
||||
"en": "Concert livestream"
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"available_from": "2020-03-22T23:00:00Z",
|
||||
"available_until": null,
|
||||
"position": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/(id)/
|
||||
|
||||
Returns information on one content item, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"subevent": null,
|
||||
"title": {
|
||||
"en": "Concert livestream"
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"available_from": "2020-03-22T23:00:00Z",
|
||||
"available_until": null,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``id`` field of the content to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/content does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/
|
||||
|
||||
Create a new digital content.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 166
|
||||
|
||||
{
|
||||
"subevent": null,
|
||||
"title": {
|
||||
"en": "Concert livestream"
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"available_from": "2020-03-22T23:00:00Z",
|
||||
"available_until": null,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"subevent": null,
|
||||
"title": {
|
||||
"en": "Concert livestream"
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"available_from": "2020-03-22T23:00:00Z",
|
||||
"available_until": null,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create new content for
|
||||
:param event: The ``slug`` field of the event to create new content for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The content could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create digital contents.
|
||||
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/(id)/
|
||||
|
||||
Update a content. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 34
|
||||
|
||||
{
|
||||
"url": "https://mywebsite.com"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"subevent": null,
|
||||
"title": {
|
||||
"en": "Concert livestream"
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://mywebsite.com",
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"available_from": "2020-03-22T23:00:00Z",
|
||||
"available_until": null,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the content to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The content could not be modified due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/content does not exist **or** you have no permission to change it.
|
||||
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/digitalcontents/(id)/
|
||||
|
||||
Delete a digital content.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the content to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/content does not exist **or** you have no permission to change it
|
||||
@@ -14,3 +14,5 @@ If you want to **create** a plugin, please go to the
|
||||
banktransfer
|
||||
ticketoutputpdf
|
||||
badges
|
||||
campaigns
|
||||
digital
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
-r ../src/requirements.txt
|
||||
sphinx==1.6.*
|
||||
sphinx==2.3.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
sphinxcontrib-images
|
||||
sphinxcontrib-spelling
|
||||
pygments-markdown-lexer
|
||||
# See https://github.com/rfk/pyenchant/pull/130
|
||||
git+https://github.com/raphaelm/pyenchant.git@patch-1#egg=pyenchant
|
||||
|
||||
@@ -103,6 +103,7 @@ regex
|
||||
renderer
|
||||
renderers
|
||||
reportlab
|
||||
reseller
|
||||
SaaS
|
||||
scalability
|
||||
screenshot
|
||||
@@ -110,9 +111,10 @@ scss
|
||||
searchable
|
||||
selectable
|
||||
serializable
|
||||
serializers
|
||||
serializer
|
||||
serializers
|
||||
sexualized
|
||||
SQL
|
||||
startup
|
||||
stdout
|
||||
stylesheet
|
||||
@@ -139,6 +141,7 @@ untrusted
|
||||
uptime
|
||||
username
|
||||
url
|
||||
validator
|
||||
versa
|
||||
versioning
|
||||
viewable
|
||||
|
||||
@@ -114,6 +114,17 @@ If you want to disable voucher input in the widget, you can pass the ``disable-v
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" disable-vouchers></pretix-widget>
|
||||
|
||||
Filtering products
|
||||
------------------
|
||||
|
||||
You can filter the products shown in the widget by passing in a list of product IDs::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" items="23,42"></pretix-widget>
|
||||
|
||||
Alternatively, you can select one or more categories to be shown::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/democon/" categories="12,25"></pretix-widget>
|
||||
|
||||
Multi-event selection
|
||||
---------------------
|
||||
|
||||
@@ -183,6 +194,24 @@ Just as the widget, the button supports the optional attributes ``voucher`` and
|
||||
|
||||
You can style the button using the ``pretix-button`` CSS class.
|
||||
|
||||
Dynamically opening the widget
|
||||
------------------------------
|
||||
|
||||
You can get the behavior of the pretix Button without a button at all, so you can trigger it from your own code in
|
||||
response to a user action. Usually, this will open an overlay with your ticket shop, however in some cases, such as
|
||||
missing HTTPS encryption on your case or a really small screen (mobile), it will open a new tab instead of an overlay.
|
||||
Therefore, make sure you call this *in direct response to a user action*, otherwise most browser will block it as an
|
||||
unwanted pop-up.
|
||||
|
||||
.. js:function:: window.PretixWidget.open(target_url [, voucher [, subevent [, items, [, widget_data [, skip_ssl_check ]]]]])
|
||||
|
||||
:param string target_url: The URL of the ticket shop.
|
||||
:param string voucher: A voucher code to be pre-selected, or ``null``.
|
||||
:param string subevent: A subevent to be pre-selected, or ``null``.
|
||||
:param array items: A collection of items to be put in the cart, of the form ``[{"item": "item_3", "count": 1}, {"item": "variation_5_6", "count": 4}]``
|
||||
:param object widget_data: Additional data to be passed to the shop, see below.
|
||||
:param boolean skip_ssl_check: Whether to ignore the check for HTTPS. Only to be used during development.
|
||||
|
||||
Dynamically loading the widget
|
||||
------------------------------
|
||||
|
||||
@@ -238,7 +267,8 @@ with that information::
|
||||
data-question-L9G8NG9M="Foobar">
|
||||
</pretix-widget>
|
||||
|
||||
This works for the pretix Button as well. Currently, the following attributes are understood by pretix itself:
|
||||
This works for the pretix Button as well, if you also specify a product.
|
||||
Currently, the following attributes are understood by pretix itself:
|
||||
|
||||
* ``data-email`` will pre-fill the order email field as well as the attendee email field (if enabled).
|
||||
|
||||
@@ -303,4 +333,8 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
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
|
||||
|
||||
Dynamically opening the widget has been added in pretix 3.6.
|
||||
|
||||
.. _Let's Encrypt: https://letsencrypt.org/
|
||||
|
||||
@@ -40,6 +40,24 @@ If you created a product and it doesn't show up, please follow the following ste
|
||||
6. If the sale period has not started yet or is already over, check the "Show items outside presale period" setting of
|
||||
your event.
|
||||
|
||||
Can I have different payment deadlines for different payment methods?
|
||||
---------------------------------------------------------------------
|
||||
|
||||
No. We do not think it makes a lot of sense, for a number of reasons. First of all we believe it is not very
|
||||
customer-friendly. You might for example want to configure a 1-day deadline for credit card payments and 2 weeks for
|
||||
bank transfers. However, think for example of a customer who wants to pay by card and then the payment fails because
|
||||
the bank locked the card or refused the payment. The customer now needs to worry about not getting their ticket, or
|
||||
needs to create a new order with a different payment method. A payment deadline is a guarantee to your customer to hold
|
||||
the ticket if it is paid for within a certain time frame. If you give a two-week guarantee to some of your customers,
|
||||
why not to others?
|
||||
|
||||
There are some other issues with it as well. pretix allows customers to switch payment methods as long as their payment
|
||||
has not been started or if it has failed. For example, a customer who selected bank transfer can later switch to credit
|
||||
card if they haven't sent the money yet, or a customer with a failed credit card payment can switch to a different
|
||||
method without creating a new order. If payment deadlines were dependent on the payment method, switching back and
|
||||
forth could either allow someone to extend their deadline forever, or render someones order invalid by moving the date
|
||||
back in the past.
|
||||
|
||||
How can I revert a check-in?
|
||||
----------------------------
|
||||
|
||||
|
||||
@@ -14,30 +14,23 @@ and with pretix, you can do this. On this page, you find out the necessary steps
|
||||
With the pretix.eu hosted service
|
||||
---------------------------------
|
||||
|
||||
Step 1: DNS Configuration
|
||||
#########################
|
||||
Go to "Organizers" in the backend and select your organizer account. Then, go to "Settings" and "Custom Domain".
|
||||
|
||||
This page will show you instructions on how to set up your own domain. Basically, it works like this:
|
||||
|
||||
Go to the website of the provider you registered your domain name with. Look for the "DNS" settings page in their
|
||||
interface. Unfortunately, we can't tell you exactly how that is named and how it looks, since it is different for every
|
||||
domain provider.
|
||||
|
||||
Use this interface to add a new subdomain record, e.g. ``tickets`` of the type ``CNAME`` (might also be called "alias").
|
||||
The value of the record should be ``www.pretix.eu``.
|
||||
|
||||
Step 2: Wait for the DNS entry to propagate
|
||||
###########################################
|
||||
The value of the record should be the one shown on the "Custom Domain" page in pretix' backend.
|
||||
|
||||
Submit your changes and wait a bit, it can regularly take up to three hours for DNS changes to propagate to the caches
|
||||
of all DNS servers. You can try checking by accessing your new subdomain, ``http://tickets.awesomepartycorp.com``.
|
||||
If DNS was changed successfully, you should see a SSL certificate error. If you ignore the error and access the page
|
||||
anyways, you should get a pretix-themed error page with the headline "Unknown domain".
|
||||
|
||||
Step 3: Tell us
|
||||
###############
|
||||
|
||||
Write an email to support@pretix.eu, naming your new domain and your organizer account. We will then generate a SSL
|
||||
certificate for you (for free!) and configure the domain.
|
||||
|
||||
Now, tell us about your domain on the "Custom Domain" page to get started.
|
||||
|
||||
With a custom pretix installation
|
||||
---------------------------------
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.5.0"
|
||||
__version__ = "3.8.0"
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import timedelta
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from oauth2_provider.generators import (
|
||||
generate_client_id, generate_client_secret,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import timedelta
|
||||
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy
|
||||
from django.utils.translation import gettext_lazy
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
@@ -56,7 +56,7 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
|
||||
if len(new_quotas) == 0:
|
||||
raise ValidationError(
|
||||
ugettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(validated_data.get('item'))
|
||||
)
|
||||
)
|
||||
@@ -64,8 +64,8 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
avail = quota.availability()
|
||||
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
|
||||
raise ValidationError(
|
||||
ugettext_lazy('There is not enough quota available on quota "{}" to perform '
|
||||
'the operation.').format(
|
||||
gettext_lazy('There is not enough quota available on quota "{}" to perform '
|
||||
'the operation.').format(
|
||||
quota.name
|
||||
)
|
||||
)
|
||||
@@ -88,7 +88,7 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
else:
|
||||
validated_data['seat'] = seat
|
||||
if not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web')):
|
||||
raise ValidationError(ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
|
||||
raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
|
||||
elif seated:
|
||||
raise ValidationError('The specified product requires to choose a seat.')
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext as _
|
||||
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
|
||||
|
||||
@@ -15,6 +17,8 @@ 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_settings
|
||||
from pretix.base.signals import api_event_settings_fields
|
||||
|
||||
|
||||
class MetaDataField(Field):
|
||||
@@ -30,6 +34,19 @@ class MetaDataField(Field):
|
||||
}
|
||||
|
||||
|
||||
class MetaPropertyField(Field):
|
||||
|
||||
def to_representation(self, value):
|
||||
return {
|
||||
v.name: v.default for v in value.item_meta_properties.all()
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return {
|
||||
'item_meta_properties': data
|
||||
}
|
||||
|
||||
|
||||
class SeatCategoryMappingField(Field):
|
||||
|
||||
def to_representation(self, value):
|
||||
@@ -73,6 +90,7 @@ class TimeZoneField(ChoiceField):
|
||||
|
||||
class EventSerializer(I18nAwareModelSerializer):
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
item_meta_properties = MetaPropertyField(required=False, source='*')
|
||||
plugins = PluginsField(required=False, source='*')
|
||||
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
|
||||
timezone = TimeZoneField(required=False, choices=[(a, a) for a in common_timezones])
|
||||
@@ -82,7 +100,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
|
||||
'date_to', 'date_admission', 'is_public', 'presale_start',
|
||||
'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan',
|
||||
'plugins', 'seat_category_mapping', 'timezone')
|
||||
'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -127,6 +145,12 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
|
||||
return value
|
||||
|
||||
@cached_property
|
||||
def item_meta_props(self):
|
||||
return {
|
||||
p.name: p for p in self.context['request'].event.item_meta_properties.all()
|
||||
}
|
||||
|
||||
def validate_seating_plan(self, value):
|
||||
if value and value.organizer != self.context['request'].organizer:
|
||||
raise ValidationError('Invalid seating plan.')
|
||||
@@ -138,8 +162,11 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
return value
|
||||
|
||||
def validate_seat_category_mapping(self, value):
|
||||
if value and value['seat_category_mapping'] and (not self.instance or not self.instance.pk):
|
||||
raise ValidationError('You cannot specify seat category mappings on event creation.')
|
||||
if not self.instance or not self.instance.pk:
|
||||
if value and value['seat_category_mapping']:
|
||||
raise ValidationError('You cannot specify seat category mappings on event creation.')
|
||||
else:
|
||||
return {'seat_category_mapping': {}}
|
||||
item_cache = {i.pk: i for i in self.instance.items.all()}
|
||||
result = {}
|
||||
for k, item in value['seat_category_mapping'].items():
|
||||
@@ -165,6 +192,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
item_meta_properties = validated_data.pop('item_meta_properties', None)
|
||||
validated_data.pop('seat_category_mapping', None)
|
||||
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
|
||||
tz = validated_data.pop('timezone', None)
|
||||
@@ -181,6 +209,15 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
value=value
|
||||
)
|
||||
|
||||
# Item Meta properties
|
||||
if item_meta_properties is not None:
|
||||
for key, value in item_meta_properties.items():
|
||||
event.item_meta_properties.create(
|
||||
name=key,
|
||||
default=value,
|
||||
event=event
|
||||
)
|
||||
|
||||
# Seats
|
||||
if event.seating_plan:
|
||||
generate_seats(event, None, event.seating_plan, {})
|
||||
@@ -195,6 +232,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
item_meta_properties = validated_data.pop('item_meta_properties', None)
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
|
||||
tz = validated_data.pop('timezone', None)
|
||||
@@ -221,6 +259,26 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
if prop.name not in meta_data:
|
||||
current_object.delete()
|
||||
|
||||
# Item Meta properties
|
||||
if item_meta_properties is not None:
|
||||
current = [imp for imp in event.item_meta_properties.all()]
|
||||
for key, value in item_meta_properties.items():
|
||||
prop = self.item_meta_props.get(key)
|
||||
if prop in current:
|
||||
prop.default = value
|
||||
prop.save()
|
||||
else:
|
||||
prop = event.item_meta_properties.create(
|
||||
name=key,
|
||||
default=value,
|
||||
event=event
|
||||
)
|
||||
current.append(prop)
|
||||
|
||||
for prop in current:
|
||||
if prop.name not in list(item_meta_properties.keys()):
|
||||
prop.delete()
|
||||
|
||||
# Seats
|
||||
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
|
||||
current_mappings = {
|
||||
@@ -466,3 +524,138 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = TaxRule
|
||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
|
||||
|
||||
|
||||
class EventSettingsSerializer(serializers.Serializer):
|
||||
default_fields = [
|
||||
'imprint_url',
|
||||
'checkout_email_helptext',
|
||||
'presale_has_ended_text',
|
||||
'voucher_explanation_text',
|
||||
'banner_text',
|
||||
'banner_text_bottom',
|
||||
'show_dates_on_frontpage',
|
||||
'show_date_to',
|
||||
'show_times',
|
||||
'show_items_outside_presale_period',
|
||||
'display_net_prices',
|
||||
'presale_start_show_date',
|
||||
'locales',
|
||||
'locale',
|
||||
'last_order_modification_date',
|
||||
'show_quota_left',
|
||||
'waiting_list_enabled',
|
||||
'waiting_list_hours',
|
||||
'waiting_list_auto',
|
||||
'max_items_per_order',
|
||||
'reservation_time',
|
||||
'contact_mail',
|
||||
'show_variations_expanded',
|
||||
'hide_sold_out',
|
||||
'meta_noindex',
|
||||
'redirect_to_checkout_directly',
|
||||
'frontpage_subevent_ordering',
|
||||
'frontpage_text',
|
||||
'attendee_names_asked',
|
||||
'attendee_names_required',
|
||||
'attendee_emails_asked',
|
||||
'attendee_emails_required',
|
||||
'attendee_addresses_asked',
|
||||
'attendee_addresses_required',
|
||||
'attendee_company_asked',
|
||||
'attendee_company_required',
|
||||
'confirm_text',
|
||||
'order_email_asked_twice',
|
||||
'payment_term_days',
|
||||
'payment_term_last',
|
||||
'payment_term_weekdays',
|
||||
'payment_term_expire_automatically',
|
||||
'payment_term_accept_late',
|
||||
'payment_explanation',
|
||||
'ticket_download',
|
||||
'ticket_download_date',
|
||||
'ticket_download_addons',
|
||||
'ticket_download_nonadm',
|
||||
'ticket_download_pending',
|
||||
'mail_prefix',
|
||||
'mail_from',
|
||||
'mail_from_name',
|
||||
'mail_attach_ical',
|
||||
'invoice_address_asked',
|
||||
'invoice_address_required',
|
||||
'invoice_address_vatid',
|
||||
'invoice_address_company_required',
|
||||
'invoice_address_beneficiary',
|
||||
'invoice_address_custom_field',
|
||||
'invoice_name_required',
|
||||
'invoice_address_not_asked_free',
|
||||
'invoice_show_payments',
|
||||
'invoice_reissue_after_modify',
|
||||
'invoice_include_free',
|
||||
'invoice_generate',
|
||||
'invoice_numbers_consecutive',
|
||||
'invoice_numbers_prefix',
|
||||
'invoice_numbers_prefix_cancellations',
|
||||
'invoice_attendee_name',
|
||||
'invoice_include_expire_date',
|
||||
'invoice_address_explanation_text',
|
||||
'invoice_email_attachment',
|
||||
'invoice_address_from_name',
|
||||
'invoice_address_from',
|
||||
'invoice_address_from_zipcode',
|
||||
'invoice_address_from_city',
|
||||
'invoice_address_from_country',
|
||||
'invoice_address_from_tax_id',
|
||||
'invoice_address_from_vat_id',
|
||||
'invoice_introductory_text',
|
||||
'invoice_additional_text',
|
||||
'invoice_footer_text',
|
||||
'cancel_allow_user',
|
||||
'cancel_allow_user_until',
|
||||
'cancel_allow_user_paid',
|
||||
'cancel_allow_user_paid_until',
|
||||
'cancel_allow_user_paid_keep',
|
||||
'cancel_allow_user_paid_keep_fees',
|
||||
'cancel_allow_user_paid_keep_percentage',
|
||||
'cancel_allow_user_paid_adjust_fees',
|
||||
'cancel_allow_user_paid_adjust_fees_explanation',
|
||||
'cancel_allow_user_paid_refund_as_giftcard',
|
||||
'cancel_allow_user_paid_require_approval',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.default_fields:
|
||||
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
|
||||
kwargs.setdefault('required', False)
|
||||
kwargs.setdefault('allow_null', True)
|
||||
form_kwargs = DEFAULTS[fname].get('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)
|
||||
elif instance.get(attr, as_type=type(value)) != value:
|
||||
instance.set(attr, value)
|
||||
return instance
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
settings_dict = self.instance.freeze()
|
||||
settings_dict.update(data)
|
||||
validate_settings(self.event, settings_dict)
|
||||
return data
|
||||
|
||||
29
src/pretix/api/serializers/fields.py
Normal file
29
src/pretix/api/serializers/fields.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
def remove_duplicates_from_list(data):
|
||||
return list(OrderedDict.fromkeys(data))
|
||||
|
||||
|
||||
class ListMultipleChoiceField(serializers.MultipleChoiceField):
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, str) or not hasattr(data, '__iter__'):
|
||||
self.fail('not_a_list', input_type=type(data).__name__)
|
||||
if not self.allow_empty and len(data) == 0:
|
||||
self.fail('empty')
|
||||
|
||||
internal_value_data = [
|
||||
super(serializers.MultipleChoiceField, self).to_internal_value(item)
|
||||
for item in data
|
||||
]
|
||||
|
||||
return remove_duplicates_from_list(internal_value_data)
|
||||
|
||||
def to_representation(self, value):
|
||||
representation_data = [
|
||||
self.choice_strings_to_values.get(str(item), item) for item in value
|
||||
]
|
||||
|
||||
return remove_duplicates_from_list(representation_data)
|
||||
@@ -2,13 +2,15 @@ from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.serializers.event import MetaDataField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question,
|
||||
QuestionOption, Quota,
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
|
||||
Question, QuestionOption, Quota,
|
||||
)
|
||||
|
||||
|
||||
@@ -110,6 +112,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
bundles = InlineItemBundleSerializer(many=True, required=False)
|
||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
@@ -119,7 +122,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'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')
|
||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def validate(self, data):
|
||||
@@ -167,18 +170,65 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
ItemAddOn.clean_max_min_count(addon_data['max_count'], addon_data['min_count'])
|
||||
return value
|
||||
|
||||
@cached_property
|
||||
def item_meta_properties(self):
|
||||
return {
|
||||
p.name: p for p in self.context['request'].event.item_meta_properties.all()
|
||||
}
|
||||
|
||||
def validate_meta_data(self, value):
|
||||
for key in value['meta_data'].keys():
|
||||
if key not in self.item_meta_properties:
|
||||
raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key))
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
|
||||
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
|
||||
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
item = Item.objects.create(**validated_data)
|
||||
|
||||
for variation_data in variations_data:
|
||||
ItemVariation.objects.create(item=item, **variation_data)
|
||||
for addon_data in addons_data:
|
||||
ItemAddOn.objects.create(base_item=item, **addon_data)
|
||||
for bundle_data in bundles_data:
|
||||
ItemBundle.objects.create(base_item=item, **bundle_data)
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
for key, value in meta_data.items():
|
||||
ItemMetaValue.objects.create(
|
||||
property=self.item_meta_properties.get(key),
|
||||
value=value,
|
||||
item=item
|
||||
)
|
||||
return item
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
item = super().update(instance, validated_data)
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
current = {mv.property: mv for mv in item.meta_values.select_related('property')}
|
||||
for key, value in meta_data.items():
|
||||
prop = self.item_meta_properties.get(key)
|
||||
if prop in current:
|
||||
current[prop].value = value
|
||||
current[prop].save()
|
||||
else:
|
||||
item.meta_values.create(
|
||||
property=self.item_meta_properties.get(key),
|
||||
value=value
|
||||
)
|
||||
|
||||
for prop, current_object in current.items():
|
||||
if prop.name not in meta_data:
|
||||
current_object.delete()
|
||||
|
||||
return item
|
||||
|
||||
|
||||
@@ -237,8 +287,8 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
if value:
|
||||
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
|
||||
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
|
||||
if value == self.instance:
|
||||
raise ValidationError('A question cannot depend on itself.')
|
||||
if value == self.instance:
|
||||
raise ValidationError('A question cannot depend on itself.')
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
@@ -5,7 +5,7 @@ from decimal import Decimal
|
||||
import pycountry
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy
|
||||
from django.utils.translation import gettext_lazy
|
||||
from django_countries.fields import Country
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
@@ -25,6 +25,7 @@ from pretix.base.models.orders import (
|
||||
)
|
||||
from pretix.base.pdf import 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
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
@@ -38,7 +39,7 @@ class CompatibleCountryField(serializers.Field):
|
||||
def to_representation(self, instance: InvoiceAddress):
|
||||
if instance.country:
|
||||
return str(instance.country)
|
||||
else:
|
||||
elif hasattr(instance, 'country_old'):
|
||||
return instance.country_old
|
||||
|
||||
|
||||
@@ -96,6 +97,11 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field):
|
||||
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()]
|
||||
|
||||
|
||||
class InlineSeatSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
@@ -106,6 +112,7 @@ 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)
|
||||
|
||||
class Meta:
|
||||
model = QuestionAnswer
|
||||
@@ -189,6 +196,11 @@ class PdfDataSerializer(serializers.Field):
|
||||
for k, v in ev._cached_meta_data.items():
|
||||
res['meta:' + k] = v
|
||||
|
||||
if not hasattr(instance.item, '_cached_meta_data'):
|
||||
instance.item._cached_meta_data = instance.item.meta_data
|
||||
for k, v in instance.item._cached_meta_data.items():
|
||||
res['itemmeta:' + k] = v
|
||||
|
||||
return res
|
||||
|
||||
|
||||
@@ -199,10 +211,12 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
||||
pdf_data = PdfDataSerializer(source='*')
|
||||
seat = InlineSeatSerializer(read_only=True)
|
||||
country = CompatibleCountryField(source='*')
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'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')
|
||||
|
||||
@@ -504,12 +518,22 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
max_digits=10)
|
||||
voucher = serializers.SlugRelatedField(slug_field='code', queryset=Voucher.objects.none(),
|
||||
required=False, allow_null=True)
|
||||
country = CompatibleCountryField(source='*')
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
||||
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for k, v in self.fields.items():
|
||||
if k in ('company', 'street', 'zipcode', 'city', 'country', 'state'):
|
||||
v.required = False
|
||||
v.allow_blank = True
|
||||
v.allow_null = True
|
||||
|
||||
def validate_secret(self, secret):
|
||||
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
raise ValidationError(
|
||||
@@ -564,6 +588,24 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
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')):
|
||||
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
|
||||
|
||||
|
||||
@@ -580,6 +622,28 @@ class CompatibleJSONField(serializers.JSONField):
|
||||
return value
|
||||
|
||||
|
||||
class WrappedList:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def all(self):
|
||||
return self._data
|
||||
|
||||
|
||||
class WrappedModel:
|
||||
def __init__(self, model):
|
||||
self._wrapped = model
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self._wrapped, item)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
invoice_address = InvoiceAddressSerializer(required=False)
|
||||
positions = OrderPositionCreateSerializer(many=True, required=True)
|
||||
@@ -600,6 +664,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
force = serializers.BooleanField(default=False, required=False)
|
||||
payment_date = serializers.DateTimeField(required=False, allow_null=True)
|
||||
send_mail = serializers.BooleanField(default=False, required=False)
|
||||
simulate = serializers.BooleanField(default=False, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -609,7 +674,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
model = Order
|
||||
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
|
||||
'force', 'send_mail')
|
||||
'force', 'send_mail', 'simulate')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
if pp is None:
|
||||
@@ -701,6 +766,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
payment_info = validated_data.pop('payment_info', '{}')
|
||||
payment_date = validated_data.pop('payment_date', now())
|
||||
force = validated_data.pop('force', False)
|
||||
simulate = validated_data.pop('simulate', False)
|
||||
self._send_mail = validated_data.pop('send_mail', False)
|
||||
|
||||
if 'invoice_address' in validated_data:
|
||||
@@ -714,7 +780,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
else:
|
||||
ia = None
|
||||
|
||||
with self.context['event'].lock() as now_dt:
|
||||
lockfn = self.context['event'].lock
|
||||
if simulate:
|
||||
lockfn = NoLockManager
|
||||
with lockfn() as now_dt:
|
||||
free_seats = set()
|
||||
seats_seen = set()
|
||||
consume_carts = validated_data.pop('consume_carts', [])
|
||||
@@ -823,7 +892,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
else:
|
||||
pos_data['seat'] = seat
|
||||
if (seat not in free_seats and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat in seats_seen:
|
||||
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
|
||||
errs[i]['seat'] = [gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
|
||||
seats_seen.add(seat)
|
||||
elif seated:
|
||||
errs[i]['seat'] = ['The specified product requires to choose a seat.']
|
||||
@@ -838,7 +907,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if pos_data.get('variation')
|
||||
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
|
||||
if len(new_quotas) == 0:
|
||||
errs[i]['item'] = [ugettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
else:
|
||||
@@ -850,7 +919,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
quota_avail_cache[quota][1] -= 1
|
||||
if quota_avail_cache[quota][1] < 0:
|
||||
errs[i]['item'] = [
|
||||
ugettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
|
||||
gettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
|
||||
quota.name
|
||||
)
|
||||
]
|
||||
@@ -864,11 +933,20 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
||||
order.meta_info = "{}"
|
||||
order.total = Decimal('0.00')
|
||||
order.save()
|
||||
if simulate:
|
||||
order = WrappedModel(order)
|
||||
order.last_modified = now()
|
||||
order.code = 'PREVIEW'
|
||||
else:
|
||||
order.save()
|
||||
|
||||
if ia:
|
||||
ia.order = order
|
||||
ia.save()
|
||||
if not simulate:
|
||||
ia.order = order
|
||||
ia.save()
|
||||
else:
|
||||
order.invoice_address = ia
|
||||
ia.last_modified = now()
|
||||
|
||||
pos_map = {}
|
||||
for pos_data in positions_data:
|
||||
@@ -880,7 +958,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
pos = OrderPosition(**pos_data)
|
||||
pos.order = order
|
||||
if simulate:
|
||||
pos.order = order._wrapped
|
||||
else:
|
||||
pos.order = order
|
||||
if addon_to:
|
||||
pos.addon_to = pos_map[addon_to]
|
||||
|
||||
@@ -911,19 +992,33 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
invoice_address=ia,
|
||||
).gross
|
||||
|
||||
if pos.voucher:
|
||||
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
|
||||
pos.save()
|
||||
if simulate:
|
||||
pos = WrappedModel(pos)
|
||||
pos.id = 0
|
||||
answers = []
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options', [])
|
||||
answ = WrappedModel(QuestionAnswer(**answ_data))
|
||||
answ.options = WrappedList(options)
|
||||
answers.append(answ)
|
||||
pos.answers = answers
|
||||
pos.pseudonymization_id = "PREVIEW"
|
||||
else:
|
||||
if pos.voucher:
|
||||
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
|
||||
pos.save()
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options', [])
|
||||
answ = pos.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
pos_map[pos.positionid] = pos
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options', [])
|
||||
answ = pos.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
|
||||
for cp in delete_cps:
|
||||
cp.delete()
|
||||
if not simulate:
|
||||
for cp in delete_cps:
|
||||
cp.delete()
|
||||
|
||||
order.total = sum([p.price for p in order.positions.all()])
|
||||
order.total = sum([p.price for p in pos_map.values()])
|
||||
fees = []
|
||||
for fee_data in fees_data:
|
||||
is_percentage = fee_data.pop('_treat_value_as_percentage', False)
|
||||
if is_percentage:
|
||||
@@ -955,17 +1050,26 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
fee_data['tax_rule'] = tr
|
||||
fee_data['value'] = val
|
||||
f = OrderFee(**fee_data)
|
||||
f.order = order
|
||||
f.order = order._wrapped if simulate else order
|
||||
f._calculate_tax()
|
||||
f.save()
|
||||
fees.append(f)
|
||||
if not simulate:
|
||||
f.save()
|
||||
else:
|
||||
f = OrderFee(**fee_data)
|
||||
f.order = order
|
||||
f.order = order._wrapped if simulate else order
|
||||
f._calculate_tax()
|
||||
f.save()
|
||||
fees.append(f)
|
||||
if not simulate:
|
||||
f.save()
|
||||
|
||||
order.total += sum([f.value for f in order.fees.all()])
|
||||
order.save(update_fields=['total'])
|
||||
order.total += sum([f.value for f in fees])
|
||||
if simulate:
|
||||
order.fees = fees
|
||||
order.positions = pos_map.values()
|
||||
return order # ignore payments
|
||||
else:
|
||||
order.save(update_fields=['total'])
|
||||
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
|
||||
payment_provider = 'free'
|
||||
@@ -1034,6 +1138,20 @@ class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
'internal_reference')
|
||||
|
||||
|
||||
class OrderPaymentCreateSerializer(I18nAwareModelSerializer):
|
||||
provider = serializers.CharField(required=True, allow_null=False, allow_blank=False)
|
||||
info = CompatibleJSONField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = OrderPayment
|
||||
fields = ('state', 'amount', 'payment_date', 'provider', 'info')
|
||||
|
||||
def create(self, validated_data):
|
||||
order = OrderPayment(order=self.context['order'], **validated_data)
|
||||
order.save()
|
||||
return order
|
||||
|
||||
|
||||
class OrderRefundCreateSerializer(I18nAwareModelSerializer):
|
||||
payment = serializers.IntegerField(required=False, allow_null=True)
|
||||
provider = serializers.CharField(required=True, allow_null=False, allow_blank=False)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import get_language, gettext_lazy as _
|
||||
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.base.models import GiftCard, Organizer, SeatingPlan
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.models import (
|
||||
GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
|
||||
class OrganizerSerializer(I18nAwareModelSerializer):
|
||||
@@ -36,16 +41,129 @@ class GiftCardSerializer(I18nAwareModelSerializer):
|
||||
qs = GiftCard.objects.filter(
|
||||
secret=s
|
||||
).filter(
|
||||
Q(issuer=self.context["organizer"]) | Q(issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
|
||||
Q(issuer=self.context["organizer"]) | Q(
|
||||
issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
|
||||
)
|
||||
if self.instance:
|
||||
qs = qs.exclude(pk=self.instance.pk)
|
||||
if qs.exists():
|
||||
raise ValidationError(
|
||||
{'secret': _('A gift card with the same secret already exists in your or an affiliated organizer account.')}
|
||||
{'secret': _(
|
||||
'A gift card with the same secret already exists in your or an affiliated organizer account.')}
|
||||
)
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = GiftCard
|
||||
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode')
|
||||
|
||||
|
||||
class EventSlugField(serializers.SlugRelatedField):
|
||||
def get_queryset(self):
|
||||
return self.context['organizer'].events.all()
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
limit_events = EventSlugField(slug_field='slug', many=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = (
|
||||
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
||||
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
|
||||
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
||||
'can_change_vouchers'
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
if full_data.get('limit_events') and full_data.get('all_events'):
|
||||
raise ValidationError('Do not set both limit_events and all_events.')
|
||||
return data
|
||||
|
||||
|
||||
class TeamInviteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TeamInvite
|
||||
fields = (
|
||||
'id', 'email'
|
||||
)
|
||||
|
||||
def _send_invite(self, instance):
|
||||
try:
|
||||
mail(
|
||||
instance.email,
|
||||
_('pretix account invitation'),
|
||||
'pretixcontrol/email/invitation.txt',
|
||||
{
|
||||
'user': self,
|
||||
'organizer': self.context['organizer'].name,
|
||||
'team': instance.team.name,
|
||||
'url': build_absolute_uri('control:auth.invite', kwargs={
|
||||
'token': instance.token
|
||||
})
|
||||
},
|
||||
event=None,
|
||||
locale=get_language() # TODO: expose?
|
||||
)
|
||||
except SendMailException:
|
||||
pass # Already logged
|
||||
|
||||
def create(self, validated_data):
|
||||
if 'email' in validated_data:
|
||||
try:
|
||||
user = User.objects.get(email__iexact=validated_data['email'])
|
||||
except User.DoesNotExist:
|
||||
if self.context['team'].invites.filter(email__iexact=validated_data['email']).exists():
|
||||
raise ValidationError(_('This user already has been invited for this team.'))
|
||||
if 'native' not in get_auth_backends():
|
||||
raise ValidationError('Users need to have a pretix account before they can be invited.')
|
||||
|
||||
invite = self.context['team'].invites.create(email=validated_data['email'])
|
||||
self._send_invite(invite)
|
||||
invite.team.log_action(
|
||||
'pretix.team.invite.created',
|
||||
data={
|
||||
'email': validated_data['email']
|
||||
},
|
||||
**self.context['log_kwargs']
|
||||
)
|
||||
return invite
|
||||
else:
|
||||
if self.context['team'].members.filter(pk=user.pk).exists():
|
||||
raise ValidationError(_('This user already has permissions for this team.'))
|
||||
|
||||
self.context['team'].members.add(user)
|
||||
self.context['team'].log_action(
|
||||
'pretix.team.member.added',
|
||||
data={
|
||||
'email': user.email,
|
||||
'user': user.pk,
|
||||
},
|
||||
**self.context['log_kwargs']
|
||||
)
|
||||
return TeamInvite(email=user.email)
|
||||
else:
|
||||
raise ValidationError('No email address given.')
|
||||
|
||||
|
||||
class TeamAPITokenSerializer(serializers.ModelSerializer):
|
||||
active = serializers.BooleanField(default=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TeamAPIToken
|
||||
fields = (
|
||||
'id', 'name', 'active'
|
||||
)
|
||||
|
||||
|
||||
class TeamMemberSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
'id', 'email', 'fullname', 'require_2fa'
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ from rest_framework import routers
|
||||
from pretix.api.views import cart
|
||||
|
||||
from .views import (
|
||||
checkin, device, event, item, oauth, order, organizer, user, voucher,
|
||||
waitinglist, webhooks,
|
||||
checkin, device, event, item, oauth, order, organizer, user, version,
|
||||
voucher, waitinglist, webhooks,
|
||||
)
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
@@ -20,6 +20,12 @@ orga_router.register(r'subevents', event.SubEventViewSet)
|
||||
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
|
||||
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
|
||||
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
|
||||
orga_router.register(r'teams', organizer.TeamViewSet)
|
||||
|
||||
team_router = routers.DefaultRouter()
|
||||
team_router.register(r'members', organizer.TeamMemberViewSet)
|
||||
team_router.register(r'invites', organizer.TeamInviteViewSet)
|
||||
team_router.register(r'tokens', organizer.TeamAPITokenViewSet)
|
||||
|
||||
event_router = routers.DefaultRouter()
|
||||
event_router.register(r'subevents', event.SubEventViewSet)
|
||||
@@ -61,7 +67,10 @@ for app in apps.get_app_configs():
|
||||
urlpatterns = [
|
||||
url(r'^', include(router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/', include(orga_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/settings/$', event.EventSettingsView.as_view(),
|
||||
name="event.settings"),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/teams/(?P<team>[^/]+)/', include(team_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/items/(?P<item>[^/]+)/', include(item_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/questions/(?P<question>[^/]+)/',
|
||||
include(question_router.urls)),
|
||||
@@ -76,4 +85,5 @@ 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"^me$", user.MeView.as_view(), name="user.me"),
|
||||
url(r"^version$", version.VersionView.as_view(), name="version"),
|
||||
]
|
||||
|
||||
@@ -4,13 +4,14 @@ from django.db.models import ProtectedError, Q
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import filters, viewsets
|
||||
from rest_framework import filters, views, viewsets
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.auth.permission import EventCRUDPermission
|
||||
from pretix.api.serializers.event import (
|
||||
CloneEventSerializer, EventSerializer, SubEventSerializer,
|
||||
TaxRuleSerializer,
|
||||
CloneEventSerializer, EventSerializer, EventSettingsSerializer,
|
||||
SubEventSerializer, TaxRuleSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
@@ -333,3 +334,33 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class EventSettingsView(views.APIView):
|
||||
permission = 'can_change_event_settings'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
if 'explain' in request.GET:
|
||||
return Response({
|
||||
fname: {
|
||||
'value': s.data[fname],
|
||||
'label': getattr(field, '_label', fname),
|
||||
'help_text': getattr(field, '_help_text', None)
|
||||
} for fname, field in s.fields.items()
|
||||
})
|
||||
return Response(s.data)
|
||||
|
||||
def patch(self, request, *wargs, **kwargs):
|
||||
s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True,
|
||||
event=request.event)
|
||||
s.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
s.save()
|
||||
self.request.event.log_action(
|
||||
'pretix.event.settings', user=self.request.user, auth=self.request.auth, data={
|
||||
k: v for k, v in s.validated_data.items()
|
||||
}
|
||||
)
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
return Response(s.data)
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from oauth2_provider.exceptions import OAuthToolkitError
|
||||
from oauth2_provider.forms import AllowForm
|
||||
from oauth2_provider.views import (
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.db.models.functions import Coalesce, Concat
|
||||
from django.http import FileResponse, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import mixins, serializers, status, viewsets
|
||||
@@ -23,9 +23,10 @@ from rest_framework.response import Response
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.order import (
|
||||
InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer,
|
||||
OrderPositionSerializer, OrderRefundCreateSerializer,
|
||||
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
|
||||
InvoiceSerializer, OrderCreateSerializer, OrderPaymentCreateSerializer,
|
||||
OrderPaymentSerializer, OrderPositionSerializer,
|
||||
OrderRefundCreateSerializer, OrderRefundSerializer, OrderSerializer,
|
||||
PriceCalcSerializer,
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
@@ -43,7 +44,7 @@ from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, _order_placed_email,
|
||||
_order_placed_email_attendee, approve_order, cancel_order, deny_order,
|
||||
extend_order, mark_order_expired, mark_order_refunded,
|
||||
extend_order, mark_order_expired, mark_order_refunded, reactivate_order,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.tickets import generate
|
||||
@@ -260,6 +261,29 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def reactivate(self, request, **kwargs):
|
||||
|
||||
order = self.get_object()
|
||||
if order.status != Order.STATUS_CANCELED:
|
||||
return Response(
|
||||
{'detail': 'The order is not allowed to be reactivated.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
reactivate_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
|
||||
)
|
||||
except OrderError as e:
|
||||
return Response(
|
||||
{'detail': str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def approve(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
@@ -465,6 +489,9 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
send_mail = serializer._send_mail
|
||||
order = serializer.instance
|
||||
serializer = OrderSerializer(order, context=serializer.context)
|
||||
if not order.pk:
|
||||
# Simulation
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
order.log_action(
|
||||
'pretix.event.order.placed',
|
||||
@@ -825,17 +852,62 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
raise ValidationError(str(e))
|
||||
|
||||
|
||||
class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPaymentSerializer
|
||||
queryset = OrderPayment.objects.none()
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
lookup_field = 'local_id'
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['order'] = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
|
||||
return order.payments.all()
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = OrderPaymentCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
mark_confirmed = False
|
||||
if serializer.validated_data['state'] == OrderPayment.PAYMENT_STATE_CONFIRMED:
|
||||
serializer.validated_data['state'] = OrderPayment.PAYMENT_STATE_PENDING
|
||||
mark_confirmed = True
|
||||
self.perform_create(serializer)
|
||||
r = serializer.instance
|
||||
if mark_confirmed:
|
||||
try:
|
||||
r.confirm(
|
||||
user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth,
|
||||
count_waitinglist=False,
|
||||
force=request.data.get('force', False)
|
||||
)
|
||||
except Quota.QuotaExceededException:
|
||||
pass
|
||||
except SendMailException:
|
||||
pass
|
||||
|
||||
serializer = OrderPaymentSerializer(r, context=serializer.context)
|
||||
|
||||
r.order.log_action(
|
||||
'pretix.event.order.payment.started', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
},
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth
|
||||
)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def confirm(self, request, **kwargs):
|
||||
payment = self.get_object()
|
||||
@@ -1015,6 +1087,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
mark_refunded = request.data.pop('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.pop('mark_canceled', False)
|
||||
mark_pending = request.data.pop('mark_pending', False)
|
||||
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
@@ -1031,11 +1104,23 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
auth=request.auth
|
||||
)
|
||||
if mark_refunded:
|
||||
mark_order_refunded(
|
||||
r.order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=(request.auth if request.auth else None),
|
||||
)
|
||||
try:
|
||||
mark_order_refunded(
|
||||
r.order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=(request.auth if request.auth else None),
|
||||
)
|
||||
except OrderError as e:
|
||||
raise ValidationError(str(e))
|
||||
elif mark_pending:
|
||||
if r.order.status == Order.STATUS_PAID and r.order.pending_sum > 0:
|
||||
r.order.status = Order.STATUS_PENDING
|
||||
r.order.set_expires(
|
||||
now(),
|
||||
r.order.event.subevents.filter(
|
||||
id__in=r.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
r.order.save(update_fields=['status', 'expires'])
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import django_filters
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import filters, serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
|
||||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.organizer import (
|
||||
GiftCardSerializer, OrganizerSerializer, SeatingPlanSerializer,
|
||||
TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer,
|
||||
TeamSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.models import GiftCard, Organizer, SeatingPlan
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
@@ -48,13 +58,14 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
|
||||
write_permission = 'can_change_organizer_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.seating_plans.all()
|
||||
return self.request.organizer.seating_plans.order_by('name')
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
self.request.organizer.log_action(
|
||||
@@ -64,6 +75,7 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
if serializer.instance.events.exists() or serializer.instance.subevents.exists():
|
||||
raise PermissionDenied('This plan can not be changed while it is in use for an event.')
|
||||
@@ -76,6 +88,7 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
return inst
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
if instance.events.exists() or instance.subevents.exists():
|
||||
raise PermissionDenied('This plan can not be deleted while it is in use for an event.')
|
||||
@@ -88,14 +101,29 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
|
||||
instance.delete()
|
||||
|
||||
|
||||
with scopes_disabled():
|
||||
class GiftCardFilter(FilterSet):
|
||||
secret = django_filters.CharFilter(field_name='secret', lookup_expr='iexact')
|
||||
|
||||
class Meta:
|
||||
model = GiftCard
|
||||
fields = ['secret', 'testmode']
|
||||
|
||||
|
||||
class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = GiftCardSerializer
|
||||
queryset = GiftCard.objects.none()
|
||||
permission = 'can_manage_gift_cards'
|
||||
write_permission = 'can_manage_gift_cards'
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = GiftCardFilter
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.issued_gift_cards.all()
|
||||
if self.request.GET.get('include_accepted') == 'true':
|
||||
qs = self.request.organizer.accepted_gift_cards
|
||||
else:
|
||||
qs = self.request.organizer.issued_gift_cards.all()
|
||||
return qs
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
@@ -116,6 +144,8 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
if 'include_accepted' in self.request.GET:
|
||||
raise PermissionDenied("Accepted gift cards cannot be updated, use transact instead.")
|
||||
GiftCard.objects.select_for_update().get(pk=self.get_object().pk)
|
||||
old_value = serializer.instance.value
|
||||
value = serializer.validated_data.pop('value')
|
||||
@@ -138,18 +168,187 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||
request.data.get('value')
|
||||
)
|
||||
text = serializers.CharField(allow_blank=True, allow_null=True).to_internal_value(
|
||||
request.data.get('text', '')
|
||||
)
|
||||
if gc.value + value < Decimal('0.00'):
|
||||
return Response({
|
||||
'value': ['The gift card does not have sufficient credit for this operation.']
|
||||
}, status=status.HTTP_409_CONFLICT)
|
||||
gc.transactions.create(value=value)
|
||||
gc.transactions.create(value=value, text=text)
|
||||
gc.log_action(
|
||||
'pretix.giftcards.transaction.manual',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'value': value}
|
||||
data={'value': value, 'text': text}
|
||||
)
|
||||
return Response(GiftCardSerializer(gc).data, status=status.HTTP_200_OK)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
raise MethodNotAllowed("Gift cards cannot be deleted.")
|
||||
|
||||
|
||||
class TeamViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = TeamSerializer
|
||||
queryset = Team.objects.none()
|
||||
permission = 'can_change_teams'
|
||||
write_permission = 'can_change_teams'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.teams.order_by('pk')
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
inst.log_action(
|
||||
'pretix.team.created',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
inst = serializer.save()
|
||||
inst.log_action(
|
||||
'pretix.team.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
return inst
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.log_action('pretix.team.deleted', user=self.request.user, auth=self.request.auth)
|
||||
instance.delete()
|
||||
|
||||
|
||||
class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = TeamMemberSerializer
|
||||
queryset = User.objects.none()
|
||||
permission = 'can_change_teams'
|
||||
write_permission = 'can_change_teams'
|
||||
|
||||
@cached_property
|
||||
def team(self):
|
||||
return get_object_or_404(self.request.organizer.teams, pk=self.kwargs.get('team'))
|
||||
|
||||
def get_queryset(self):
|
||||
return self.team.members.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
self.team.members.remove(instance)
|
||||
self.team.log_action(
|
||||
'pretix.team.member.removed', user=self.request.user, auth=self.request.auth, data={
|
||||
'email': instance.email,
|
||||
'user': instance.pk
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = TeamInviteSerializer
|
||||
queryset = TeamInvite.objects.none()
|
||||
permission = 'can_change_teams'
|
||||
write_permission = 'can_change_teams'
|
||||
|
||||
@cached_property
|
||||
def team(self):
|
||||
return get_object_or_404(self.request.organizer.teams, pk=self.kwargs.get('team'))
|
||||
|
||||
def get_queryset(self):
|
||||
return self.team.invites.order_by('email')
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
ctx['team'] = self.team
|
||||
ctx['log_kwargs'] = {
|
||||
'user': self.request.user,
|
||||
'auth': self.request.auth,
|
||||
}
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
self.team.log_action(
|
||||
'pretix.team.invite.deleted', user=self.request.user, auth=self.request.auth, data={
|
||||
'email': instance.email,
|
||||
}
|
||||
)
|
||||
instance.delete()
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(team=self.team)
|
||||
|
||||
|
||||
class TeamAPITokenViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = TeamAPITokenSerializer
|
||||
queryset = TeamAPIToken.objects.none()
|
||||
permission = 'can_change_teams'
|
||||
write_permission = 'can_change_teams'
|
||||
|
||||
@cached_property
|
||||
def team(self):
|
||||
return get_object_or_404(self.request.organizer.teams, pk=self.kwargs.get('team'))
|
||||
|
||||
def get_queryset(self):
|
||||
return self.team.tokens.order_by('name')
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
ctx['team'] = self.team
|
||||
ctx['log_kwargs'] = {
|
||||
'user': self.request.user,
|
||||
'auth': self.request.auth,
|
||||
}
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
instance.active = False
|
||||
instance.save()
|
||||
self.team.log_action(
|
||||
'pretix.team.token.deleted', user=self.request.user, auth=self.request.auth, data={
|
||||
'name': instance.name,
|
||||
}
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save(team=self.team)
|
||||
self.team.log_action(
|
||||
'pretix.team.token.created', auth=self.request.auth, user=self.request.user, data={
|
||||
'name': instance.name,
|
||||
'id': instance.pk
|
||||
}
|
||||
)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
d = serializer.data
|
||||
d['token'] = serializer.instance.token
|
||||
return Response(d, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
self.perform_destroy(instance)
|
||||
serializer = self.get_serializer_class()(instance)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK, headers=headers)
|
||||
|
||||
56
src/pretix/api/views/version.py
Normal file
56
src/pretix/api/views/version.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from packaging import version
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from pretix import __version__
|
||||
from pretix.api.auth.device import DeviceTokenAuthentication
|
||||
from pretix.api.auth.token import TeamTokenAuthentication
|
||||
|
||||
|
||||
def numeric_version(v):
|
||||
# Converts a pretix version to a large int
|
||||
# e.g. 30060001000
|
||||
# |--------------------- Major version
|
||||
# |-|------------------ Minor version
|
||||
# |-|--------------- Patch version
|
||||
# ||------------- Stage (10 dev, 20 alpha, 30 beta, 40 rc, 50 release, 60 post)
|
||||
# ||----------- Stage version (number of dev/alpha/beta/rc/post release)
|
||||
v = version.parse(v)
|
||||
phases = {
|
||||
'dev': 10,
|
||||
'a': 20,
|
||||
'b': 30,
|
||||
'rc': 40,
|
||||
'release': 50,
|
||||
'post': 60
|
||||
}
|
||||
vnum = 0
|
||||
|
||||
if v.is_postrelease:
|
||||
vnum += v.post
|
||||
vnum += phases['post'] * 100
|
||||
elif v.dev is not None:
|
||||
vnum += v.dev
|
||||
vnum += phases['dev'] * 100
|
||||
elif v.is_prerelease and v.pre:
|
||||
vnum += v.pre[0]
|
||||
vnum += phases[v.pre[1]] * 100
|
||||
else:
|
||||
vnum += phases['release'] * 100
|
||||
for i, part in enumerate(reversed(v.release)):
|
||||
vnum += part * (1000 ** i) * 10000
|
||||
return vnum
|
||||
|
||||
|
||||
class VersionView(APIView):
|
||||
authentication_classes = (
|
||||
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
|
||||
)
|
||||
|
||||
def get(self, request, format=None):
|
||||
return Response({
|
||||
'pretix': __version__,
|
||||
'pretix_numeric': numeric_version(__version__),
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import requests
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from requests import RequestException
|
||||
|
||||
@@ -125,6 +125,10 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.event.order.canceled',
|
||||
_('Order canceled'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.reactivated',
|
||||
_('Order reactivated'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.expired',
|
||||
_('Order expired'),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class PretixBaseConfig(AppConfig):
|
||||
@@ -14,6 +13,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import notifications # NOQA
|
||||
from . import email # NOQA
|
||||
from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from django.conf import settings
|
||||
|
||||
try:
|
||||
from .celery_app import app as celery_app # NOQA
|
||||
|
||||
@@ -85,6 +85,16 @@ class BaseAuthBackend:
|
||||
"""
|
||||
return
|
||||
|
||||
def get_next_url(self, request):
|
||||
"""
|
||||
This method will be called after a successful login to determine the next URL. Pretix in general uses the
|
||||
``'next'`` query parameter. However, external authentication methods could use custom attributes with hardcoded
|
||||
names for security purposes. For example, OAuth uses ``'state'`` for keeping track of application state.
|
||||
"""
|
||||
if "next" in request.GET:
|
||||
return request.GET.get("next")
|
||||
return None
|
||||
|
||||
|
||||
class NativeAuthBackend(BaseAuthBackend):
|
||||
identifier = 'native'
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.signals import register_sales_channels
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.core.mail.backends.smtp import EmailBackend
|
||||
from django.dispatch import receiver
|
||||
from django.template.loader import get_template
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import get_language, ugettext_lazy as _
|
||||
from django.utils.translation import get_language, gettext_lazy as _
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
from pretix.base.i18n import LazyCurrencyNumber, LazyDate, LazyNumber
|
||||
@@ -136,15 +136,22 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
|
||||
|
||||
class ClassicMailRenderer(TemplateBasedMailRenderer):
|
||||
verbose_name = _('pretix default')
|
||||
verbose_name = _('Default')
|
||||
identifier = 'classic'
|
||||
thumbnail_filename = 'pretixbase/email/thumb.png'
|
||||
template_name = 'pretixbase/email/plainwrapper.html'
|
||||
|
||||
|
||||
class UnembellishedMailRenderer(TemplateBasedMailRenderer):
|
||||
verbose_name = _('Simple with logo')
|
||||
identifier = 'simple_logo'
|
||||
thumbnail_filename = 'pretixbase/email/thumb_simple_logo.png'
|
||||
template_name = 'pretixbase/email/simple_logo.html'
|
||||
|
||||
|
||||
@receiver(register_html_mail_renderers, dispatch_uid="pretixbase_email_renderers")
|
||||
def base_renderers(sender, **kwargs):
|
||||
return [ClassicMailRenderer]
|
||||
return [ClassicMailRenderer, UnembellishedMailRenderer]
|
||||
|
||||
|
||||
class BaseMailTextPlaceholder:
|
||||
@@ -260,6 +267,10 @@ def base_placeholders(sender, **kwargs):
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event', ['event'], lambda event: event.name, lambda event: event.name
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
|
||||
lambda event_or_subevent: event_or_subevent.name
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
|
||||
),
|
||||
@@ -272,6 +283,11 @@ def base_placeholders(sender, **kwargs):
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'refund_amount', ['event_or_subevent', 'refund_amount'],
|
||||
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
|
||||
lambda event_or_subevent: LazyCurrencyNumber(Decimal('42.23'), event_or_subevent.currency)
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
|
||||
event.currency),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import io
|
||||
import tempfile
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
from typing import Tuple
|
||||
|
||||
from defusedcsv import csv
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext, ugettext_lazy as _
|
||||
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
|
||||
|
||||
@@ -117,12 +119,20 @@ class ListExporter(BaseExporter):
|
||||
if output_file:
|
||||
writer = csv.writer(output_file, **kwargs)
|
||||
for line in self.iterate_list(form_data):
|
||||
line = [
|
||||
localize(f) if isinstance(f, Decimal) else f
|
||||
for f in line
|
||||
]
|
||||
writer.writerow(line)
|
||||
return self.get_filename() + '.csv', 'text/csv', None
|
||||
else:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, **kwargs)
|
||||
for line in self.iterate_list(form_data):
|
||||
line = [
|
||||
localize(f) if isinstance(f, Decimal) else f
|
||||
for f in line
|
||||
]
|
||||
writer.writerow(line)
|
||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
@@ -170,9 +180,9 @@ class MultiSheetListExporter(ListExporter):
|
||||
]
|
||||
for s, l in self.sheets:
|
||||
choices += [
|
||||
(s + ':default', str(l) + ' – ' + ugettext('CSV (with commas)')),
|
||||
(s + ':excel', str(l) + ' – ' + ugettext('CSV (Excel-style)')),
|
||||
(s + ':semicolon', str(l) + ' – ' + ugettext('CSV (with semicolons)')),
|
||||
(s + ':default', str(l) + ' – ' + gettext('CSV (with commas)')),
|
||||
(s + ':excel', str(l) + ' – ' + gettext('CSV (Excel-style)')),
|
||||
(s + ':semicolon', str(l) + ' – ' + gettext('CSV (with semicolons)')),
|
||||
]
|
||||
ff = OrderedDict(
|
||||
[
|
||||
@@ -196,12 +206,20 @@ class MultiSheetListExporter(ListExporter):
|
||||
if output_file:
|
||||
writer = csv.writer(output_file, **kwargs)
|
||||
for line in self.iterate_sheet(form_data, sheet):
|
||||
line = [
|
||||
localize(f) if isinstance(f, Decimal) else f
|
||||
for f in line
|
||||
]
|
||||
writer.writerow(line)
|
||||
return self.get_filename() + '.csv', 'text/csv', None
|
||||
else:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, **kwargs)
|
||||
for line in self.iterate_sheet(form_data, sheet):
|
||||
line = [
|
||||
localize(f) if isinstance(f, Decimal) else f
|
||||
for f in line
|
||||
]
|
||||
writer.writerow(line)
|
||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from zipfile import ZipFile
|
||||
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import QuestionAnswer
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import dateutil
|
||||
from django import forms
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext, ugettext_lazy
|
||||
from django.utils.translation import gettext, gettext_lazy
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Invoice, OrderPayment
|
||||
@@ -79,7 +79,7 @@ class DekodiNREIExporter(BaseExporter):
|
||||
payments.append({
|
||||
'PTID': '5',
|
||||
'PTN': 'Lastschrift',
|
||||
'PTNo4': ugettext('Event ticket {event}-{code}').format(
|
||||
'PTNo4': gettext('Event ticket {event}-{code}').format(
|
||||
event=self.event.slug.upper(),
|
||||
code=invoice.order.code
|
||||
),
|
||||
@@ -199,19 +199,19 @@ class DekodiNREIExporter(BaseExporter):
|
||||
[
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=ugettext_lazy('Start date'),
|
||||
label=gettext_lazy('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=ugettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does '
|
||||
'not always correspond to the order or payment date.')
|
||||
help_text=gettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does '
|
||||
'not always correspond to the order or payment date.')
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=ugettext_lazy('End date'),
|
||||
label=gettext_lazy('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=ugettext_lazy('Only include invoices issued on or before this date. Note that the invoice date '
|
||||
'does not always correspond to the order or payment date.')
|
||||
help_text=gettext_lazy('Only include invoices issued on or before this date. Note that the invoice date '
|
||||
'does not always correspond to the order or payment date.')
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import dateutil.parser
|
||||
from django import forms
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import OrderPayment
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ from decimal import Decimal
|
||||
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum
|
||||
from django.db.models import (
|
||||
Count, DateTimeField, F, IntegerField, Max, OuterRef, Subquery, Sum,
|
||||
)
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import pgettext, ugettext as _, ugettext_lazy
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext
|
||||
|
||||
from pretix.base.models import (
|
||||
InvoiceAddress, InvoiceLine, Order, OrderPosition, Question,
|
||||
GiftCard, InvoiceAddress, InvoiceLine, Order, OrderPosition, Question,
|
||||
)
|
||||
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
@@ -20,7 +22,7 @@ from ..signals import register_data_exporters
|
||||
|
||||
class OrderListExporter(MultiSheetListExporter):
|
||||
identifier = 'orderlist'
|
||||
verbose_name = ugettext_lazy('Order data')
|
||||
verbose_name = gettext_lazy('Order data')
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
@@ -80,8 +82,12 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
'm'
|
||||
).order_by()
|
||||
|
||||
s = OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||
qs = self.event.orders.annotate(
|
||||
payment_date=Subquery(p_date, output_field=DateTimeField())
|
||||
payment_date=Subquery(p_date, output_field=DateTimeField()),
|
||||
pcnt=Subquery(s, output_field=IntegerField())
|
||||
).select_related('invoice_address').prefetch_related('invoices')
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(status=Order.STATUS_PAID)
|
||||
@@ -111,6 +117,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Sales channel'))
|
||||
headers.append(_('Requires special attention'))
|
||||
headers.append(_('Comment'))
|
||||
headers.append(_('Positions'))
|
||||
|
||||
yield headers
|
||||
|
||||
@@ -134,7 +141,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
for order in qs.order_by('datetime'):
|
||||
row = [
|
||||
order.code,
|
||||
localize(order.total),
|
||||
order.total,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
@@ -163,7 +170,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
row += [
|
||||
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
||||
localize(full_fee_sum_cache.get(order.id) or Decimal('0.00')),
|
||||
full_fee_sum_cache.get(order.id) or Decimal('0.00'),
|
||||
order.locale,
|
||||
]
|
||||
|
||||
@@ -173,16 +180,19 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
{'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
|
||||
|
||||
row += [
|
||||
localize(taxrate_values['grosssum'] + fee_taxrate_values['grosssum']),
|
||||
localize(taxrate_values['grosssum'] - taxrate_values['taxsum']
|
||||
+ fee_taxrate_values['grosssum'] - fee_taxrate_values['taxsum']),
|
||||
localize(taxrate_values['taxsum'] + fee_taxrate_values['taxsum']),
|
||||
taxrate_values['grosssum'] + fee_taxrate_values['grosssum'],
|
||||
(
|
||||
taxrate_values['grosssum'] - taxrate_values['taxsum'] +
|
||||
fee_taxrate_values['grosssum'] - fee_taxrate_values['taxsum']
|
||||
),
|
||||
taxrate_values['taxsum'] + fee_taxrate_values['taxsum'],
|
||||
]
|
||||
|
||||
row.append(', '.join([i.number for i in order.invoices.all()]))
|
||||
row.append(order.sales_channel)
|
||||
row.append(_('Yes') if order.checkin_attention else _('No'))
|
||||
row.append(order.comment or "")
|
||||
row.append(order.pcnt)
|
||||
yield row
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
@@ -264,7 +274,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
'order', 'order__invoice_address', 'item', 'variation',
|
||||
'voucher', 'tax_rule'
|
||||
).prefetch_related(
|
||||
'answers', 'answers__question'
|
||||
'answers', 'answers__question', 'answers__options'
|
||||
)
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
@@ -295,12 +305,25 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Attendee name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Attendee email'),
|
||||
_('Company'),
|
||||
_('Address'),
|
||||
_('ZIP code'),
|
||||
_('City'),
|
||||
_('Country'),
|
||||
pgettext('address', 'State'),
|
||||
_('Voucher'),
|
||||
_('Pseudonymization ID'),
|
||||
]
|
||||
questions = list(self.event.questions.all())
|
||||
options = {}
|
||||
for q in questions:
|
||||
headers.append(str(q.question))
|
||||
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)
|
||||
else:
|
||||
headers.append(str(q.question))
|
||||
headers += [
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
@@ -347,6 +370,12 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
)
|
||||
row += [
|
||||
op.attendee_email,
|
||||
op.company or '',
|
||||
op.street or '',
|
||||
op.zipcode or '',
|
||||
op.city or '',
|
||||
op.country if op.country else '',
|
||||
op.state or '',
|
||||
op.voucher.code if op.voucher else '',
|
||||
op.pseudonymization_id,
|
||||
]
|
||||
@@ -354,12 +383,19 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
for a in op.answers.all():
|
||||
# We do not want to localize Date, Time and Datetime question answers, as those can lead
|
||||
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
|
||||
if a.question.type in Question.UNLOCALIZED_TYPES:
|
||||
if a.question.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
acache[a.question_id] = set(o.pk for o in a.options.all())
|
||||
elif a.question.type in Question.UNLOCALIZED_TYPES:
|
||||
acache[a.question_id] = a.answer
|
||||
else:
|
||||
acache[a.question_id] = str(a)
|
||||
for q in questions:
|
||||
row.append(acache.get(q.pk, ''))
|
||||
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'))
|
||||
else:
|
||||
row.append(acache.get(q.pk, ''))
|
||||
|
||||
try:
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
@@ -390,7 +426,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
class PaymentListExporter(ListExporter):
|
||||
identifier = 'paymentlist'
|
||||
verbose_name = ugettext_lazy('Order payments and refunds')
|
||||
verbose_name = gettext_lazy('Order payments and refunds')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
@@ -450,7 +486,7 @@ class PaymentListExporter(ListExporter):
|
||||
d2,
|
||||
obj.get_state_display(),
|
||||
obj.state,
|
||||
localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)),
|
||||
obj.amount * (-1 if isinstance(obj, OrderRefund) else 1),
|
||||
provider_names.get(obj.provider, obj.provider)
|
||||
]
|
||||
yield row
|
||||
@@ -461,7 +497,7 @@ class PaymentListExporter(ListExporter):
|
||||
|
||||
class QuotaListExporter(ListExporter):
|
||||
identifier = 'quotalist'
|
||||
verbose_name = ugettext_lazy('Quota availabilities')
|
||||
verbose_name = gettext_lazy('Quota availabilities')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
headers = [
|
||||
@@ -490,7 +526,7 @@ class QuotaListExporter(ListExporter):
|
||||
|
||||
class InvoiceDataExporter(MultiSheetListExporter):
|
||||
identifier = 'invoicedata'
|
||||
verbose_name = ugettext_lazy('Invoice data')
|
||||
verbose_name = gettext_lazy('Invoice data')
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
@@ -531,10 +567,11 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
_('Foreign currency rate'),
|
||||
_('Total value (with taxes)'),
|
||||
_('Total value (without taxes)'),
|
||||
_('Payment matching IDs'),
|
||||
]
|
||||
qs = self.event.invoices.order_by('full_invoice_no').select_related(
|
||||
'order', 'refers'
|
||||
).annotate(
|
||||
).prefetch_related('order__payments').annotate(
|
||||
total_gross=Subquery(
|
||||
InvoiceLine.objects.filter(
|
||||
invoice=OuterRef('pk')
|
||||
@@ -551,6 +588,16 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
)
|
||||
)
|
||||
for i in qs:
|
||||
pmis = []
|
||||
for p in i.order.payments.all():
|
||||
if p.state in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_CREATED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_REFUNDED):
|
||||
pprov = p.payment_provider
|
||||
if pprov:
|
||||
mid = pprov.matching_id(p)
|
||||
if mid:
|
||||
pmis.append(mid)
|
||||
pmi = '\n'.join(pmis)
|
||||
yield [
|
||||
i.full_invoice_no,
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
@@ -581,6 +628,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
i.foreign_currency_rate,
|
||||
i.total_gross if i.total_gross else Decimal('0.00'),
|
||||
Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')),
|
||||
pmi
|
||||
]
|
||||
elif sheet == 'lines':
|
||||
yield [
|
||||
@@ -662,6 +710,45 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
return '{}_invoices'.format(self.event.slug)
|
||||
|
||||
|
||||
class GiftcardRedemptionListExporter(ListExporter):
|
||||
identifier = 'giftcardredemptionlist'
|
||||
verbose_name = gettext_lazy('Giftcard Redemptions')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
payments = OrderPayment.objects.filter(
|
||||
order__event=self.event,
|
||||
provider='giftcard'
|
||||
).order_by('created')
|
||||
refunds = OrderRefund.objects.filter(
|
||||
order__event=self.event,
|
||||
provider='giftcard'
|
||||
).order_by('created')
|
||||
|
||||
objs = sorted(list(payments) + list(refunds), key=lambda o: (o.order.code, o.created))
|
||||
|
||||
headers = [
|
||||
_('Order'), _('Payment ID'), _('Date'), _('Gift card code'), _('Amount'), _('Issuer')
|
||||
]
|
||||
yield headers
|
||||
|
||||
for obj in objs:
|
||||
gc = GiftCard.objects.get(pk=obj.info_data.get('gift_card'))
|
||||
row = [
|
||||
obj.order.code,
|
||||
obj.full_id,
|
||||
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
gc.secret,
|
||||
obj.amount * (-1 if isinstance(obj, OrderRefund) else 1),
|
||||
gc.issuer
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_giftcardredemptions'.format(self.event.slug)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
|
||||
def register_orderlist_exporter(sender, **kwargs):
|
||||
return OrderListExporter
|
||||
@@ -680,3 +767,8 @@ def register_quotalist_exporter(sender, **kwargs):
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_invoicedata")
|
||||
def register_invoicedata_exporter(sender, **kwargs):
|
||||
return InvoiceDataExporter
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_giftcardredemptionlist")
|
||||
def register_giftcardredemptionlist_exporter(sender, **kwargs):
|
||||
return GiftcardRedemptionListExporter
|
||||
|
||||
@@ -3,12 +3,10 @@ import logging
|
||||
import i18nfield.forms
|
||||
from django import forms
|
||||
from django.forms.models import ModelFormMetaclass
|
||||
from django.utils import six
|
||||
from django.utils.crypto import get_random_string
|
||||
from formtools.wizard.views import SessionWizardView
|
||||
from hierarkey.forms import HierarkeyForm
|
||||
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||
|
||||
from .validators import PlaceholderValidator # NOQA
|
||||
@@ -26,7 +24,7 @@ class BaseI18nModelForm(i18nfield.forms.BaseI18nModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class I18nModelForm(six.with_metaclass(ModelFormMetaclass, BaseI18nModelForm)):
|
||||
class I18nModelForm(BaseI18nModelForm, metaclass=ModelFormMetaclass):
|
||||
pass
|
||||
|
||||
|
||||
@@ -51,19 +49,33 @@ class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
|
||||
|
||||
|
||||
class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
auto_fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
from pretix.base.settings import DEFAULTS
|
||||
|
||||
self.obj = kwargs.get('obj', None)
|
||||
self.locales = self.obj.settings.get('locales') if self.obj else kwargs.pop('locales', None)
|
||||
kwargs['attribute_name'] = 'settings'
|
||||
kwargs['locales'] = self.locales
|
||||
kwargs['initial'] = self.obj.settings.freeze()
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.auto_fields:
|
||||
kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
||||
kwargs.setdefault('required', False)
|
||||
field = DEFAULTS[fname]['form_class'](
|
||||
**kwargs
|
||||
)
|
||||
if isinstance(field, i18nfield.forms.I18nFormField):
|
||||
field.widget.enabled_locales = self.locales
|
||||
self.fields[fname] = field
|
||||
for k, f in self.fields.items():
|
||||
if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
|
||||
f.set_event(self.obj)
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
from pretix.base.models import Event
|
||||
|
||||
nonce = get_random_string(length=8)
|
||||
if isinstance(self.obj, Event):
|
||||
fname = '%s/%s/%s.%s.%s' % (
|
||||
|
||||
@@ -3,9 +3,10 @@ from django.conf import settings
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
)
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import User
|
||||
from pretix.helpers.dicts import move_to_end
|
||||
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
@@ -36,7 +37,7 @@ class LoginForm(forms.Form):
|
||||
if not settings.PRETIX_LONG_SESSIONS or backend.url:
|
||||
del self.fields['keep_logged_in']
|
||||
else:
|
||||
self.fields.move_to_end('keep_logged_in')
|
||||
move_to_end(self.fields, 'keep_logged_in')
|
||||
|
||||
def clean(self):
|
||||
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
|
||||
|
||||
@@ -18,7 +18,7 @@ from django.forms import Select
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import (
|
||||
get_language, pgettext_lazy, ugettext_lazy as _,
|
||||
get_language, gettext_lazy as _, pgettext_lazy,
|
||||
)
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country, CountryField
|
||||
@@ -41,6 +41,7 @@ from pretix.base.settings import (
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.control.forms import SplitDateTimeField
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.escapejson import escapejson_attr
|
||||
from pretix.helpers.i18n import get_format_without_seconds
|
||||
from pretix.presale.signals import question_form_fields
|
||||
@@ -214,6 +215,10 @@ def guess_country(event):
|
||||
return country
|
||||
|
||||
|
||||
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
|
||||
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
|
||||
|
||||
|
||||
class BaseQuestionsForm(forms.Form):
|
||||
"""
|
||||
This form class is responsible for asking order-related questions. This includes
|
||||
@@ -241,7 +246,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
if item.admission and event.settings.attendee_names_asked:
|
||||
self.fields['attendee_name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.attendee_names_required,
|
||||
required=event.settings.attendee_names_required and not self.all_optional,
|
||||
scheme=event.settings.name_scheme,
|
||||
titles=event.settings.name_scheme_titles,
|
||||
label=_('Attendee name'),
|
||||
@@ -249,7 +254,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
)
|
||||
if item.admission and event.settings.attendee_emails_asked:
|
||||
self.fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required,
|
||||
required=event.settings.attendee_emails_required and not self.all_optional,
|
||||
label=_('Attendee email'),
|
||||
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
|
||||
widget=forms.EmailInput(
|
||||
@@ -258,6 +263,75 @@ class BaseQuestionsForm(forms.Form):
|
||||
}
|
||||
)
|
||||
)
|
||||
if item.admission and event.settings.attendee_company_asked:
|
||||
self.fields['company'] = forms.CharField(
|
||||
required=event.settings.attendee_company_required and not self.all_optional,
|
||||
label=_('Company'),
|
||||
initial=(cartpos.company if cartpos else orderpos.company),
|
||||
)
|
||||
|
||||
if item.admission and event.settings.attendee_addresses_asked:
|
||||
self.fields['street'] = forms.CharField(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('Address'),
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
'placeholder': _('Street and Number'),
|
||||
'autocomplete': 'street-address'
|
||||
}),
|
||||
initial=(cartpos.street if cartpos else orderpos.street),
|
||||
)
|
||||
self.fields['zipcode'] = forms.CharField(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('ZIP code'),
|
||||
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'postal-code',
|
||||
}),
|
||||
)
|
||||
self.fields['city'] = forms.CharField(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('City'),
|
||||
initial=(cartpos.city if cartpos else orderpos.city),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
)
|
||||
country = (cartpos.country if cartpos else orderpos.country) or guess_country(event)
|
||||
self.fields['country'] = CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('Country'),
|
||||
initial=country,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'country',
|
||||
}),
|
||||
)
|
||||
c = [('', pgettext_lazy('address', 'Select state'))]
|
||||
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
|
||||
cc = None
|
||||
if fprefix + 'country' in self.data:
|
||||
cc = str(self.data[fprefix + 'country'])
|
||||
elif country:
|
||||
cc = str(country)
|
||||
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
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])
|
||||
elif fprefix + 'state' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
self.fields['state'] = forms.ChoiceField(
|
||||
label=pgettext_lazy('address', 'State'),
|
||||
required=False,
|
||||
choices=c,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'address-level1',
|
||||
}),
|
||||
)
|
||||
self.fields['state'].widget.is_required = True
|
||||
|
||||
for q in questions:
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
@@ -309,12 +383,14 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField().formfield(
|
||||
field = CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label='',
|
||||
initial=initial.answer if initial else None,
|
||||
initial=initial.answer if initial else guess_country(event),
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
@@ -332,7 +408,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
to_field_name='identifier',
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
widget=QuestionCheckboxSelectMultiple,
|
||||
initial=initial.options.all() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_FILE:
|
||||
@@ -419,6 +495,10 @@ class BaseQuestionsForm(forms.Form):
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
|
||||
if d.get('city') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
if not d.get('state'):
|
||||
self.add_error('state', _('This field is required.'))
|
||||
|
||||
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
|
||||
|
||||
def question_is_visible(parentid, qvals):
|
||||
@@ -457,7 +537,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'state',
|
||||
'vat_id', 'internal_reference', 'beneficiary')
|
||||
'vat_id', 'internal_reference', 'beneficiary', 'custom_field')
|
||||
widgets = {
|
||||
'is_business': BusinessBooleanRadio,
|
||||
'street': forms.Textarea(attrs={
|
||||
@@ -500,6 +580,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if not event.settings.invoice_address_vatid:
|
||||
del self.fields['vat_id']
|
||||
|
||||
self.fields['country'].choices = CachedCountries()
|
||||
|
||||
c = [('', pgettext_lazy('address', 'Select state'))]
|
||||
fprefix = self.prefix + '-' if self.prefix else ''
|
||||
cc = None
|
||||
@@ -561,6 +643,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if not event.settings.invoice_address_beneficiary:
|
||||
del self.fields['beneficiary']
|
||||
|
||||
if event.settings.invoice_address_custom_field:
|
||||
self.fields['custom_field'].label = event.settings.invoice_address_custom_field
|
||||
else:
|
||||
del self.fields['custom_field']
|
||||
|
||||
for k, v in self.fields.items():
|
||||
if v.widget.attrs.get('autocomplete') or k == 'name_parts':
|
||||
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '')
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
)
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from pytz import common_timezones
|
||||
|
||||
from pretix.base.models import User
|
||||
|
||||
@@ -2,7 +2,7 @@ import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import BaseValidator
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,7 @@ from django import forms
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class DatePickerWidget(forms.DateInput):
|
||||
@@ -71,6 +68,9 @@ class UploadedFileWidget(forms.ClearableFileInput):
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
from pretix.base.models import OrderPosition
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
|
||||
if isinstance(self.position, OrderPosition):
|
||||
return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={
|
||||
'order': self.position.order.code,
|
||||
|
||||
@@ -3,7 +3,7 @@ from contextlib import contextmanager
|
||||
from django.conf import settings
|
||||
from django.utils import translation
|
||||
from django.utils.formats import date_format, number_format
|
||||
from django.utils.translation import ugettext
|
||||
from django.utils.translation import gettext
|
||||
from i18nfield.fields import ( # noqa
|
||||
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
|
||||
)
|
||||
@@ -69,6 +69,6 @@ class LazyLocaleException(Exception):
|
||||
|
||||
def __str__(self):
|
||||
if self.msgargs:
|
||||
return ugettext(self.msg) % self.msgargs
|
||||
return gettext(self.msg) % self.msgargs
|
||||
else:
|
||||
return ugettext(self.msg)
|
||||
return gettext(self.msg)
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.contrib.staticfiles import finders
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import (
|
||||
get_language, pgettext, ugettext, ugettext_lazy,
|
||||
get_language, gettext, gettext_lazy, pgettext,
|
||||
)
|
||||
from PIL.Image import BICUBIC
|
||||
from reportlab.lib import pagesizes
|
||||
@@ -28,7 +28,7 @@ from reportlab.platypus import (
|
||||
)
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import Event, Invoice
|
||||
from pretix.base.models import Event, Invoice, Order
|
||||
from pretix.base.signals import register_invoice_renderers
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
|
||||
@@ -264,7 +264,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
invoice_to_top = 52 * mm
|
||||
|
||||
def _draw_invoice_to(self, canvas):
|
||||
p = Paragraph(self.invoice.address_invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p = Paragraph(bleach.clean(self.invoice.address_invoice_to, tags=[]).strip().replace('\n', '<br />\n'),
|
||||
style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
|
||||
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
|
||||
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
|
||||
@@ -422,7 +423,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSansBd', 30)
|
||||
canvas.setFillColorRGB(32, 0, 0)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, ugettext('TEST MODE'))
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, gettext('TEST MODE'))
|
||||
canvas.restoreState()
|
||||
|
||||
def _on_first_page(self, canvas: Canvas, doc):
|
||||
@@ -459,6 +460,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
def _get_intro(self):
|
||||
story = []
|
||||
if self.invoice.custom_field:
|
||||
story.append(Paragraph(
|
||||
'{}: {}'.format(self.invoice.event.settings.invoice_address_custom_field, self.invoice.custom_field),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.internal_reference:
|
||||
story.append(Paragraph(
|
||||
pgettext('invoice', 'Customer reference: {reference}').format(reference=self.invoice.internal_reference),
|
||||
@@ -559,6 +566,20 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.65, .05, .30)]
|
||||
|
||||
if self.invoice.event.settings.invoice_show_payments and not self.invoice.is_cancellation and \
|
||||
self.invoice.order.status == Order.STATUS_PENDING:
|
||||
pending_sum = self.invoice.order.pending_sum
|
||||
if pending_sum != total:
|
||||
tdata.append([pgettext('invoice', 'Received payments')] + (['', '', ''] if has_taxes else ['']) + [
|
||||
money_filter(pending_sum - total, self.invoice.event.currency)
|
||||
])
|
||||
tdata.append([pgettext('invoice', 'Outstanding payments')] + (['', '', ''] if has_taxes else ['']) + [
|
||||
money_filter(pending_sum, self.invoice.event.currency)
|
||||
])
|
||||
tstyledata += [
|
||||
('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold),
|
||||
]
|
||||
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=1)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
story.append(table)
|
||||
@@ -667,7 +688,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
class Modern1Renderer(ClassicInvoiceRenderer):
|
||||
identifier = 'modern1'
|
||||
verbose_name = ugettext_lazy('Modern Invoice Renderer (pretix 2.7)')
|
||||
verbose_name = gettext_lazy('Modern Invoice Renderer (pretix 2.7)')
|
||||
bottom_margin = 16.9 * mm
|
||||
top_margin = 16.9 * mm
|
||||
right_margin = 20 * mm
|
||||
|
||||
@@ -13,11 +13,17 @@ class Command(BaseCommand):
|
||||
return parser
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
from django_extensions.management.commands import shell_plus # noqa
|
||||
cmd = 'shell_plus'
|
||||
except ImportError:
|
||||
cmd = 'shell'
|
||||
|
||||
parser = self.create_parser(sys.argv[0], sys.argv[1])
|
||||
flags = parser.parse_known_args(sys.argv[2:])[1]
|
||||
if "--override" in flags:
|
||||
with scopes_disabled():
|
||||
return call_command("shell_plus", *args, **options)
|
||||
return call_command(cmd, *args, **options)
|
||||
|
||||
lookups = {}
|
||||
for flag in flags:
|
||||
@@ -36,4 +42,4 @@ class Command(BaseCommand):
|
||||
for app_name, app_value in lookups.items()
|
||||
}
|
||||
with scope(**scope_options):
|
||||
return call_command("shell_plus", *args, **options)
|
||||
return call_command(cmd, *args, **options)
|
||||
|
||||
@@ -15,7 +15,9 @@ from django.utils.translation.trans_real import (
|
||||
)
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.multidomain.urlreverse import get_domain
|
||||
from pretix.multidomain.urlreverse import (
|
||||
get_event_domain, get_organizer_domain,
|
||||
)
|
||||
|
||||
_supported = None
|
||||
|
||||
@@ -231,7 +233,10 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
dynamicdomain += " " + settings.SITE_URL
|
||||
|
||||
if hasattr(request, 'organizer') and request.organizer:
|
||||
domain = get_domain(request.organizer)
|
||||
if hasattr(request, 'event') and request.event:
|
||||
domain = get_event_domain(request.event, fallback=True)
|
||||
else:
|
||||
domain = get_organizer_domain(request.organizer)
|
||||
if domain:
|
||||
siteurlsplit = urlsplit(settings.SITE_URL)
|
||||
if siteurlsplit.port and siteurlsplit.port not in (80, 443):
|
||||
|
||||
@@ -6,7 +6,7 @@ import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
import pretix.base.validators
|
||||
from pretix.base.i18n import language
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.db import migrations, models
|
||||
from django.db.models import F
|
||||
from django.db.models.functions import Concat
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
import pretix.base.models.auth
|
||||
import pretix.base.validators
|
||||
|
||||
28
src/pretix/base/migrations/0143_auto_20200217_1211.py
Normal file
28
src/pretix/base/migrations/0143_auto_20200217_1211.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 2.2.4 on 2020-02-17 12:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0142_auto_20191215_1522'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='seat',
|
||||
name='row_label',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='seat',
|
||||
name='seat_label',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='giftcard',
|
||||
name='secret',
|
||||
field=models.CharField(db_index=True, max_length=190),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-18 08:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0143_auto_20200217_1211'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='custom_field',
|
||||
field=models.CharField(max_length=255, null=True, blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='custom_field',
|
||||
field=models.CharField(max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
42
src/pretix/base/migrations/0145_auto_20200210_1038.py
Normal file
42
src/pretix/base/migrations/0145_auto_20200210_1038.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 2.2.8 on 2020-02-10 10:38
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0144_invoiceaddress_custom_field'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ItemMetaProperty',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(db_index=True, max_length=50)),
|
||||
('default', models.TextField()),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_meta_properties', to='pretixbase.Event')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ItemMetaValue',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('value', models.TextField()),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meta_values', to='pretixbase.Item')),
|
||||
('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_values', to='pretixbase.ItemMetaProperty')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('item', 'property')},
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0146_giftcardtransaction_text.py
Normal file
18
src/pretix/base/migrations/0146_giftcardtransaction_text.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.4 on 2020-03-02 11:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0145_auto_20200210_1038'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='giftcardtransaction',
|
||||
name='text',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
]
|
||||
20
src/pretix/base/migrations/0147_user_session_token.py
Normal file
20
src/pretix/base/migrations/0147_user_session_token.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 2.2.9 on 2020-03-21 15:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.auth
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0146_giftcardtransaction_text'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='session_token',
|
||||
field=models.CharField(default=pretix.base.models.auth.generate_session_token, max_length=32),
|
||||
),
|
||||
]
|
||||
24
src/pretix/base/migrations/0148_cancellationrequest.py
Normal file
24
src/pretix/base/migrations/0148_cancellationrequest.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.0.4 on 2020-03-25 10:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0147_user_session_token'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CancellationRequest',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('cancellation_fee', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('refund_as_giftcard', models.BooleanField(default=False)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cancellation_requests', to='pretixbase.Order')),
|
||||
],
|
||||
),
|
||||
]
|
||||
46
src/pretix/base/migrations/0149_order_cancellation_date.py
Normal file
46
src/pretix/base/migrations/0149_order_cancellation_date.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 3.0.4 on 2020-03-25 14:40
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Count, OuterRef, Q, Subquery
|
||||
from django.utils.timezone import now
|
||||
|
||||
|
||||
def fill_cancellation_date(apps, schema_editor):
|
||||
Order = apps.get_model('pretixbase', 'Order')
|
||||
LogEntry = apps.get_model('pretixbase', 'LogEntry')
|
||||
OrderPosition = apps.get_model('pretixbase', 'OrderPosition')
|
||||
|
||||
s = OrderPosition.all.filter(
|
||||
order=OuterRef('pk'),
|
||||
canceled=False,
|
||||
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||
for o in Order.objects.annotate(
|
||||
pcnt=Subquery(s)
|
||||
).filter(
|
||||
Q(pcnt=0) | Q(pcnt__isnull=True) | Q(status="c")
|
||||
).values('id').iterator():
|
||||
le = LogEntry.objects.filter(
|
||||
content_type__model="order",
|
||||
object_id=o['id'],
|
||||
action_type='pretix.event.order.canceled'
|
||||
).order_by('-datetime').only('datetime').first()
|
||||
if le:
|
||||
Order.objects.filter(pk=o['id']).update(
|
||||
cancellation_date=le.datetime,
|
||||
last_modified=now()
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0148_cancellationrequest'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='cancellation_date',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.RunPython(fill_cancellation_date, migrations.RunPython.noop)
|
||||
]
|
||||
74
src/pretix/base/migrations/0150_auto_20200401_1123.py
Normal file
74
src/pretix/base/migrations/0150_auto_20200401_1123.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# Generated by Django 3.0.4 on 2020-04-01 11:24
|
||||
|
||||
import django_countries.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0149_order_cancellation_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='city',
|
||||
field=models.CharField(max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='company',
|
||||
field=models.CharField(max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='country',
|
||||
field=django_countries.fields.CountryField(max_length=2, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='state',
|
||||
field=models.CharField(max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='street',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='zipcode',
|
||||
field=models.CharField(max_length=30, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='city',
|
||||
field=models.CharField(max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='company',
|
||||
field=models.CharField(max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='country',
|
||||
field=django_countries.fields.CountryField(max_length=2, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='state',
|
||||
field=models.CharField(max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='street',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='zipcode',
|
||||
field=models.CharField(max_length=30, null=True),
|
||||
),
|
||||
]
|
||||
@@ -10,9 +10,9 @@ from .event import (
|
||||
from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question,
|
||||
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
|
||||
itempicture_upload_to,
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
|
||||
ItemVariation, Question, QuestionOption, Quota, SubEventItem,
|
||||
SubEventItemVariation, itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .notifications import NotificationSetting
|
||||
|
||||
@@ -12,9 +12,9 @@ from django.contrib.auth.tokens import default_token_generator
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
from django_scopes import scopes_disabled
|
||||
from u2flib_server.utils import (
|
||||
@@ -54,6 +54,10 @@ def generate_notifications_token():
|
||||
return get_random_string(length=32)
|
||||
|
||||
|
||||
def generate_session_token():
|
||||
return get_random_string(length=32)
|
||||
|
||||
|
||||
class SuperuserPermissionSet:
|
||||
def __contains__(self, item):
|
||||
return True
|
||||
@@ -110,6 +114,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
)
|
||||
notifications_token = models.CharField(max_length=255, default=generate_notifications_token)
|
||||
auth_backend = models.CharField(max_length=255, default='native')
|
||||
session_token = models.CharField(max_length=32, default=generate_session_token)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
@@ -382,6 +387,20 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
self._staff_session_cache[session_key] = sess
|
||||
return self._staff_session_cache[session_key]
|
||||
|
||||
def get_session_auth_hash(self):
|
||||
"""
|
||||
Return an HMAC that needs to
|
||||
"""
|
||||
key_salt = "pretix.base.models.User.get_session_auth_hash"
|
||||
payload = self.password
|
||||
payload += self.email
|
||||
payload += self.session_token
|
||||
return salted_hmac(key_salt, payload).hexdigest()
|
||||
|
||||
def update_session_token(self):
|
||||
self.session_token = generate_session_token()
|
||||
self.save(update_fields=['session_token'])
|
||||
|
||||
|
||||
class StaffSession(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
@@ -3,7 +3,7 @@ import string
|
||||
from django.db import models
|
||||
from django.db.models import Max
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
@@ -94,6 +94,7 @@ class Device(LoggedModel):
|
||||
return {
|
||||
'can_view_orders',
|
||||
'can_change_orders',
|
||||
'can_manage_gift_cards'
|
||||
}
|
||||
|
||||
def get_event_permission_set(self, organizer, event) -> set:
|
||||
|
||||
@@ -15,9 +15,10 @@ from django.db import models
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.template.defaultfilters import date as _date
|
||||
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 ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
@@ -293,7 +294,7 @@ class Event(EventMixin, LoggedModel):
|
||||
"This will be used in URLs, order codes, invoice numbers, and bank transfer references."),
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex="^[a-zA-Z0-9.-]+$",
|
||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*$",
|
||||
message=_("The slug may only contain letters, numbers, dots and dashes."),
|
||||
),
|
||||
EventSlugBanlistValidator()
|
||||
@@ -370,6 +371,8 @@ class Event(EventMixin, LoggedModel):
|
||||
"""
|
||||
self.settings.invoice_renderer = 'modern1'
|
||||
self.settings.invoice_include_expire_date = True
|
||||
self.settings.ticketoutput_pdf__enabled = True
|
||||
self.settings.ticketoutput_passbook__enabled = True
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
@@ -385,7 +388,7 @@ class Event(EventMixin, LoggedModel):
|
||||
if img:
|
||||
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web'):
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
from .vouchers import Voucher
|
||||
vqs = Voucher.objects.filter(
|
||||
@@ -416,7 +419,7 @@ class Event(EventMixin, LoggedModel):
|
||||
vqs
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel:
|
||||
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
|
||||
@@ -515,7 +518,7 @@ class Event(EventMixin, LoggedModel):
|
||||
), tz)
|
||||
|
||||
def copy_data_from(self, other):
|
||||
from . import ItemAddOn, ItemCategory, Item, Question, Quota
|
||||
from . import ItemAddOn, ItemCategory, Item, Question, Quota, ItemMetaValue
|
||||
from ..signals import event_copy_data
|
||||
|
||||
self.plugins = other.plugins
|
||||
@@ -540,6 +543,14 @@ class Event(EventMixin, LoggedModel):
|
||||
c.save()
|
||||
c.log_action('pretix.object.cloned')
|
||||
|
||||
item_meta_properties_map = {}
|
||||
for imp in other.item_meta_properties.all():
|
||||
item_meta_properties_map[imp.pk] = imp
|
||||
imp.pk = None
|
||||
imp.event = self
|
||||
imp.save()
|
||||
imp.log_action('pretix.object.cloned')
|
||||
|
||||
item_map = {}
|
||||
variation_map = {}
|
||||
for i in Item.objects.filter(event=other).prefetch_related('variations'):
|
||||
@@ -561,6 +572,12 @@ class Event(EventMixin, LoggedModel):
|
||||
v.item = i
|
||||
v.save()
|
||||
|
||||
for imv in ItemMetaValue.objects.filter(item__event=other).prefetch_related('item', 'property'):
|
||||
imv.pk = None
|
||||
imv.property = item_meta_properties_map[imv.property.pk]
|
||||
imv.item = item_map[imv.item.pk]
|
||||
imv.save()
|
||||
|
||||
for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'):
|
||||
ia.pk = None
|
||||
ia.base_item = item_map[ia.base_item.pk]
|
||||
@@ -608,8 +625,10 @@ class Event(EventMixin, LoggedModel):
|
||||
q.dependency_question = question_map[q.dependency_question_id]
|
||||
q.save(update_fields=['dependency_question'])
|
||||
|
||||
checkin_list_map = {}
|
||||
for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'):
|
||||
items = list(cl.limit_products.all())
|
||||
checkin_list_map[cl.pk] = cl
|
||||
cl.pk = None
|
||||
cl.event = self
|
||||
cl.save()
|
||||
@@ -633,6 +652,8 @@ class Event(EventMixin, LoggedModel):
|
||||
for s in other.seats.filter(subevent__isnull=True):
|
||||
s.pk = None
|
||||
s.event = self
|
||||
if s.product_id:
|
||||
s.product = item_map[s.product_id]
|
||||
s.save()
|
||||
|
||||
for s in other.settings._objects.all():
|
||||
@@ -662,7 +683,7 @@ class Event(EventMixin, LoggedModel):
|
||||
event_copy_data.send(
|
||||
sender=self, other=other,
|
||||
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
|
||||
question_map=question_map
|
||||
question_map=question_map, checkin_list_map=checkin_list_map
|
||||
)
|
||||
|
||||
def get_payment_providers(self, cached=False) -> dict:
|
||||
@@ -1014,9 +1035,13 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
ordering = ("date_from", "name")
|
||||
|
||||
def __str__(self):
|
||||
return '{} - {}'.format(self.name, self.get_date_range_display())
|
||||
return '{} - {} {}'.format(
|
||||
self.name,
|
||||
self.get_date_range_display(),
|
||||
date_format(self.date_from.astimezone(self.timezone), "TIME_FORMAT") if self.settings.show_times else ""
|
||||
).strip()
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web'):
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
from .vouchers import Voucher
|
||||
vqs = Voucher.objects.filter(
|
||||
@@ -1050,7 +1075,7 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
vqs
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if sales_channel not in self.settings.seating_allow_blocked_seats_for_channel:
|
||||
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
|
||||
def gen_giftcard_secret(length):
|
||||
def gen_giftcard_secret(length=8):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
while True:
|
||||
code = get_random_string(length=length, allowed_chars=charset)
|
||||
@@ -50,6 +51,12 @@ class GiftCard(LoggedModel):
|
||||
max_length=190,
|
||||
db_index=True,
|
||||
verbose_name=_('Gift card code'),
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]+$",
|
||||
message=_("The giftcard code may only contain letters, numbers, dots and dashes."),
|
||||
)
|
||||
],
|
||||
)
|
||||
testmode = models.BooleanField(
|
||||
verbose_name=_('Test mode card'),
|
||||
@@ -76,6 +83,7 @@ class GiftCard(LoggedModel):
|
||||
|
||||
class Meta:
|
||||
unique_together = (('secret', 'issuer'),)
|
||||
ordering = ("issuance",)
|
||||
|
||||
|
||||
class GiftCardTransaction(models.Model):
|
||||
@@ -112,6 +120,7 @@ class GiftCardTransaction(models.Model):
|
||||
blank=True,
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
text = models.TextField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("datetime",)
|
||||
|
||||
@@ -111,6 +111,7 @@ class Invoice(models.Model):
|
||||
|
||||
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
|
||||
internal_reference = models.TextField(blank=True)
|
||||
custom_field = models.CharField(max_length=255, null=True)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
@@ -120,13 +121,19 @@ class Invoice(models.Model):
|
||||
|
||||
@property
|
||||
def full_invoice_from(self):
|
||||
taxidrow = ""
|
||||
if self.invoice_from_tax_id:
|
||||
if str(self.invoice_from_country) == "AU":
|
||||
taxidrow = "ABN: %s" % self.invoice_from_tax_id
|
||||
else:
|
||||
taxidrow = pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id
|
||||
parts = [
|
||||
self.invoice_from_name,
|
||||
self.invoice_from,
|
||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
||||
self.invoice_from_country.name if self.invoice_from_country else "",
|
||||
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
|
||||
pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id if self.invoice_from_tax_id else "",
|
||||
taxidrow,
|
||||
]
|
||||
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||
|
||||
@@ -150,9 +157,12 @@ class Invoice(models.Model):
|
||||
state_name = self.invoice_to_state
|
||||
if str(self.invoice_to_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_to_country)][1] == 'long':
|
||||
state_name = pycountry.subdivisions.get(
|
||||
code='{}-{}'.format(self.invoice_to_country, self.invoice_to_state)
|
||||
).name
|
||||
try:
|
||||
state_name = pycountry.subdivisions.get(
|
||||
code='{}-{}'.format(self.invoice_to_country, self.invoice_to_state)
|
||||
).name
|
||||
except:
|
||||
pass
|
||||
|
||||
parts = [
|
||||
self.invoice_to_company,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import sys
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from collections import Counter, OrderedDict
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal, DecimalException
|
||||
from typing import Tuple
|
||||
@@ -9,13 +9,14 @@ import dateutil.parser
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, Func, Q, Sum
|
||||
from django.utils import formats
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import is_naive, make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries.fields import Country
|
||||
from django_scopes import ScopedManager
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
@@ -454,7 +455,8 @@ class Item(LoggedModel):
|
||||
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
|
||||
rate=Decimal('0.00'), name='')
|
||||
else:
|
||||
t = self.tax_rule.tax(price, base_price_is=base_price_is, currency=currency)
|
||||
t = self.tax_rule.tax(price, base_price_is=base_price_is,
|
||||
currency=currency or self.event.currency)
|
||||
|
||||
if include_bundled:
|
||||
for b in self.bundles.all():
|
||||
@@ -591,6 +593,16 @@ class Item(LoggedModel):
|
||||
if from_date > until_date:
|
||||
raise ValidationError(_('The item\'s availability cannot end before it starts.'))
|
||||
|
||||
@property
|
||||
def meta_data(self):
|
||||
data = {p.name: p.default for p in self.event.item_meta_properties.all()}
|
||||
if hasattr(self, 'meta_values_cached'):
|
||||
data.update({v.property.name: v.value for v in self.meta_values_cached})
|
||||
else:
|
||||
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
|
||||
|
||||
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
|
||||
|
||||
|
||||
class ItemVariation(models.Model):
|
||||
"""
|
||||
@@ -1105,10 +1117,13 @@ class Question(LoggedModel):
|
||||
return None
|
||||
|
||||
if self.type == Question.TYPE_CHOICE:
|
||||
try:
|
||||
return self.options.get(Q(pk=answer) | Q(identifier=answer))
|
||||
except:
|
||||
q = Q(identifier=answer)
|
||||
if isinstance(answer, int) or answer.isdigit():
|
||||
q |= Q(pk=answer)
|
||||
o = self.options.filter(q).first()
|
||||
if not o:
|
||||
raise ValidationError(_('Invalid option selected.'))
|
||||
return o
|
||||
elif self.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
if isinstance(answer, str):
|
||||
l_ = list(self.options.filter(
|
||||
@@ -1541,3 +1556,57 @@ class Quota(LoggedModel):
|
||||
else:
|
||||
if subevent:
|
||||
raise ValidationError(_('The subevent does not belong to this event.'))
|
||||
|
||||
|
||||
class ItemMetaProperty(LoggedModel):
|
||||
"""
|
||||
An event can have ItemMetaProperty objects attached to define meta information fields
|
||||
for its items. This information can be re-used for example in ticket layouts.
|
||||
|
||||
:param event: The event this property is defined for.
|
||||
:type event: Event
|
||||
:param name: Name
|
||||
:type name: Name of the property, used in various places
|
||||
:param default: Default value
|
||||
:type default: str
|
||||
"""
|
||||
event = models.ForeignKey(Event, related_name="item_meta_properties", on_delete=models.CASCADE)
|
||||
name = models.CharField(
|
||||
max_length=50, db_index=True,
|
||||
help_text=_(
|
||||
"Can not contain spaces or special characters except underscores"
|
||||
),
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex="^[a-zA-Z0-9_]+$",
|
||||
message=_("The property name may only contain letters, numbers and underscores."),
|
||||
),
|
||||
],
|
||||
verbose_name=_("Name"),
|
||||
)
|
||||
default = models.TextField(blank=True)
|
||||
|
||||
|
||||
class ItemMetaValue(LoggedModel):
|
||||
"""
|
||||
A meta-data value assigned to an item.
|
||||
|
||||
:param item: The item this metadata is valid for
|
||||
:type item: Item
|
||||
:param property: The property this value belongs to
|
||||
:type property: ItemMetaProperty
|
||||
:param value: The actual value
|
||||
:type value: str
|
||||
"""
|
||||
item = models.ForeignKey('Item', on_delete=models.CASCADE, related_name='meta_values')
|
||||
property = models.ForeignKey('ItemMetaProperty', on_delete=models.CASCADE, related_name='item_values')
|
||||
value = models.TextField()
|
||||
|
||||
class Meta:
|
||||
unique_together = ('item', 'property')
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.signals import logentry_object_link
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class NotificationSetting(models.Model):
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import string
|
||||
from collections import Counter
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Union
|
||||
@@ -25,7 +26,7 @@ from django.utils.encoding import escape_uri_path
|
||||
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 pgettext_lazy, ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries.fields import Country, CountryField
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
@@ -43,6 +44,7 @@ from pretix.base.services.locking import NoLockManager
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import order_gracefully_delete
|
||||
|
||||
from ...helpers.countries import CachedCountries
|
||||
from .base import LockModel, LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
||||
@@ -151,6 +153,9 @@ class Order(LockModel, LoggedModel):
|
||||
datetime = models.DateTimeField(
|
||||
verbose_name=_("Date"), db_index=True
|
||||
)
|
||||
cancellation_date = models.DateTimeField(
|
||||
null=True, blank=True
|
||||
)
|
||||
expires = models.DateTimeField(
|
||||
verbose_name=_("Expiration date")
|
||||
)
|
||||
@@ -400,10 +405,13 @@ class Order(LockModel, LoggedModel):
|
||||
term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||
if term_last:
|
||||
if self.event.has_subevents and subevents:
|
||||
term_last = min([
|
||||
terms = [
|
||||
term_last.datetime(se).date()
|
||||
for se in subevents
|
||||
])
|
||||
]
|
||||
if not terms:
|
||||
return
|
||||
term_last = min(terms)
|
||||
else:
|
||||
term_last = term_last.datetime(self.event).date()
|
||||
term_last = make_aware(datetime.combine(
|
||||
@@ -423,7 +431,7 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
def cancel_allowed(self):
|
||||
return (
|
||||
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.count_positions
|
||||
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED) and self.count_positions
|
||||
)
|
||||
|
||||
@cached_property
|
||||
@@ -434,26 +442,28 @@ class Order(LockModel, LoggedModel):
|
||||
until = self.event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper)
|
||||
if until:
|
||||
if self.event.has_subevents:
|
||||
return min([
|
||||
terms = [
|
||||
until.datetime(se)
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
])
|
||||
]
|
||||
return min(terms) if terms else None
|
||||
else:
|
||||
return until.datetime(self.event)
|
||||
|
||||
@cached_property
|
||||
def user_cancel_fee(self):
|
||||
fee = Decimal('0.00')
|
||||
if self.event.settings.cancel_allow_user_paid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
if self.event.settings.cancel_allow_user_paid_keep_percentage:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * self.total
|
||||
if self.event.settings.cancel_allow_user_paid_keep_fees:
|
||||
fee += self.fees.filter(
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE)
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE,
|
||||
OrderFee.FEE_TYPE_CANCELLATION)
|
||||
).aggregate(
|
||||
s=Sum('value')
|
||||
)['s'] or 0
|
||||
if self.event.settings.cancel_allow_user_paid_keep_percentage:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * (self.total - fee)
|
||||
if self.event.settings.cancel_allow_user_paid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
return round_decimal(fee, self.event.currency)
|
||||
|
||||
@property
|
||||
@@ -464,6 +474,8 @@ class Order(LockModel, LoggedModel):
|
||||
"""
|
||||
from .checkin import Checkin
|
||||
|
||||
if self.cancellation_requests.exists():
|
||||
return False
|
||||
positions = list(
|
||||
self.positions.all().annotate(
|
||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
|
||||
@@ -586,10 +598,11 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
modify_deadline = self.event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper)
|
||||
if self.event.has_subevents and modify_deadline:
|
||||
modify_deadline = min([
|
||||
dates = [
|
||||
modify_deadline.datetime(se)
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
])
|
||||
]
|
||||
modify_deadline = min(dates) if dates else None
|
||||
elif modify_deadline:
|
||||
modify_deadline = modify_deadline.datetime(self.event)
|
||||
|
||||
@@ -620,10 +633,11 @@ class Order(LockModel, LoggedModel):
|
||||
dl_date = self.event.settings.get('ticket_download_date', as_type=RelativeDateWrapper)
|
||||
if dl_date:
|
||||
if self.event.has_subevents:
|
||||
dl_date = min([
|
||||
dates = [
|
||||
dl_date.datetime(se)
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
])
|
||||
]
|
||||
dl_date = min(dates) if dates else None
|
||||
else:
|
||||
dl_date = dl_date.datetime(self.event)
|
||||
return dl_date
|
||||
@@ -648,10 +662,14 @@ class Order(LockModel, LoggedModel):
|
||||
term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||
if term_last:
|
||||
if self.event.has_subevents:
|
||||
term_last = min([
|
||||
terms = [
|
||||
term_last.datetime(se).date()
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
])
|
||||
]
|
||||
if terms:
|
||||
term_last = min(terms)
|
||||
else:
|
||||
term_last = None
|
||||
else:
|
||||
term_last = term_last.datetime(self.event).date()
|
||||
term_last = make_aware(datetime.combine(
|
||||
@@ -683,16 +701,19 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
return self._is_still_available(count_waitinglist=count_waitinglist, force=force)
|
||||
|
||||
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False) -> Union[bool, str]:
|
||||
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False,
|
||||
check_voucher_usage=False) -> Union[bool, str]:
|
||||
error_messages = {
|
||||
'unavailable': _('The ordered product "{item}" is no longer available.'),
|
||||
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
|
||||
'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'),
|
||||
'voucher_usages': _('The voucher "{voucher}" has been used in the meantime.'),
|
||||
}
|
||||
now_dt = now_dt or now()
|
||||
positions = self.positions.all().select_related('item', 'variation', 'seat', 'voucher')
|
||||
quota_cache = {}
|
||||
v_budget = {}
|
||||
v_usage = Counter()
|
||||
try:
|
||||
for i, op in enumerate(positions):
|
||||
if op.seat:
|
||||
@@ -711,6 +732,13 @@ class Order(LockModel, LoggedModel):
|
||||
))
|
||||
v_budget[op.voucher] -= disc
|
||||
|
||||
if op.voucher and check_voucher_usage:
|
||||
v_usage[op.voucher.pk] += 1
|
||||
if v_usage[op.voucher.pk] + op.voucher.redeemed > op.voucher.max_usages:
|
||||
raise Quota.QuotaExceededException(error_messages['voucher_usages'].format(
|
||||
voucher=op.voucher.code
|
||||
))
|
||||
|
||||
quotas = list(op.quotas)
|
||||
if len(quotas) == 0:
|
||||
raise Quota.QuotaExceededException(error_messages['unavailable'].format(
|
||||
@@ -900,7 +928,7 @@ class QuestionAnswer(models.Model):
|
||||
|
||||
@property
|
||||
def is_image(self):
|
||||
return any(self.file.name.endswith(e) for e in ('.jpg', '.png', '.gif', '.tiff', '.bmp', '.jpeg'))
|
||||
return any(self.file.name.lower().endswith(e) for e in ('.jpg', '.png', '.gif', '.tiff', '.bmp', '.jpeg'))
|
||||
|
||||
@property
|
||||
def file_name(self):
|
||||
@@ -1038,6 +1066,13 @@ class AbstractPosition(models.Model):
|
||||
'Seat', null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
|
||||
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
|
||||
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True, null=True)
|
||||
country = CountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
|
||||
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -1258,6 +1293,36 @@ class OrderPayment(models.Model):
|
||||
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
|
||||
order_paid.send(self.order.event, order=self.order)
|
||||
|
||||
def fail(self, info=None, user=None, auth=None):
|
||||
"""
|
||||
Marks the order as failed and sets info to ``info``, but only if the order is in ``created`` or ``pending``
|
||||
state. This is equivalent to setting ``state`` to ``OrderPayment.PAYMENT_STATE_FAILED`` and logging a failure,
|
||||
but it adds strong database logging since we do not want to report a failure for an order that has just
|
||||
been marked as paid.
|
||||
"""
|
||||
with transaction.atomic():
|
||||
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||
if locked_instance.state not in (OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING):
|
||||
# Race condition detected, this payment is already confirmed
|
||||
logger.info('Failed payment {} but ignored due to likely race condition.'.format(
|
||||
self.full_id,
|
||||
))
|
||||
return
|
||||
|
||||
if isinstance(info, str):
|
||||
locked_instance.info = info
|
||||
elif info:
|
||||
locked_instance.info_data = info
|
||||
locked_instance.state = OrderPayment.PAYMENT_STATE_FAILED
|
||||
locked_instance.save(update_fields=['state', 'info'])
|
||||
|
||||
self.refresh_from_db()
|
||||
self.order.log_action('pretix.event.order.payment.failed', {
|
||||
'local_id': self.local_id,
|
||||
'provider': self.provider,
|
||||
'info': info,
|
||||
}, user=user, auth=auth)
|
||||
|
||||
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
|
||||
ignore_date=False, lock=True, payment_date=None):
|
||||
"""
|
||||
@@ -1285,6 +1350,9 @@ class OrderPayment(models.Model):
|
||||
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||
if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
|
||||
# Race condition detected, this payment is already confirmed
|
||||
logger.info('Confirmed payment {} but ignored due to likely race condition.'.format(
|
||||
self.full_id,
|
||||
))
|
||||
return
|
||||
|
||||
locked_instance.state = self.PAYMENT_STATE_CONFIRMED
|
||||
@@ -1305,6 +1373,7 @@ class OrderPayment(models.Model):
|
||||
}, user=user, auth=auth)
|
||||
|
||||
if self.order.status in (Order.STATUS_PAID, Order.STATUS_CANCELED):
|
||||
logger.info('Confirmed payment {} but order is in status {}.'.format(self.full_id, self.order.status))
|
||||
return
|
||||
|
||||
payment_sum = self.order.payments.filter(
|
||||
@@ -1315,6 +1384,9 @@ 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(
|
||||
self.full_id, payment_sum, refund_sum
|
||||
))
|
||||
return
|
||||
|
||||
if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12)) or not lock:
|
||||
@@ -1873,8 +1945,9 @@ class OrderPosition(AbstractPosition):
|
||||
if self.tax_rate is None:
|
||||
self._calculate_tax()
|
||||
self.order.touch()
|
||||
if self.pk is None:
|
||||
while OrderPosition.all.filter(secret=self.secret).exists():
|
||||
if not self.pk:
|
||||
while OrderPosition.all.filter(secret=self.secret,
|
||||
order__event__organizer_id=self.order.event.organizer_id).exists():
|
||||
self.secret = generate_position_secret()
|
||||
|
||||
if not self.pseudonymization_id:
|
||||
@@ -1891,9 +1964,10 @@ class OrderPosition(AbstractPosition):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
while True:
|
||||
code = get_random_string(length=10, allowed_chars=charset)
|
||||
if not OrderPosition.all.filter(pseudonymization_id=code).exists():
|
||||
self.pseudonymization_id = code
|
||||
return
|
||||
with scopes_disabled():
|
||||
if not OrderPosition.all.filter(pseudonymization_id=code).exists():
|
||||
self.pseudonymization_id = code
|
||||
return
|
||||
|
||||
@property
|
||||
def event(self):
|
||||
@@ -2040,11 +2114,13 @@ class InvoiceAddress(models.Model):
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
|
||||
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
|
||||
country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'))
|
||||
country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
|
||||
countries=CachedCountries)
|
||||
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True)
|
||||
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'),
|
||||
help_text=_('Only for business customers within the EU.'))
|
||||
vat_id_validated = models.BooleanField(default=False)
|
||||
custom_field = models.CharField(max_length=255, null=True, blank=True)
|
||||
internal_reference = models.TextField(
|
||||
verbose_name=_('Internal reference'),
|
||||
help_text=_('This reference will be printed on your invoice for your convenience.'),
|
||||
@@ -2146,6 +2222,13 @@ class CachedCombinedTicket(models.Model):
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class CancellationRequest(models.Model):
|
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='cancellation_requests')
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
cancellation_fee = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
refund_as_giftcard = models.BooleanField(default=False)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=CachedTicket)
|
||||
def cachedticket_delete(sender, instance, **kwargs):
|
||||
if instance.file:
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.validators import OrganizerSlugBanlistValidator
|
||||
@@ -37,7 +37,7 @@ class Organizer(LoggedModel):
|
||||
"once. This is being used in URLs to refer to your organizer accounts and your events."),
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex="^[a-zA-Z0-9.-]+$",
|
||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]+$",
|
||||
message=_("The slug may only contain letters, numbers, dots and dashes.")
|
||||
),
|
||||
OrganizerSlugBanlistValidator()
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, ugettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent
|
||||
|
||||
@@ -28,7 +28,8 @@ class SeatingPlanLayoutValidator:
|
||||
try:
|
||||
jsonschema.validate(val, schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(str(e)))
|
||||
e = str(e).replace('%', '%%')
|
||||
raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(e))
|
||||
|
||||
|
||||
class SeatingPlan(LoggedModel):
|
||||
@@ -40,7 +41,7 @@ class SeatingPlan(LoggedModel):
|
||||
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
|
||||
|
||||
Category = namedtuple('Categrory', 'name')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank row_label seat_label')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -69,11 +70,17 @@ class SeatingPlan(LoggedModel):
|
||||
# optimization, because this way we do not need to update the rank of very seat if we change a plan a little.
|
||||
for zi, z in enumerate(self.layout_data['zones']):
|
||||
for ri, r in enumerate(z['rows']):
|
||||
row_label = None
|
||||
if r.get('row_label'):
|
||||
row_label = r['row_label'].replace("%s", r.get('row_number', str(ri)))
|
||||
try:
|
||||
row_rank = int(r['row_number'])
|
||||
except ValueError:
|
||||
row_rank = ri
|
||||
for si, s in enumerate(r['seats']):
|
||||
seat_label = None
|
||||
if r.get('seat_label'):
|
||||
seat_label = r['seat_label'].replace("%s", s.get('seat_number', str(si)))
|
||||
try:
|
||||
seat_rank = int(s['seat_number'])
|
||||
except ValueError:
|
||||
@@ -87,6 +94,8 @@ class SeatingPlan(LoggedModel):
|
||||
guid=s['seat_guid'],
|
||||
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
|
||||
row=r['row_number'],
|
||||
row_label=row_label,
|
||||
seat_label=seat_label,
|
||||
zone=z['name'],
|
||||
category=s['category'],
|
||||
sorting_rank=rank
|
||||
@@ -114,7 +123,9 @@ class Seat(models.Model):
|
||||
name = models.CharField(max_length=190)
|
||||
zone_name = models.CharField(max_length=190, blank=True, default="")
|
||||
row_name = models.CharField(max_length=190, blank=True, default="")
|
||||
row_label = models.CharField(max_length=190, null=True)
|
||||
seat_number = models.CharField(max_length=190, blank=True, default="")
|
||||
seat_label = models.CharField(max_length=190, null=True)
|
||||
seat_guid = models.CharField(max_length=190, db_index=True)
|
||||
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
|
||||
blocked = models.BooleanField(default=False)
|
||||
@@ -127,10 +138,17 @@ class Seat(models.Model):
|
||||
parts = []
|
||||
if self.zone_name:
|
||||
parts.append(self.zone_name)
|
||||
if self.row_name:
|
||||
|
||||
if self.row_label:
|
||||
parts.append(self.row_label)
|
||||
elif self.row_name:
|
||||
parts.append(gettext('Row {number}').format(number=self.row_name))
|
||||
if self.seat_number:
|
||||
|
||||
if self.seat_label:
|
||||
parts.append(self.seat_label)
|
||||
elif self.seat_number:
|
||||
parts.append(gettext('Seat {number}').format(number=self.seat_number))
|
||||
|
||||
if not parts:
|
||||
return self.name
|
||||
return ', '.join(parts)
|
||||
|
||||
@@ -4,7 +4,7 @@ from decimal import Decimal
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from i18nfield.fields import I18nCharField
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.db.models import F, OuterRef, Q, Subquery, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import timedelta
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.email import get_email_context
|
||||
|
||||
@@ -3,7 +3,7 @@ from collections import OrderedDict, namedtuple
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.models import Event, LogEntry
|
||||
from pretix.base.signals import register_notification_types
|
||||
@@ -223,6 +223,12 @@ def register_default_notification_types(sender, **kwargs):
|
||||
_('Order canceled'),
|
||||
_('Order {order.code} has been canceled.')
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.reactivated',
|
||||
_('Order reactivated'),
|
||||
_('Order {order.code} has been reactivated.')
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.expired',
|
||||
|
||||
@@ -325,7 +325,7 @@ class InvoiceAddressState(ImportColumn):
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Invoice address') + ': ' + _('State')
|
||||
return _('Invoice address') + ': ' + pgettext('address', 'State')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
@@ -398,6 +398,99 @@ class AttendeeEmail(ImportColumn):
|
||||
position.attendee_email = value
|
||||
|
||||
|
||||
class AttendeeCompany(ImportColumn):
|
||||
identifier = 'attendee_company'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Attendee address') + ': ' + _('Company')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.company = value or ''
|
||||
|
||||
|
||||
class AttendeeStreet(ImportColumn):
|
||||
identifier = 'attendee_street'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Attendee address') + ': ' + _('Address')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.address = value or ''
|
||||
|
||||
|
||||
class AttendeeZip(ImportColumn):
|
||||
identifier = 'attendee_zipcode'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Attendee address') + ': ' + _('ZIP code')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.zipcode = value or ''
|
||||
|
||||
|
||||
class AttendeeCity(ImportColumn):
|
||||
identifier = 'attendee_city'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Attendee address') + ': ' + _('City')
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.city = value or ''
|
||||
|
||||
|
||||
class AttendeeCountry(ImportColumn):
|
||||
identifier = 'attendee_country'
|
||||
default_value = None
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
return 'static:' + str(guess_country(self.event))
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Attendee address') + ': ' + _('Country')
|
||||
|
||||
def static_choices(self):
|
||||
return list(countries)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value and not Country(value).numeric:
|
||||
raise ValidationError(_("Please enter a valid country code."))
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.country = value or ''
|
||||
|
||||
|
||||
class AttendeeState(ImportColumn):
|
||||
identifier = 'attendee_state'
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Attendee address') + ': ' + _('State')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
if previous_values.get('attendee_country') not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
raise ValidationError(_("States are not supported for this country."))
|
||||
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[previous_values.get('attendee_country')]
|
||||
match = [
|
||||
s for s in pycountry.subdivisions.get(country_code=previous_values.get('attendee_country'))
|
||||
if s.type in types and (s.code[3:] == value or s.name == value)
|
||||
]
|
||||
if len(match) == 0:
|
||||
raise ValidationError(_("Please enter a valid state."))
|
||||
return match[0].code[3:]
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.state = value or ''
|
||||
|
||||
|
||||
class Price(ImportColumn):
|
||||
identifier = 'price'
|
||||
verbose_name = gettext_lazy('Price')
|
||||
@@ -596,6 +689,12 @@ def get_all_columns(event):
|
||||
default.append(AttendeeNamePart(event, n, l))
|
||||
default += [
|
||||
AttendeeEmail(event),
|
||||
AttendeeCompany(event),
|
||||
AttendeeStreet(event),
|
||||
AttendeeZip(event),
|
||||
AttendeeCity(event),
|
||||
AttendeeCountry(event),
|
||||
AttendeeState(event),
|
||||
Price(event),
|
||||
Secret(event),
|
||||
Locale(event),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
@@ -14,8 +15,9 @@ from django.dispatch import receiver
|
||||
from django.forms import Form
|
||||
from django.http import HttpRequest
|
||||
from django.template.loader import get_template
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries import Countries
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||
from i18nfield.strings import LazyI18nString
|
||||
@@ -27,12 +29,13 @@ from pretix.base.models import (
|
||||
OrderRefund, Quota,
|
||||
)
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
|
||||
from pretix.base.services.cart import get_fees
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.helpers.money import DecimalTextInput
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
|
||||
from pretix.presale.views import get_cart, get_cart_total
|
||||
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
||||
|
||||
@@ -204,6 +207,13 @@ class BasePaymentProvider:
|
||||
implementation.
|
||||
"""
|
||||
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
|
||||
|
||||
if not self.settings.get('_hidden_seed'):
|
||||
self.settings.set('_hidden_seed', get_random_string(64))
|
||||
hidden_url = build_absolute_uri(self.event, 'presale:event.payment.unlock', kwargs={
|
||||
'hash': hashlib.sha256((self.settings._hidden_seed + self.event.slug).encode()).hexdigest(),
|
||||
})
|
||||
|
||||
d = OrderedDict([
|
||||
('_enabled',
|
||||
forms.BooleanField(
|
||||
@@ -297,7 +307,30 @@ class BasePaymentProvider:
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
help_text=_(
|
||||
'Only allow the usage of this payment provider in the following sales channels'),
|
||||
))
|
||||
)),
|
||||
('_hidden',
|
||||
forms.BooleanField(
|
||||
label=_('Hide payment method'),
|
||||
required=False,
|
||||
help_text=_(
|
||||
'The payment method will not be shown by default but only to people who enter the shop through '
|
||||
'a special link.'
|
||||
),
|
||||
)),
|
||||
('_hidden_url',
|
||||
forms.URLField(
|
||||
label=_('Link to enable payment method'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'readonly': 'readonly',
|
||||
'data-display-dependency': '#id_%s_hidden' % self.settings.get_prefix(),
|
||||
'value': hidden_url,
|
||||
}),
|
||||
required=False,
|
||||
initial=hidden_url,
|
||||
help_text=_(
|
||||
'Share this link with customers who should use this payment method.'
|
||||
),
|
||||
)),
|
||||
])
|
||||
d['_restricted_countries']._as_type = list
|
||||
d['_restrict_to_sales_channels']._as_type = list
|
||||
@@ -378,28 +411,31 @@ class BasePaymentProvider:
|
||||
availability_date = self.settings.get('_availability_date', as_type=RelativeDateWrapper)
|
||||
if availability_date:
|
||||
if self.event.has_subevents and cart_id:
|
||||
availability_date = min([
|
||||
dates = [
|
||||
availability_date.datetime(se).date()
|
||||
for se in self.event.subevents.filter(
|
||||
id__in=CartPosition.objects.filter(
|
||||
cart_id=cart_id, event=self.event
|
||||
).values_list('subevent', flat=True)
|
||||
)
|
||||
])
|
||||
]
|
||||
availability_date = min(dates) if dates else None
|
||||
elif self.event.has_subevents and order:
|
||||
availability_date = min([
|
||||
dates = [
|
||||
availability_date.datetime(se).date()
|
||||
for se in self.event.subevents.filter(
|
||||
id__in=order.positions.values_list('subevent', flat=True)
|
||||
)
|
||||
])
|
||||
]
|
||||
availability_date = min(dates) if dates else None
|
||||
elif self.event.has_subevents:
|
||||
logger.error('Payment provider is not subevent-ready.')
|
||||
return False
|
||||
else:
|
||||
availability_date = availability_date.datetime(self.event).date()
|
||||
|
||||
return availability_date >= now_dt.astimezone(tz).date()
|
||||
if availability_date:
|
||||
return availability_date >= now_dt.astimezone(tz).date()
|
||||
|
||||
return True
|
||||
|
||||
@@ -433,6 +469,11 @@ class BasePaymentProvider:
|
||||
if self.settings._total_min is not None:
|
||||
pricing = pricing and total >= Decimal(self.settings._total_min)
|
||||
|
||||
if self.settings.get('_hidden', as_type=bool):
|
||||
hashes = request.session.get('pretix_unlock_hashes', [])
|
||||
if hashlib.sha256((self.settings._hidden_seed + self.event.slug).encode()).hexdigest() not in hashes:
|
||||
return False
|
||||
|
||||
def get_invoice_address():
|
||||
if not hasattr(request, '_checkout_flow_invoice_address'):
|
||||
cs = cart_session(request)
|
||||
@@ -602,6 +643,9 @@ class BasePaymentProvider:
|
||||
if self.settings._total_min is not None and ps < Decimal(self.settings._total_min):
|
||||
return False
|
||||
|
||||
if self.settings.get('_hidden', as_type=bool):
|
||||
return False
|
||||
|
||||
restricted_countries = self.settings.get('_restricted_countries', as_type=list)
|
||||
if restricted_countries:
|
||||
try:
|
||||
@@ -687,7 +731,7 @@ class BasePaymentProvider:
|
||||
On failure, you should raise a PaymentException.
|
||||
"""
|
||||
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
|
||||
payment.save()
|
||||
payment.save(update_fields=['state'])
|
||||
|
||||
def execute_refund(self, refund: OrderRefund):
|
||||
"""
|
||||
@@ -721,6 +765,16 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return {}
|
||||
|
||||
def matching_id(self, payment: OrderPayment):
|
||||
"""
|
||||
Will be called to get an ID for a matching this payment when comparing pretix records with records of an external
|
||||
source. This should return the main transaction ID for your API.
|
||||
|
||||
:param payment: The payment in question.
|
||||
:return: A string or None
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class PaymentException(Exception):
|
||||
pass
|
||||
@@ -1053,8 +1107,16 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
return
|
||||
cs['gift_cards'] = cs['gift_cards'] + [gc.pk]
|
||||
|
||||
remainder = cart['total'] - gc.value
|
||||
if remainder >= Decimal('0.00'):
|
||||
total = sum(p.total for p in cart['positions'])
|
||||
# Recompute fees. Some plugins, e.g. pretix-servicefees, change their fee schedule if a gift card is
|
||||
# applied.
|
||||
fees = get_fees(
|
||||
self.event, request, total, cart['invoice_address'], cs.get('payment'),
|
||||
cart['raw']
|
||||
)
|
||||
total += sum([f.value for f in fees])
|
||||
remainder = total - gc.value
|
||||
if remainder > Decimal('0.00'):
|
||||
del cs['payment']
|
||||
messages.success(request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format(
|
||||
money_filter(remainder, self.event.currency)
|
||||
@@ -1157,6 +1219,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
)
|
||||
refund.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'gift_card_code': gc.secret,
|
||||
'transaction_id': trans.pk,
|
||||
}
|
||||
refund.done()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import copy
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
@@ -9,16 +9,15 @@ from collections import OrderedDict
|
||||
from functools import partial
|
||||
from io import BytesIO
|
||||
|
||||
import bleach
|
||||
from arabic_reshaper import ArabicReshaper
|
||||
from bidi.algorithm import get_display
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from PyPDF2 import PdfFileReader
|
||||
from pytz import timezone
|
||||
from reportlab.graphics import renderPDF
|
||||
@@ -64,32 +63,32 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
("item", {
|
||||
"label": _("Product name"),
|
||||
"editor_sample": _("Sample product"),
|
||||
"evaluate": lambda orderposition, order, event: escape(str(orderposition.item.name))
|
||||
"evaluate": lambda orderposition, order, event: str(orderposition.item.name)
|
||||
}),
|
||||
("variation", {
|
||||
"label": _("Variation name"),
|
||||
"editor_sample": _("Sample variation"),
|
||||
"evaluate": lambda op, order, event: escape(str(op.variation) if op.variation else '')
|
||||
"evaluate": lambda op, order, event: str(op.variation) if op.variation else ''
|
||||
}),
|
||||
("item_description", {
|
||||
"label": _("Product description"),
|
||||
"editor_sample": _("Sample product description"),
|
||||
"evaluate": lambda orderposition, order, event: escape(str(orderposition.item.description))
|
||||
"evaluate": lambda orderposition, order, event: str(orderposition.item.description)
|
||||
}),
|
||||
("itemvar", {
|
||||
"label": _("Product name and variation"),
|
||||
"editor_sample": _("Sample product – sample variation"),
|
||||
"evaluate": lambda orderposition, order, event: escape((
|
||||
"evaluate": lambda orderposition, order, event: (
|
||||
'{} - {}'.format(orderposition.item.name, orderposition.variation)
|
||||
if orderposition.variation else str(orderposition.item.name)
|
||||
))
|
||||
)
|
||||
}),
|
||||
("item_category", {
|
||||
"label": _("Product category"),
|
||||
"editor_sample": _("Ticket category"),
|
||||
"evaluate": lambda orderposition, order, event: escape((
|
||||
"evaluate": lambda orderposition, order, event: (
|
||||
str(orderposition.item.category.name) if orderposition.item.category else ""
|
||||
))
|
||||
)
|
||||
}),
|
||||
("price", {
|
||||
"label": _("Price"),
|
||||
@@ -108,12 +107,12 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
("attendee_name", {
|
||||
"label": _("Attendee name"),
|
||||
"editor_sample": _("John Doe"),
|
||||
"evaluate": lambda op, order, ev: escape(op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''))
|
||||
"evaluate": lambda op, order, ev: op.attendee_name or (op.addon_to.attendee_name if op.addon_to else '')
|
||||
}),
|
||||
("event_name", {
|
||||
"label": _("Event name"),
|
||||
"editor_sample": _("Sample event name"),
|
||||
"evaluate": lambda op, order, ev: escape(str(ev.name))
|
||||
"evaluate": lambda op, order, ev: str(ev.name)
|
||||
}),
|
||||
("event_date", {
|
||||
"label": _("Event date"),
|
||||
@@ -189,27 +188,32 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
("event_location", {
|
||||
"label": _("Event location"),
|
||||
"editor_sample": _("Random City"),
|
||||
"evaluate": lambda op, order, ev: str(ev.location).replace("\n", "<br/>\n")
|
||||
"evaluate": lambda op, order, ev: str(ev.location)
|
||||
}),
|
||||
("invoice_name", {
|
||||
"label": _("Invoice address name"),
|
||||
"editor_sample": _("John Doe"),
|
||||
"evaluate": lambda op, order, ev: escape(order.invoice_address.name if getattr(order, 'invoice_address', None) else '')
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.name if getattr(order, 'invoice_address', None) else ''
|
||||
}),
|
||||
("invoice_company", {
|
||||
"label": _("Invoice address company"),
|
||||
"editor_sample": _("Sample company"),
|
||||
"evaluate": lambda op, order, ev: escape(order.invoice_address.company if getattr(order, 'invoice_address', None) else '')
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.company if getattr(order, 'invoice_address', None) else ''
|
||||
}),
|
||||
("invoice_city", {
|
||||
"label": _("Invoice address city"),
|
||||
"editor_sample": _("Sample city"),
|
||||
"evaluate": lambda op, order, ev: escape(order.invoice_address.city if getattr(order, 'invoice_address', None) else '')
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.city if getattr(order, 'invoice_address', None) else ''
|
||||
}),
|
||||
("attendee_company", {
|
||||
"label": _("Attendee company"),
|
||||
"editor_sample": _("Sample company"),
|
||||
"evaluate": lambda op, order, ev: op.company or (op.addon_to.company if op.addon_to else '')
|
||||
}),
|
||||
("addons", {
|
||||
"label": _("List of Add-Ons"),
|
||||
"editor_sample": _("Addon 1\nAddon 2"),
|
||||
"evaluate": lambda op, order, ev: "<br/>".join([
|
||||
"evaluate": lambda op, order, ev: "\n".join([
|
||||
'{} - {}'.format(p.item, p.variation) if p.variation else str(p.item)
|
||||
for p in (
|
||||
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
|
||||
@@ -221,7 +225,7 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
("organizer", {
|
||||
"label": _("Organizer name"),
|
||||
"editor_sample": _("Event organizer company"),
|
||||
"evaluate": lambda op, order, ev: escape(str(order.event.organizer.name))
|
||||
"evaluate": lambda op, order, ev: str(order.event.organizer.name)
|
||||
}),
|
||||
("organizer_info_text", {
|
||||
"label": _("Organizer info text"),
|
||||
@@ -301,7 +305,7 @@ def variables_from_questions(sender, *args, **kwargs):
|
||||
if not a:
|
||||
return ""
|
||||
else:
|
||||
return escape(str(a)).replace("\n", "<br/>\n")
|
||||
return str(a)
|
||||
|
||||
d = {}
|
||||
for q in sender.questions.all():
|
||||
@@ -314,11 +318,13 @@ def variables_from_questions(sender, *args, **kwargs):
|
||||
|
||||
|
||||
def _get_attendee_name_part(key, op, order, ev):
|
||||
return escape(op.attendee_name_parts.get(key, ''))
|
||||
if isinstance(key, tuple):
|
||||
return ' '.join(p for p in [_get_attendee_name_part(c[0], op, order, ev) for c in key] if p)
|
||||
return op.attendee_name_parts.get(key, '')
|
||||
|
||||
|
||||
def _get_ia_name_part(key, op, order, ev):
|
||||
return escape(order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else '')
|
||||
return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
||||
|
||||
|
||||
def get_variables(event):
|
||||
@@ -331,6 +337,13 @@ def get_variables(event):
|
||||
'editor_sample': scheme['sample'][key],
|
||||
'evaluate': partial(_get_attendee_name_part, key)
|
||||
}
|
||||
for i in range(2, len(scheme['fields']) + 1):
|
||||
for comb in itertools.combinations(scheme['fields'], i):
|
||||
v['attendee_name_%s' % ('_'.join(c[0] for c in comb))] = {
|
||||
'label': _("Attendee name: {part}").format(part=' + '.join(str(c[1]) for c in comb)),
|
||||
'editor_sample': ' '.join(str(scheme['sample'][c[0]]) for c in comb),
|
||||
'evaluate': partial(_get_attendee_name_part, comb)
|
||||
}
|
||||
|
||||
v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
|
||||
v['attendee_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
|
||||
@@ -422,7 +435,9 @@ class Renderer:
|
||||
if not o['content']:
|
||||
return '(error)'
|
||||
if o['content'] == 'other':
|
||||
return o['text'].replace("\n", "<br/>\n")
|
||||
return o['text']
|
||||
elif o['content'].startswith('itemmeta:'):
|
||||
return op.item.meta_data.get(o['content'][9:]) or ''
|
||||
elif o['content'].startswith('meta:'):
|
||||
return ev.meta_data.get(o['content'][5:]) or ''
|
||||
elif o['content'] in self.variables:
|
||||
@@ -454,13 +469,9 @@ class Renderer:
|
||||
textColor=Color(o['color'][0] / 255, o['color'][1] / 255, o['color'][2] / 255),
|
||||
alignment=align_map[o['align']]
|
||||
)
|
||||
text = re.sub(
|
||||
"<br[^>]*>", "<br/>",
|
||||
bleach.clean(
|
||||
self._get_text_content(op, order, o) or "",
|
||||
tags=["br"], attributes={}, styles=[], strip=True
|
||||
)
|
||||
)
|
||||
text = conditional_escape(
|
||||
self._get_text_content(op, order, o) or "",
|
||||
).replace("\n", "<br/>\n")
|
||||
|
||||
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
|
||||
# to resolve all ligatures and python-bidi to switch RTL texts.
|
||||
@@ -488,7 +499,7 @@ class Renderer:
|
||||
p.drawOn(canvas, 0, -h - ad[1])
|
||||
canvas.restoreState()
|
||||
|
||||
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition):
|
||||
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True):
|
||||
for o in self.layout:
|
||||
if o['type'] == "barcodearea":
|
||||
self._draw_barcodearea(canvas, op, o)
|
||||
@@ -498,7 +509,8 @@ class Renderer:
|
||||
self._draw_poweredby(canvas, op, o)
|
||||
if self.bg_pdf:
|
||||
canvas.setPageSize((self.bg_pdf.getPage(0).mediaBox[2], self.bg_pdf.getPage(0).mediaBox[3]))
|
||||
canvas.showPage()
|
||||
if show_page:
|
||||
canvas.showPage()
|
||||
|
||||
def render_background(self, buffer, title=_('Ticket')):
|
||||
if settings.PDFTK:
|
||||
|
||||
@@ -7,7 +7,8 @@ from dateutil import parser
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
BASE_CHOICES = (
|
||||
('date_from', _('Event start')),
|
||||
@@ -115,6 +116,8 @@ class RelativeDateWrapper:
|
||||
base_date_name=parts[3],
|
||||
time=time
|
||||
)
|
||||
if data.base_date_name not in [k[0] for k in BASE_CHOICES]:
|
||||
raise ValueError('{} is not a valid base date'.format(data.base_date_name))
|
||||
else:
|
||||
data = parser.parse(input)
|
||||
return RelativeDateWrapper(data)
|
||||
@@ -321,7 +324,7 @@ class ModelRelativeDateTimeField(models.CharField):
|
||||
return value.to_string()
|
||||
return value
|
||||
|
||||
def from_db_value(self, value, expression, connection, context):
|
||||
def from_db_value(self, value, expression, connection):
|
||||
if value is None:
|
||||
return None
|
||||
return RelativeDateWrapper.from_string(value)
|
||||
@@ -330,3 +333,39 @@ class ModelRelativeDateTimeField(models.CharField):
|
||||
defaults = {'form_class': self.form_class}
|
||||
defaults.update(kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
||||
|
||||
class SerializerRelativeDateField(serializers.CharField):
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if data is None:
|
||||
return None
|
||||
try:
|
||||
r = RelativeDateWrapper.from_string(data)
|
||||
if isinstance(r.data, RelativeDate):
|
||||
if r.data.time is not None:
|
||||
raise ValidationError("Do not specify a time for a date field")
|
||||
return r
|
||||
except:
|
||||
raise ValidationError("Invalid relative date")
|
||||
|
||||
def to_representation(self, value: RelativeDateWrapper):
|
||||
if value is None:
|
||||
return None
|
||||
return value.to_string()
|
||||
|
||||
|
||||
class SerializerRelativeDateTimeField(serializers.CharField):
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if data is None:
|
||||
return None
|
||||
try:
|
||||
return RelativeDateWrapper.from_string(data)
|
||||
except:
|
||||
raise ValidationError("Invalid relative date")
|
||||
|
||||
def to_representation(self, value: RelativeDateWrapper):
|
||||
if value is None:
|
||||
return None
|
||||
return value.to_string()
|
||||
|
||||
224
src/pretix/base/services/cancelevent.py
Normal file
224
src/pretix/base/services/cancelevent.py
Normal file
@@ -0,0 +1,224 @@
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, IntegerField, OuterRef, Subquery
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Event, InvoiceAddress, Order, OrderFee, OrderPosition, OrderRefund,
|
||||
SubEvent, User, WaitingListEntry,
|
||||
)
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.base.services.mail import SendMailException, TolerantDict, mail
|
||||
from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
|
||||
)
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.celery_app import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent):
|
||||
with language(wle.locale):
|
||||
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
|
||||
try:
|
||||
mail(
|
||||
wle.email,
|
||||
str(subject).format_map(TolerantDict(email_context)),
|
||||
message,
|
||||
email_context,
|
||||
wle.event,
|
||||
locale=wle.locale
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Waiting list canceled email could not be sent')
|
||||
|
||||
|
||||
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
|
||||
refund_amount: Decimal, user: User, positions: list):
|
||||
with language(order.locale):
|
||||
try:
|
||||
ia = order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = InvoiceAddress()
|
||||
|
||||
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
|
||||
order=order, position_or_address=ia, event=order.event)
|
||||
real_subject = str(subject).format_map(TolerantDict(email_context))
|
||||
try:
|
||||
order.send_mail(
|
||||
real_subject, message, email_context,
|
||||
'pretix.event.order.email.event_canceled',
|
||||
user,
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order canceled email could not be sent')
|
||||
|
||||
for p in positions:
|
||||
if subevent and p.subevent_id != subevent.id:
|
||||
continue
|
||||
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
real_subject = str(subject).format_map(TolerantDict(email_context))
|
||||
email_context = get_email_context(event_or_subevent=subevent or order.event,
|
||||
event=order.event,
|
||||
refund_amount=refund_amount,
|
||||
position_or_address=p,
|
||||
order=order, position=p)
|
||||
try:
|
||||
order.send_mail(
|
||||
real_subject, message, email_context,
|
||||
'pretix.event.order.email.event_canceled',
|
||||
position=p,
|
||||
user=user
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order canceled email could not be sent to attendee')
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_fixed: str,
|
||||
keep_fee_percentage: str, keep_fees: list=None, manual_refund: bool=False,
|
||||
send: bool=False, send_subject: dict=None, send_message: dict=None,
|
||||
send_waitinglist: bool=False, send_waitinglist_subject: dict={}, send_waitinglist_message: dict={},
|
||||
user: int=None, refund_as_giftcard: bool=False):
|
||||
send_subject = LazyI18nString(send_subject)
|
||||
send_message = LazyI18nString(send_message)
|
||||
send_waitinglist_subject = LazyI18nString(send_waitinglist_subject)
|
||||
send_waitinglist_message = LazyI18nString(send_waitinglist_message)
|
||||
if user:
|
||||
user = User.objects.get(pk=user)
|
||||
|
||||
s = OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||
orders_to_cancel = event.orders.annotate(pcnt=Subquery(s, output_field=IntegerField())).filter(
|
||||
status__in=[Order.STATUS_PAID, Order.STATUS_PENDING, Order.STATUS_EXPIRED],
|
||||
pcnt__gt=0
|
||||
).all()
|
||||
|
||||
if subevent:
|
||||
subevent = event.subevents.get(pk=subevent)
|
||||
|
||||
has_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).filter(
|
||||
subevent=subevent
|
||||
)
|
||||
has_other_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).exclude(
|
||||
subevent=subevent
|
||||
)
|
||||
orders_to_change = orders_to_cancel.annotate(
|
||||
has_subevent=Exists(has_subevent),
|
||||
has_other_subevent=Exists(has_other_subevent),
|
||||
).filter(
|
||||
has_subevent=True, has_other_subevent=True
|
||||
)
|
||||
orders_to_cancel = orders_to_cancel.annotate(
|
||||
has_subevent=Exists(has_subevent),
|
||||
has_other_subevent=Exists(has_other_subevent),
|
||||
).filter(
|
||||
has_subevent=True, has_other_subevent=False
|
||||
)
|
||||
|
||||
subevent.log_action(
|
||||
'pretix.subevent.canceled', user=user,
|
||||
)
|
||||
subevent.active = False
|
||||
subevent.save(update_fields=['active'])
|
||||
subevent.log_action(
|
||||
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
)
|
||||
else:
|
||||
orders_to_change = event.orders.none()
|
||||
event.log_action(
|
||||
'pretix.event.canceled', user=user,
|
||||
)
|
||||
|
||||
for i in event.items.filter(active=True):
|
||||
i.active = False
|
||||
i.save(update_fields=['active'])
|
||||
i.log_action(
|
||||
'pretix.event.item.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
)
|
||||
failed = 0
|
||||
|
||||
for o in orders_to_cancel.only('id', 'total'):
|
||||
try:
|
||||
fee = Decimal('0.00')
|
||||
fee_sum = Decimal('0.00')
|
||||
keep_fee_objects = []
|
||||
if keep_fees:
|
||||
for f in o.fees.all():
|
||||
if f.fee_type in keep_fees:
|
||||
fee += f.value
|
||||
keep_fee_objects.append(f)
|
||||
fee_sum += f.value
|
||||
if keep_fee_percentage:
|
||||
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * (o.total - fee_sum)
|
||||
if keep_fee_fixed:
|
||||
fee += Decimal(keep_fee_fixed)
|
||||
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
|
||||
|
||||
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects)
|
||||
refund_amount = o.payment_refund_sum
|
||||
|
||||
try:
|
||||
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)
|
||||
finally:
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
|
||||
except LockTimeoutException:
|
||||
logger.exception("Could not cancel order")
|
||||
failed += 1
|
||||
except OrderError:
|
||||
logger.exception("Could not cancel order")
|
||||
failed += 1
|
||||
|
||||
for o in orders_to_change.values_list('id', flat=True):
|
||||
with transaction.atomic():
|
||||
o = event.orders.select_for_update().get(pk=o)
|
||||
total = Decimal('0.00')
|
||||
positions = []
|
||||
|
||||
ocm = OrderChangeManager(o, user=user, notify=False)
|
||||
for p in o.positions.all():
|
||||
if p.subevent == subevent:
|
||||
total += p.price
|
||||
ocm.cancel(p)
|
||||
positions.append(p)
|
||||
|
||||
fee = Decimal('0.00')
|
||||
if keep_fee_fixed:
|
||||
fee += Decimal(keep_fee_fixed)
|
||||
if keep_fee_percentage:
|
||||
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * total
|
||||
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
|
||||
if fee:
|
||||
f = OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
|
||||
value=fee,
|
||||
order=o,
|
||||
tax_rule=o.event.settings.tax_rate_default,
|
||||
)
|
||||
f._calculate_tax()
|
||||
ocm.add_fee(f)
|
||||
|
||||
ocm.commit()
|
||||
refund_amount = o.payment_refund_sum - o.total
|
||||
|
||||
if auto_refund:
|
||||
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True, source=OrderRefund.REFUND_SOURCE_ADMIN)
|
||||
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
|
||||
|
||||
for wle in event.waitinglistentries.filter(subevent=subevent, voucher__isnull=True):
|
||||
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, subevent)
|
||||
|
||||
return failed
|
||||
@@ -9,7 +9,7 @@ from django.db import DatabaseError, transaction
|
||||
from django.db.models import Count, Exists, OuterRef, Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext as _
|
||||
from django.utils.translation import gettext as _, pgettext_lazy
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
@@ -213,7 +213,7 @@ class CartManager:
|
||||
has_variations=Count('variations'),
|
||||
).filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)
|
||||
).order_by()
|
||||
})
|
||||
self._variations_cache.update({
|
||||
v.pk: v
|
||||
@@ -221,7 +221,7 @@ class CartManager:
|
||||
'quotas'
|
||||
).select_related('item', 'item__event').filter(
|
||||
id__in=[i for i in variation_ids if i and i not in self._variations_cache]
|
||||
)
|
||||
).order_by()
|
||||
})
|
||||
|
||||
def _check_max_cart_size(self):
|
||||
@@ -303,32 +303,6 @@ class CartManager:
|
||||
if op.item.require_bundling and not op.addon_to == 'FAKE':
|
||||
raise CartError(error_messages['bundled_only'])
|
||||
|
||||
if op.item.max_per_order or op.item.min_per_order:
|
||||
new_total = (
|
||||
len([1 for p in self.positions if p.item_id == op.item.pk]) +
|
||||
sum([_op.count for _op in self._operations + current_ops
|
||||
if isinstance(_op, self.AddOperation) and _op.item == op.item]) +
|
||||
op.count -
|
||||
len([1 for _op in self._operations + current_ops
|
||||
if isinstance(_op, self.RemoveOperation) and _op.position.item_id == op.item.pk])
|
||||
)
|
||||
|
||||
if op.item.max_per_order and new_total > op.item.max_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['max_items_per_product']) % {
|
||||
'max': op.item.max_per_order,
|
||||
'product': op.item.name
|
||||
}
|
||||
)
|
||||
|
||||
if op.item.min_per_order and new_total < op.item.min_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['min_items_per_product']) % {
|
||||
'min': op.item.min_per_order,
|
||||
'product': op.item.name
|
||||
}
|
||||
)
|
||||
|
||||
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
||||
voucher: Optional[Voucher], custom_price: Optional[Decimal],
|
||||
subevent: Optional[SubEvent], cp_is_net: bool=None, force_custom_price=False,
|
||||
@@ -787,37 +761,48 @@ class CartManager:
|
||||
|
||||
return vouchers_ok
|
||||
|
||||
def _check_min_per_product(self):
|
||||
per_product = Counter()
|
||||
min_per_product = {}
|
||||
def _check_min_max_per_product(self):
|
||||
items = Counter()
|
||||
for p in self.positions:
|
||||
per_product[p.item_id] += 1
|
||||
min_per_product[p.item.pk] = p.item.min_per_order
|
||||
|
||||
items[p.item] += 1
|
||||
for op in self._operations:
|
||||
if isinstance(op, self.AddOperation):
|
||||
per_product[op.item.pk] += op.count
|
||||
min_per_product[op.item.pk] = op.item.min_per_order
|
||||
items[op.item] += op.count
|
||||
elif isinstance(op, self.RemoveOperation):
|
||||
per_product[op.position.item_id] -= 1
|
||||
min_per_product[op.position.item.pk] = op.position.item.min_per_order
|
||||
items[op.position.item] -= 1
|
||||
|
||||
err = None
|
||||
for itemid, num in per_product.items():
|
||||
min_p = min_per_product[itemid]
|
||||
if min_p and num < min_p:
|
||||
for item, count in items.items():
|
||||
if count == 0:
|
||||
continue
|
||||
|
||||
if item.max_per_order and count > item.max_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['max_items_per_product']) % {
|
||||
'max': item.max_per_order,
|
||||
'product': item.name
|
||||
}
|
||||
)
|
||||
|
||||
if item.min_per_order and count < item.min_per_order:
|
||||
self._operations = [o for o in self._operations if not (
|
||||
isinstance(o, self.AddOperation) and o.item.pk == itemid
|
||||
isinstance(o, self.AddOperation) and o.item.pk == item.pk
|
||||
)]
|
||||
removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)]
|
||||
for p in self.positions:
|
||||
if p.item_id == itemid and p.pk not in removals:
|
||||
if p.item_id == item.pk and p.pk not in removals:
|
||||
self._operations.append(self.RemoveOperation(position=p))
|
||||
err = _(error_messages['min_items_per_product_removed']) % {
|
||||
'min': min_p,
|
||||
'product': p.item.name
|
||||
'min': item.min_per_order,
|
||||
'product': item.name
|
||||
}
|
||||
|
||||
if not err:
|
||||
raise CartError(
|
||||
_(error_messages['min_items_per_product']) % {
|
||||
'min': item.min_per_order,
|
||||
'product': item.name
|
||||
}
|
||||
)
|
||||
return err
|
||||
|
||||
def _perform_operations(self):
|
||||
@@ -826,7 +811,7 @@ class CartManager:
|
||||
err = None
|
||||
new_cart_positions = []
|
||||
|
||||
err = err or self._check_min_per_product()
|
||||
err = err or self._check_min_max_per_product()
|
||||
|
||||
self._operations.sort(key=lambda a: self.order[type(a)])
|
||||
seats_seen = set()
|
||||
|
||||
@@ -2,12 +2,12 @@ from django.db import transaction
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Order, OrderPosition, Question, QuestionOption,
|
||||
)
|
||||
from pretix.base.signals import order_placed
|
||||
from pretix.base.signals import checkin_created, order_placed
|
||||
|
||||
|
||||
class CheckInError(Exception):
|
||||
@@ -143,6 +143,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
'datetime': dt,
|
||||
'list': clist.pk
|
||||
}, user=user, auth=auth)
|
||||
checkin_created.send(op.order.event, checkin=ci)
|
||||
else:
|
||||
if not force:
|
||||
raise CheckInError(
|
||||
@@ -171,4 +172,5 @@ def order_placed(sender, **kwargs):
|
||||
for op in order.positions.all():
|
||||
for cl in cls:
|
||||
if cl.all_products or op.item_id in {i.pk for i in cl.limit_products.all()}:
|
||||
Checkin.objects.create(position=op, list=cl, auto_checked_in=True)
|
||||
ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True)
|
||||
checkin_created.send(event, checkin=ci)
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Any, Dict
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import override
|
||||
from django.utils.translation import ugettext
|
||||
from django.utils.translation import gettext
|
||||
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import CachedFile, Event, cachedfile_name
|
||||
@@ -26,7 +26,7 @@ def export(event: Event, fileid: str, provider: str, form_data: Dict[str, Any])
|
||||
d = ex.render(form_data)
|
||||
if d is None:
|
||||
raise ExportError(
|
||||
ugettext('Your export did not contain any data.')
|
||||
gettext('Your export did not contain any data.')
|
||||
)
|
||||
file.filename, file.type, data = d
|
||||
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
|
||||
|
||||
@@ -15,7 +15,7 @@ from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
from django.utils.translation import gettext as _, pgettext
|
||||
from django_countries.fields import Country
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
@@ -37,6 +37,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@transaction.atomic
|
||||
def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.locale = invoice.event.settings.get('invoice_language', invoice.event.settings.locale)
|
||||
if invoice.locale == '__user__':
|
||||
invoice.locale = invoice.order.locale or invoice.event.settings.locale
|
||||
|
||||
lp = invoice.order.payments.last()
|
||||
|
||||
with language(invoice.locale):
|
||||
@@ -85,6 +89,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
).split("\n") if a.strip()
|
||||
)
|
||||
invoice.internal_reference = ia.internal_reference
|
||||
invoice.custom_field = ia.custom_field
|
||||
invoice.invoice_to_company = ia.company
|
||||
invoice.invoice_to_name = ia.name
|
||||
invoice.invoice_to_street = ia.street
|
||||
@@ -249,17 +254,11 @@ def regenerate_invoice(invoice: Invoice):
|
||||
|
||||
|
||||
def generate_invoice(order: Order, trigger_pdf=True):
|
||||
locale = order.event.settings.get('invoice_language', order.event.settings.locale)
|
||||
if locale:
|
||||
if locale == '__user__':
|
||||
locale = order.locale or order.event.settings.locale
|
||||
|
||||
invoice = Invoice(
|
||||
order=order,
|
||||
event=order.event,
|
||||
organizer=order.event.organizer,
|
||||
date=timezone.now().date(),
|
||||
locale=locale
|
||||
)
|
||||
invoice = build_invoice(invoice)
|
||||
if trigger_pdf:
|
||||
@@ -313,7 +312,7 @@ def build_preview_invoice_pdf(event):
|
||||
|
||||
with rolledback_transaction(), language(locale):
|
||||
order = event.orders.create(status=Order.STATUS_PENDING, datetime=timezone.now(),
|
||||
expires=timezone.now(), code="PREVIEW", total=119)
|
||||
expires=timezone.now(), code="PREVIEW", total=100 * event.tax_rules.count())
|
||||
invoice = Invoice(
|
||||
order=order, event=event, invoice_no="PREVIEW",
|
||||
date=timezone.now().date(), locale=locale, organizer=event.organizer
|
||||
@@ -351,7 +350,7 @@ def build_preview_invoice_pdf(event):
|
||||
|
||||
if event.tax_rules.exists():
|
||||
for i, tr in enumerate(event.tax_rules.all()):
|
||||
tax = tr.tax(Decimal('100.00'))
|
||||
tax = tr.tax(Decimal('100.00'), base_price_is='gross')
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=_("Sample product {}").format(i + 1),
|
||||
gross_value=tax.gross, tax_value=tax.tax,
|
||||
|
||||
@@ -20,7 +20,7 @@ from django.core.mail import (
|
||||
)
|
||||
from django.core.mail.message import SafeMIMEText
|
||||
from django.template.loader import get_template
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
from django.utils.translation import gettext as _, pgettext
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
@@ -126,7 +126,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
renderer = ClassicMailRenderer(None)
|
||||
content_plain = body_plain = render_mail(template, context)
|
||||
subject = str(subject).format_map(TolerantDict(context))
|
||||
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
|
||||
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM) or settings.MAIL_FROM
|
||||
if event:
|
||||
sender_name = event.settings.mail_from_name or str(event.name)
|
||||
sender = formataddr((sender_name, sender))
|
||||
@@ -276,20 +276,6 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
cm = lambda: scopes_disabled() # noqa
|
||||
|
||||
with cm():
|
||||
if invoices:
|
||||
invoices = Invoice.objects.filter(pk__in=invoices)
|
||||
for inv in invoices:
|
||||
if inv.file:
|
||||
try:
|
||||
with language(inv.order.locale):
|
||||
email.attach(
|
||||
pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf',
|
||||
inv.file.file.read(),
|
||||
'application/pdf'
|
||||
)
|
||||
except:
|
||||
logger.exception('Could not attach invoice to email')
|
||||
pass
|
||||
if event:
|
||||
if order:
|
||||
try:
|
||||
@@ -344,6 +330,21 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
|
||||
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
|
||||
|
||||
if invoices:
|
||||
invoices = Invoice.objects.filter(pk__in=invoices)
|
||||
for inv in invoices:
|
||||
if inv.file:
|
||||
try:
|
||||
with language(inv.order.locale):
|
||||
email.attach(
|
||||
pgettext('invoice', 'Invoice {num}').format(num=inv.number).replace(' ', '_') + '.pdf',
|
||||
inv.file.file.read(),
|
||||
'application/pdf'
|
||||
)
|
||||
except:
|
||||
logger.exception('Could not attach invoice to email')
|
||||
pass
|
||||
|
||||
email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order)
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.conf import settings
|
||||
from django.template.loader import get_template
|
||||
from django.utils.timezone import override
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
@@ -79,7 +80,7 @@ def send_notification(logentry_id: int, action_type: str, user_id: int, method:
|
||||
if not notification_type:
|
||||
return # Ignore, e.g. plugin not active for this event
|
||||
|
||||
with language(user.locale):
|
||||
with language(user.locale), override(logentry.event.timezone if logentry.event else user.timezone):
|
||||
notification = notification_type.build_notification(logentry)
|
||||
|
||||
if method == "mail":
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user