forked from CGM_Public/pretix_original
Compare commits
276
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bbe605439 | ||
|
|
8267da66e4 | ||
|
|
481db3cb88 | ||
|
|
435d90474a | ||
|
|
fcc4170a4a | ||
|
|
c7f345e98e | ||
|
|
d30fbf4e6a | ||
|
|
5326aa7486 | ||
|
|
53147c0f0c | ||
|
|
fe31318413 | ||
|
|
bb4821eeb5 | ||
|
|
003d958cc5 | ||
|
|
93089d87e3 | ||
|
|
f79d17cb6a | ||
|
|
0e8db3181c | ||
|
|
23031642bd | ||
|
|
93cca34eab | ||
|
|
e29c8a1708 | ||
|
|
acfec59abc | ||
|
|
7adf203863 | ||
|
|
3c2de09216 | ||
|
|
26a96f107f | ||
|
|
819dd7eee6 | ||
|
|
ccc662228c | ||
|
|
99a2fde373 | ||
|
|
dda48d92c6 | ||
|
|
0a1429ed60 | ||
|
|
8487a5446d | ||
|
|
64833c0bab | ||
|
|
3f40525af5 | ||
|
|
7b6b3b1348 | ||
|
|
0b9f4cd739 | ||
|
|
885eefbcb0 | ||
|
|
573757e2bf | ||
|
|
c5a2bd35b7 | ||
|
|
4b65b94bd5 | ||
|
|
d716f7e014 | ||
|
|
d85ddb5bda | ||
|
|
b1db5dbb3e | ||
|
|
fed389b990 | ||
|
|
1ce613ff89 | ||
|
|
44bef85b66 | ||
|
|
49c4acefd0 | ||
|
|
61e111742d | ||
|
|
b2274039b3 | ||
|
|
4913190730 | ||
|
|
276a087fdb | ||
|
|
3639f2cea1 | ||
|
|
692e9c38f1 | ||
|
|
dd4075b2cc | ||
|
|
b549cb451a | ||
|
|
576132b2d0 | ||
|
|
e0c432d014 | ||
|
|
b66a35df7a | ||
|
|
2e1347cf9a | ||
|
|
8d1c9e44fc | ||
|
|
a187a02daa | ||
|
|
b9d100b5a8 | ||
|
|
f1e097c1b1 | ||
|
|
91a5b1546a | ||
|
|
3532f9c5a9 | ||
|
|
884e54180a | ||
|
|
e17ddb0cc8 | ||
|
|
55edc8a3d6 | ||
|
|
bd79a93737 | ||
|
|
12ab260eb1 | ||
|
|
30f0318de6 | ||
|
|
52e072e68f | ||
|
|
f25bb571b9 | ||
|
|
ae71492902 | ||
|
|
57375eb9b6 | ||
|
|
0657ef2e0c | ||
|
|
f63907fb16 | ||
|
|
e266d3808f | ||
|
|
180f9a356f | ||
|
|
480b71bd50 | ||
|
|
79839e3735 | ||
|
|
6ba5c58556 | ||
|
|
a5e3bab107 | ||
|
|
4dcce70ab3 | ||
|
|
8a5332f415 | ||
|
|
58ce1cbab7 | ||
|
|
27c3e5d875 | ||
|
|
caac517c0d | ||
|
|
58b9052164 | ||
|
|
2d223a9e11 | ||
|
|
fe37ab9286 | ||
|
|
95cc661a05 | ||
|
|
9a98d16949 | ||
|
|
50ba019a07 | ||
|
|
7d3e9b1777 | ||
|
|
f82640d763 | ||
|
|
d84cd71a5c | ||
|
|
74105ddd53 | ||
|
|
3a2f915ac9 | ||
|
|
9024a552a9 | ||
|
|
bae9fab2c4 | ||
|
|
ee3cd6d465 | ||
|
|
ccdcd380fa | ||
|
|
3c0f0434cd | ||
|
|
58dba57bef | ||
|
|
9178aef323 | ||
|
|
e9d696ea5e | ||
|
|
983ffdd8a8 | ||
|
|
294d47ccfc | ||
|
|
a14b1a5a14 | ||
|
|
a28c5f71c9 | ||
|
|
35bd9d1c22 | ||
|
|
b070fc0297 | ||
|
|
f7fd3596a6 | ||
|
|
3b4f758c82 | ||
|
|
df8c8f2063 | ||
|
|
ebb6b5b469 | ||
|
|
16ad39bb16 | ||
|
|
6ca65edde9 | ||
|
|
02684a0fcd | ||
|
|
141ba6e50d | ||
|
|
6681eb1a27 | ||
|
|
2b515ea30c | ||
|
|
7997882e24 | ||
|
|
a8190258a4 | ||
|
|
9376a26709 | ||
|
|
d8e2e0e217 | ||
|
|
f9c942bc6f | ||
|
|
f9b7696366 | ||
|
|
2143135285 | ||
|
|
54146bb9e8 | ||
|
|
d1f702cafd | ||
|
|
54e7b8da89 | ||
|
|
25af386d87 | ||
|
|
51fa9e78dd | ||
|
|
6cf244bb4b | ||
|
|
6a0e3b1b46 | ||
|
|
571b0e9aa8 | ||
|
|
8075d3e385 | ||
|
|
411f5c358f | ||
|
|
94d3eff799 | ||
|
|
a073d66213 | ||
|
|
7b2dda9cd9 | ||
|
|
e9c66f5bb1 | ||
|
|
24aa8fc033 | ||
|
|
ee74f75913 | ||
|
|
b484675aeb | ||
|
|
88379d7c25 | ||
|
|
4e8bdb4427 | ||
|
|
f68c29ca95 | ||
|
|
eb7154a55b | ||
|
|
ed19cc99f3 | ||
|
|
51ae1e5e33 | ||
|
|
132f8d8cb3 | ||
|
|
cd4b4b98b8 | ||
|
|
07e0ffd4f3 | ||
|
|
1d2a6d55b9 | ||
|
|
33b893b0ba | ||
|
|
03ebe0e528 | ||
|
|
28797b8cc6 | ||
|
|
691ba3a1a7 | ||
|
|
656673ccde | ||
|
|
7073622ab3 | ||
|
|
b8f71d2428 | ||
|
|
2a8bdc29f4 | ||
|
|
a6da1bb4e9 | ||
|
|
d1e67d38d9 | ||
|
|
a5214d459c | ||
|
|
9ad2891d17 | ||
|
|
4b2d55a2fb | ||
|
|
5cfed32d61 | ||
|
|
06ccd83921 | ||
|
|
ba417b6e3c | ||
|
|
f7ed0236f3 | ||
|
|
4491b80786 | ||
|
|
37bcb520cc | ||
|
|
782e957c3a | ||
|
|
953890c269 | ||
|
|
60d9c1080a | ||
|
|
364e7cefda | ||
|
|
33accf3250 | ||
|
|
be4d9ac00e | ||
|
|
8ca5e4dd54 | ||
|
|
1394cf3148 | ||
|
|
de58b35bf4 | ||
|
|
490b421d53 | ||
|
|
61d45f26dd | ||
|
|
a8b0475c6d | ||
|
|
31cf94eb02 | ||
|
|
dc0590ea91 | ||
|
|
bc5e5d0a27 | ||
|
|
0fc448fbd3 | ||
|
|
67d5c1ccad | ||
|
|
779ad11640 | ||
|
|
70e9d9faad | ||
|
|
51f5b0645a | ||
|
|
b3436c1a93 | ||
|
|
9eef5d5d6d | ||
|
|
e139de3c19 | ||
|
|
74f861bd48 | ||
|
|
35c02f35d7 | ||
|
|
d5c0b0f71d | ||
|
|
6c701d66b1 | ||
|
|
8d62b509a2 | ||
|
|
9e0b97e88e | ||
|
|
28a5519881 | ||
|
|
363826e294 | ||
|
|
eb8ea6d477 | ||
|
|
77be4d835b | ||
|
|
c6390520a7 | ||
|
|
594803ec17 | ||
|
|
32ce3a4319 | ||
|
|
d3f01832fe | ||
|
|
bba702489d | ||
|
|
85fe7e55be | ||
|
|
92c9216fbd | ||
|
|
db63e20708 | ||
|
|
e2ce35a85b | ||
|
|
d39964b021 | ||
|
|
59beba5069 | ||
|
|
1bd3a63959 | ||
|
|
1d644e90c9 | ||
|
|
e0e66c903e | ||
|
|
bc08bdebb5 | ||
|
|
edd92ac34d | ||
|
|
f1bce0c08b | ||
|
|
68c24ebea3 | ||
|
|
d22a7844ea | ||
|
|
6238e1df98 | ||
|
|
6acca4c4ba | ||
|
|
1a9f6e49d4 | ||
|
|
efa1d2683e | ||
|
|
9b39d34f81 | ||
|
|
96c5c8c4ff | ||
|
|
3254ac36a2 | ||
|
|
52d10957a1 | ||
|
|
f9d4669423 | ||
|
|
6e220cbbd8 | ||
|
|
036a555374 | ||
|
|
861a41c95f | ||
|
|
e2abc19fe3 | ||
|
|
97fc226e07 | ||
|
|
d73c98bff0 | ||
|
|
aa186f7a09 | ||
|
|
1b434b40d2 | ||
|
|
71475c5863 | ||
|
|
71b544d951 | ||
|
|
b0685437f1 | ||
|
|
2d99828eab | ||
|
|
2ffc1b8eaf | ||
|
|
893f47d365 | ||
|
|
7de1fca2f4 | ||
|
|
c6b18b31a1 | ||
|
|
ecc9c7f39f | ||
|
|
b9aba9cf56 | ||
|
|
33f0892052 | ||
|
|
4bf3d48549 | ||
|
|
32aa4b4f3e | ||
|
|
e1992bb99f | ||
|
|
45e98546d6 | ||
|
|
c7774dfdb8 | ||
|
|
6c582b8f8c | ||
|
|
5f82db3949 | ||
|
|
2b818f42cd | ||
|
|
b19df33dda | ||
|
|
dba8761bc5 | ||
|
|
0311c0251a | ||
|
|
5b99bf3623 | ||
|
|
4137e0fc1f | ||
|
|
b32c6033f1 | ||
|
|
de0e700fec | ||
|
|
00bc5f4fae | ||
|
|
6ef3603d9f | ||
|
|
2c7cefea35 | ||
|
|
a10b31cacb | ||
|
|
4e9e925b32 | ||
|
|
f4415cf906 | ||
|
|
bf4fcfd914 | ||
|
|
7021c178ab | ||
|
|
5d8e3e28d6 |
@@ -25,8 +25,6 @@ matrix:
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.7
|
||||
env: JOB=plugins
|
||||
- python: 3.7
|
||||
env: JOB=doc-spelling
|
||||
- python: 3.7
|
||||
|
||||
@@ -78,6 +78,10 @@ Example::
|
||||
Enables or disables nagging staff users for leaving comments on their sessions for auditability.
|
||||
Defaults to ``off``.
|
||||
|
||||
``obligatory_2fa``
|
||||
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
|
||||
Defaults to ``False``
|
||||
|
||||
|
||||
Locale settings
|
||||
---------------
|
||||
@@ -273,6 +277,24 @@ to speed up various operations::
|
||||
If redis is not configured, pretix will store sessions and locks in the database. If memcached
|
||||
is configured, memcached will be used for caching instead of redis.
|
||||
|
||||
Translations
|
||||
------------
|
||||
|
||||
pretix comes with a number of translations. Some of them are marked as "incubating", which means
|
||||
they can usually only be selected in development mode. If you want to use them nevertheless, you
|
||||
can activate them like this::
|
||||
|
||||
[languages]
|
||||
allow_incubating=pt-br,da
|
||||
|
||||
You can also tell pretix about additional paths where it will search for translations::
|
||||
|
||||
[languages]
|
||||
path=/path/to/my/translations
|
||||
|
||||
For a given language (e.g. ``pt-br``), pretix will then look in the
|
||||
specific sub-folder, e.g. ``/path/to/my/translations/pt_BR/LC_MESSAGES/django.po``.
|
||||
|
||||
Celery task queue
|
||||
-----------------
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
pretix Hosted billing invoices
|
||||
==============================
|
||||
|
||||
This endpoint allows you to access invoices you received for pretix Hosted. It only contains invoices created starting
|
||||
November 2017.
|
||||
|
||||
.. note:: Only available on pretix Hosted, not on self-hosted pretix instances.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
invoice_number string Invoice number
|
||||
date_issued date Invoice date
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/
|
||||
|
||||
Returns a list of all invoices to a given organizer.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/billing_invoices/ 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": [
|
||||
{
|
||||
"invoice_number": "R2019002",
|
||||
"date_issued": "2019-06-03"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_issued`` and
|
||||
its reverse, ``-date_issued``. Default: ``date_issued``.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/(invoice_number)/
|
||||
|
||||
Returns information on one invoice, identified by its invoice number.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/billing_invoices/R2019002/ 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
|
||||
|
||||
{
|
||||
"invoice_number": "R2019002",
|
||||
"date_issued": "2019-06-03"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param invoice_number: The ``invoice_number`` field of the invoice to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/(invoice_number)/download/
|
||||
|
||||
Download an invoice in PDF format.
|
||||
|
||||
.. warning:: After we created the invoices, they are placed in review with our accounting department. You will
|
||||
already see them in the API at this point, but you are not able to download them until they completed
|
||||
review and are sent to you via email. This usually takes a few hours. If you try to download them
|
||||
in this time frame, you will receive a status code :http:statuscode:`423`.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/billing_invoices/R2019002/download/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/pdf
|
||||
|
||||
...
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param invoice_number: The ``invoice_number`` field of the invoice to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 423: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||
seconds.
|
||||
@@ -36,12 +36,20 @@ answers list of objects Answers to user
|
||||
├ question_identifier string The question's ``identifier`` field
|
||||
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
|
||||
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
|
||||
seat objects The assigned seat. Can be ``null``.
|
||||
├ id integer Internal ID of the seat instance
|
||||
├ name string Human-readable seat name
|
||||
└ seat_guid string Identifier of the seat within the seating plan
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.17
|
||||
|
||||
This resource has been added.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
This ``seat`` attribute has been added.
|
||||
|
||||
|
||||
Cart position endpoints
|
||||
-----------------------
|
||||
@@ -87,6 +95,7 @@ Cart position endpoints
|
||||
"datetime": "2018-06-11T10:00:00Z",
|
||||
"expires": "2018-06-11T10:00:00Z",
|
||||
"includes_tax": true,
|
||||
"seat": null,
|
||||
"answers": []
|
||||
}
|
||||
]
|
||||
@@ -132,6 +141,7 @@ Cart position endpoints
|
||||
"datetime": "2018-06-11T10:00:00Z",
|
||||
"expires": "2018-06-11T10:00:00Z",
|
||||
"includes_tax": true,
|
||||
"seat": null,
|
||||
"answers": []
|
||||
}
|
||||
|
||||
@@ -178,6 +188,7 @@ Cart position endpoints
|
||||
* ``item``
|
||||
* ``variation`` (optional)
|
||||
* ``price``
|
||||
* ``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`` (optional)
|
||||
* ``attendee_email`` (optional)
|
||||
* ``subevent`` (optional)
|
||||
|
||||
@@ -396,6 +396,7 @@ Order position endpoints
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 1,
|
||||
@@ -505,6 +506,7 @@ Order position endpoints
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 1,
|
||||
|
||||
@@ -27,9 +27,13 @@ presale_end datetime The date at whi
|
||||
location multi-lingual string The event location (or ``null``)
|
||||
has_subevents boolean ``true`` if the event series feature is active for this
|
||||
event. Cannot change after event is created.
|
||||
meta_data dict Values set for organizer-specific meta data parameters.
|
||||
meta_data object Values set for organizer-specific meta data parameters.
|
||||
plugins list A list of package names of the enabled plugins for this
|
||||
event.
|
||||
seating_plan integer If reserved seating is in use, the ID of a seating
|
||||
plan. Otherwise ``null``.
|
||||
seat_category_mapping object An object mapping categories of the seating plan
|
||||
(strings) to items in the event (integers or ``null``).
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -50,6 +54,14 @@ plugins list A list of packa
|
||||
|
||||
The ``testmode`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.8
|
||||
|
||||
When cloning events, the ``testmode`` attribute will now be cloned, too.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -95,6 +107,8 @@ Endpoints
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer"
|
||||
"pretix.plugins.stripe"
|
||||
@@ -112,6 +126,9 @@ Endpoints
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned.
|
||||
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned.
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_from`` and
|
||||
``slug``. Keep in mind that ``date_from`` of event series does not really tell you anything.
|
||||
Default: ``slug``.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
@@ -153,6 +170,8 @@ Endpoints
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer"
|
||||
@@ -198,6 +217,8 @@ Endpoints
|
||||
"is_public": false,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
@@ -228,6 +249,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
@@ -246,7 +269,7 @@ Endpoints
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/
|
||||
|
||||
Creates a new event with properties as set in the request body. The properties that are copied are: 'is_public',
|
||||
settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
|
||||
`testmode`, settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
|
||||
|
||||
If the 'plugins' and/or 'is_public' fields are present in the post body this will determine their value. Otherwise
|
||||
their value will be copied from the existing event.
|
||||
@@ -277,6 +300,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"has_subevents": false,
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
@@ -307,6 +332,8 @@ Endpoints
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.stripe",
|
||||
@@ -368,6 +395,8 @@ Endpoints
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"meta_data": {},
|
||||
"plugins": [
|
||||
"pretix.plugins.banktransfer",
|
||||
|
||||
@@ -23,3 +23,5 @@ Resources and endpoints
|
||||
waitinglist
|
||||
carts
|
||||
webhooks
|
||||
seatingplans
|
||||
billing_invoices
|
||||
|
||||
@@ -176,6 +176,10 @@ answers list of objects Answers to user
|
||||
├ question_identifier string The question's ``identifier`` field
|
||||
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
|
||||
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
|
||||
seat objects The assigned seat. Can be ``null``.
|
||||
├ id integer Internal ID of the seat instance
|
||||
├ name string Human-readable seat name
|
||||
└ seat_guid string Identifier of the seat within the seating plan
|
||||
pdf_data object Data object required for ticket PDF generation. By default,
|
||||
this field is missing. It will be added only if you add the
|
||||
``pdf_data=true`` query parameter to your request.
|
||||
@@ -197,6 +201,10 @@ pdf_data object Data object req
|
||||
|
||||
The attributes ``pseudonymization_id`` and ``pdf_data`` have been added.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The attribute ``seat`` has been added.
|
||||
|
||||
.. _order-payment-resource:
|
||||
|
||||
Order payment resource
|
||||
@@ -328,6 +336,7 @@ List of all orders
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
@@ -470,6 +479,7 @@ Fetching individual orders
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
@@ -737,7 +747,7 @@ Creating orders
|
||||
then call the ``mark_paid`` API method.
|
||||
* ``testmode`` (optional) – Defaults to ``false``
|
||||
* ``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 that become free by this operation will be credited to your order
|
||||
order creation is successful. Any quotas or seats that become free by this operation will be credited to your order
|
||||
creation.
|
||||
* ``email``
|
||||
* ``locale``
|
||||
@@ -771,6 +781,7 @@ Creating orders
|
||||
* ``item``
|
||||
* ``variation``
|
||||
* ``price``
|
||||
* ``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_email``
|
||||
* ``secret`` (optional)
|
||||
@@ -1287,6 +1298,7 @@ List of all order positions
|
||||
"tax_value": "0.00",
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"seat": null,
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"checkins": [
|
||||
@@ -1389,6 +1401,7 @@ Fetching individual positions
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"pseudonymization_id": "MQLJvANO3B",
|
||||
"seat": null,
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
|
||||
@@ -56,6 +56,8 @@ Endpoints
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``slug`` and
|
||||
``name``. Default: ``slug``.
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
.. _`rest-seatingplans`:
|
||||
|
||||
Seating plans
|
||||
=============
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The seating plan resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the plan
|
||||
name string Human-readable name of the plan
|
||||
layout object JSON representation of the seating plan. These
|
||||
representations follow a JSON schema that currently
|
||||
still evolves. The version in use can be found `here`_.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
This endpoint has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/seatingplans/
|
||||
|
||||
Returns a list of all seating plans within a given organizer.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/seatingplans/ 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": 2,
|
||||
"name": "Main plan",
|
||||
"layout": { … }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
: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)/seatingplans/(id)/
|
||||
|
||||
Returns information on one plan, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/seatingplans/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": 2,
|
||||
"name": "Main plan",
|
||||
"layout": { … }
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param id: The ``id`` field of the seating plan 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)/seatingplans/
|
||||
|
||||
Creates a new seating plan
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/seatingplans/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"name": "Main plan",
|
||||
"layout": { … }
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Main plan",
|
||||
"layout": { … }
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a seating plan for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The seating plan 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)/seatingplans/(id)/
|
||||
|
||||
Update a plan. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` field. **You can not change a plan while it is in use for
|
||||
any events.**
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/seatingplans/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"name": "Old plan"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Old plan",
|
||||
"layout": { … }
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the plan to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The plan 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 **or** the plan is currently in use.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/seatingplans/(id)/
|
||||
|
||||
Delete a plan. You can not delete plans which are currently in use by any events.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/seatingplans/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 id: The ``id`` field of the plan to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource **or** the plan is currently in use.
|
||||
|
||||
|
||||
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/seating/seating-plan.schema.json
|
||||
@@ -36,7 +36,11 @@ variation_price_overrides list of objects List of variati
|
||||
the default price
|
||||
├ variation integer The internal variation ID
|
||||
└ price money (string) The price or ``null`` for the default price
|
||||
meta_data dict Values set for organizer-specific meta data parameters.
|
||||
meta_data object Values set for organizer-specific meta data parameters.
|
||||
seating_plan integer If reserved seating is in use, the ID of a seating
|
||||
plan. Otherwise ``null``.
|
||||
seat_category_mapping object An object mapping categories of the seating plan
|
||||
(strings) to items in the event (integers or ``null``).
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
@@ -54,6 +58,10 @@ meta_data dict Values set for
|
||||
|
||||
The attribute ``is_public`` has been added.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -93,6 +101,8 @@ Endpoints
|
||||
"date_admission": null,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"location": null,
|
||||
"item_price_overrides": [
|
||||
{
|
||||
@@ -142,6 +152,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
@@ -172,6 +184,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
@@ -223,6 +237,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
@@ -287,6 +303,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
@@ -371,6 +389,8 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"seating_plan": null,
|
||||
"seat_category_mapping": {},
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
|
||||
@@ -41,6 +41,7 @@ quota integer An ID of a quot
|
||||
tag string A string that is used for grouping vouchers
|
||||
comment string An internal comment on the voucher
|
||||
subevent integer ID of the date inside an event series this voucher belongs to (or ``null``).
|
||||
show_hidden_items boolean Only if set to ``true``, this voucher allows to buy products with the property ``hide_without_voucher``. Defaults to ``true``.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -48,6 +49,10 @@ subevent integer ID of the date
|
||||
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The attribute ``show_hidden_items`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
|
||||
+2
-2
@@ -66,7 +66,7 @@ source_suffix = '.rst'
|
||||
#source_encoding = 'utf-8-sig'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'contents'
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = 'pretix'
|
||||
@@ -234,7 +234,7 @@ latex_elements = {
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
('contents', 'pretix.tex', 'pretix Documentation',
|
||||
('index', 'pretix.tex', 'pretix Documentation',
|
||||
'Raphael Michel', 'manual'),
|
||||
]
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Core
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
|
||||
item_copy_data, register_sales_channels, register_global_settings
|
||||
item_copy_data, register_sales_channels, register_global_settings, quota_availability
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
@@ -20,13 +20,17 @@ Order events
|
||||
There are multiple signals that will be sent out in the ordering cycle:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: validate_cart, 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
|
||||
:members: validate_cart, 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
|
||||
|
||||
Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, 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, checkout_flow_steps, order_info, order_meta_from_request
|
||||
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, 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
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: order_info, order_meta_from_request
|
||||
|
||||
Request flow
|
||||
""""""""""""
|
||||
@@ -45,11 +49,11 @@ Backend
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
|
||||
order_info, event_settings_widget, oauth_application_registered, order_position_buttons
|
||||
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, nav_item, subevent_forms
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: logentry_display, logentry_object_link, requiredaction_display
|
||||
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events
|
||||
|
||||
Vouchers
|
||||
""""""""
|
||||
|
||||
@@ -21,10 +21,12 @@ Your should install the following on your system:
|
||||
* Python 3.5 or newer
|
||||
* ``pip`` for Python 3 (Debian package: ``python3-pip``)
|
||||
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
|
||||
* On Debian/Ubuntu: ``python-venv`` for Python 3 (Debian package: ``python3-venv``)
|
||||
* ``libffi`` (Debian package: ``libffi-dev``)
|
||||
* ``libssl`` (Debian package: ``libssl-dev``)
|
||||
* ``libxml2`` (Debian package ``libxml2-dev``)
|
||||
* ``libxslt`` (Debian package ``libxslt1-dev``)
|
||||
* ``libenchant1c2a`` (Debian package ``libenchant1c2a``)
|
||||
* ``msgfmt`` (Debian package ``gettext``)
|
||||
* ``git``
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ frontend
|
||||
frontpage
|
||||
gettext
|
||||
gunicorn
|
||||
guid
|
||||
hardcoded
|
||||
hostname
|
||||
idempotency
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.7.0"
|
||||
__version__ = "2.9.0.dev0"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
|
||||
@@ -12,7 +13,8 @@ class DeviceTokenAuthentication(TokenAuthentication):
|
||||
def authenticate_credentials(self, key):
|
||||
model = self.get_model()
|
||||
try:
|
||||
device = model.objects.select_related('organizer').get(api_token=key)
|
||||
with scopes_disabled():
|
||||
device = model.objects.select_related('organizer').get(api_token=key)
|
||||
except model.DoesNotExist:
|
||||
raise exceptions.AuthenticationFailed('Invalid token.')
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.base.models import Device, Event, User
|
||||
from pretix.base.models.auth import SuperuserPermissionSet
|
||||
from pretix.base.models.organizer import Organizer, TeamAPIToken
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.helpers.security import (
|
||||
SessionInvalid, SessionReauthRequired, assert_session_valid,
|
||||
)
|
||||
@@ -50,9 +50,6 @@ class EventPermission(BasePermission):
|
||||
return False
|
||||
|
||||
elif 'organizer' in request.resolver_match.kwargs:
|
||||
request.organizer = Organizer.objects.filter(
|
||||
slug=request.resolver_match.kwargs['organizer'],
|
||||
).first()
|
||||
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer, request=request):
|
||||
return False
|
||||
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
|
||||
|
||||
@@ -4,10 +4,13 @@ from hashlib import sha1
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.urls import resolve
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scope
|
||||
from rest_framework import status
|
||||
|
||||
from pretix.api.models import ApiCall
|
||||
from pretix.base.models import Organizer
|
||||
|
||||
|
||||
class IdempotencyMiddleware:
|
||||
@@ -89,3 +92,21 @@ class IdempotencyMiddleware:
|
||||
for k, v in json.loads(call.response_headers).values():
|
||||
r[k] = v
|
||||
return r
|
||||
|
||||
|
||||
class ApiScopeMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest):
|
||||
if not request.path.startswith('/api/'):
|
||||
return self.get_response(request)
|
||||
|
||||
url = resolve(request.path_info)
|
||||
if 'organizer' in url.kwargs:
|
||||
request.organizer = Organizer.objects.filter(
|
||||
slug=url.kwargs['organizer'],
|
||||
).first()
|
||||
|
||||
with scope(organizer=getattr(request, 'organizer', None)):
|
||||
return self.get_response(request)
|
||||
|
||||
@@ -8,31 +8,33 @@ from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
AnswerCreateSerializer, AnswerSerializer,
|
||||
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
|
||||
)
|
||||
from pretix.base.models import Quota
|
||||
from pretix.base.models import Quota, Seat
|
||||
from pretix.base.models.orders import CartPosition
|
||||
|
||||
|
||||
class CartPositionSerializer(I18nAwareModelSerializer):
|
||||
answers = AnswerSerializer(many=True)
|
||||
seat = InlineSeatSerializer()
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
|
||||
'answers',)
|
||||
'answers', 'seat')
|
||||
|
||||
|
||||
class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
expires = serializers.DateTimeField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
seat = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'subevent', 'expires', 'includes_tax', 'answers',)
|
||||
'subevent', 'expires', 'includes_tax', 'answers', 'seat')
|
||||
|
||||
def create(self, validated_data):
|
||||
answers_data = validated_data.pop('answers')
|
||||
@@ -71,6 +73,22 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
|
||||
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
|
||||
if validated_data.get('seat'):
|
||||
if not seated:
|
||||
raise ValidationError('The specified product does not allow to choose a seat.')
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError('The specified seat does not exist.')
|
||||
else:
|
||||
validated_data['seat'] = seat
|
||||
if not seat.is_available():
|
||||
raise ValidationError(ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
|
||||
elif seated:
|
||||
raise ValidationError('The specified product requires to choose a seat.')
|
||||
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
|
||||
@@ -11,6 +11,9 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import Event, TaxRule
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
||||
from pretix.base.services.seating import (
|
||||
SeatProtected, generate_seats, validate_plan_change,
|
||||
)
|
||||
|
||||
|
||||
class MetaDataField(Field):
|
||||
@@ -26,6 +29,22 @@ class MetaDataField(Field):
|
||||
}
|
||||
|
||||
|
||||
class SeatCategoryMappingField(Field):
|
||||
|
||||
def to_representation(self, value):
|
||||
qs = value.seat_category_mappings.all()
|
||||
if isinstance(value, Event):
|
||||
qs = qs.filter(subevent=None)
|
||||
return {
|
||||
v.layout_category: v.product_id for v in qs
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return {
|
||||
'seat_category_mapping': data or {}
|
||||
}
|
||||
|
||||
|
||||
class PluginsField(Field):
|
||||
|
||||
def to_representation(self, obj):
|
||||
@@ -45,12 +64,14 @@ class PluginsField(Field):
|
||||
class EventSerializer(I18nAwareModelSerializer):
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
plugins = PluginsField(required=False, source='*')
|
||||
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
|
||||
'date_to', 'date_admission', 'is_public', 'presale_start',
|
||||
'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins')
|
||||
'presale_end', 'location', 'has_subevents', 'meta_data', 'seating_plan',
|
||||
'plugins', 'seat_category_mapping')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -61,6 +82,9 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
Event.clean_dates(data.get('date_from'), data.get('date_to'))
|
||||
Event.clean_presale(data.get('presale_start'), data.get('presale_end'))
|
||||
|
||||
if full_data.get('has_subevents') and full_data.get('seating_plan'):
|
||||
raise ValidationError('Event series should not directly be assigned a seating plan.')
|
||||
|
||||
return data
|
||||
|
||||
def validate_has_subevents(self, value):
|
||||
@@ -92,6 +116,27 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
|
||||
return value
|
||||
|
||||
def validate_seating_plan(self, value):
|
||||
if value and value.organizer != self.context['request'].organizer:
|
||||
raise ValidationError('Invalid seating plan.')
|
||||
if self.instance and self.instance.pk:
|
||||
try:
|
||||
validate_plan_change(self.instance, None, value)
|
||||
except SeatProtected as e:
|
||||
raise ValidationError(str(e))
|
||||
return value
|
||||
|
||||
def validate_seat_category_mapping(self, value):
|
||||
if value and (not self.instance or not self.instance.pk):
|
||||
raise ValidationError('You cannot specify seat category mappings on event creation.')
|
||||
item_cache = {i.pk: i for i in self.instance.items.all()}
|
||||
result = {}
|
||||
for k, item in value['seat_category_mapping'].items():
|
||||
if item not in item_cache:
|
||||
raise ValidationError('Item \'{id}\' does not exist.'.format(id=item))
|
||||
result[k] = item_cache[item]
|
||||
return {'seat_category_mapping': result}
|
||||
|
||||
def validate_plugins(self, value):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
@@ -109,6 +154,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
validated_data.pop('seat_category_mapping', None)
|
||||
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
|
||||
event = super().create(validated_data)
|
||||
|
||||
@@ -120,6 +166,10 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
value=value
|
||||
)
|
||||
|
||||
# Seats
|
||||
if event.seating_plan:
|
||||
generate_seats(event, None, event.seating_plan, {})
|
||||
|
||||
# Plugins
|
||||
if plugins is not None:
|
||||
event.set_active_plugins(plugins)
|
||||
@@ -131,6 +181,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
def update(self, instance, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
|
||||
event = super().update(instance, validated_data)
|
||||
|
||||
# Meta data
|
||||
@@ -151,6 +202,29 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
if prop.name not in meta_data:
|
||||
current_object.delete()
|
||||
|
||||
# Seats
|
||||
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
|
||||
current_mappings = {
|
||||
m.layout_category: m
|
||||
for m in event.seat_category_mappings.filter(subevent=None)
|
||||
}
|
||||
if not event.seating_plan:
|
||||
seat_category_mapping = {}
|
||||
for key, value in seat_category_mapping.items():
|
||||
if key in current_mappings:
|
||||
m = current_mappings.pop(key)
|
||||
m.product = value
|
||||
m.save()
|
||||
else:
|
||||
event.seat_category_mappings.create(product=value, layout_category=key)
|
||||
for m in current_mappings.values():
|
||||
m.delete()
|
||||
if 'seating_plan' in validated_data or seat_category_mapping is not None:
|
||||
generate_seats(event, None, event.seating_plan, {
|
||||
m.layout_category: m.product
|
||||
for m in event.seat_category_mappings.select_related('product').filter(subevent=None)
|
||||
})
|
||||
|
||||
# Plugins
|
||||
if plugins is not None:
|
||||
event.set_active_plugins(plugins)
|
||||
@@ -164,6 +238,7 @@ class CloneEventSerializer(EventSerializer):
|
||||
def create(self, validated_data):
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
is_public = validated_data.pop('is_public', None)
|
||||
testmode = validated_data.pop('testmode', None)
|
||||
new_event = super().create(validated_data)
|
||||
|
||||
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
|
||||
@@ -173,6 +248,8 @@ class CloneEventSerializer(EventSerializer):
|
||||
new_event.set_active_plugins(plugins)
|
||||
if is_public is not None:
|
||||
new_event.is_public = is_public
|
||||
if testmode is not None:
|
||||
new_event.testmode = testmode
|
||||
new_event.save()
|
||||
|
||||
return new_event
|
||||
@@ -193,14 +270,15 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class SubEventSerializer(I18nAwareModelSerializer):
|
||||
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False)
|
||||
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False)
|
||||
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
|
||||
event = SlugRelatedField(slug_field='slug', read_only=True)
|
||||
meta_data = MetaDataField(source='*')
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
|
||||
'presale_start', 'presale_end', 'location', 'event', 'is_public',
|
||||
'item_price_overrides', 'variation_price_overrides', 'meta_data')
|
||||
'presale_start', 'presale_end', 'location', 'event', 'is_public', 'seating_plan',
|
||||
'item_price_overrides', 'variation_price_overrides', 'meta_data', 'seat_category_mapping')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -222,6 +300,25 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
def validate_variation_price_overrides(self, data):
|
||||
return list(filter(lambda i: 'variation' in i, data))
|
||||
|
||||
def validate_seating_plan(self, value):
|
||||
if value and value.organizer != self.context['request'].organizer:
|
||||
raise ValidationError('Invalid seating plan.')
|
||||
if self.instance and self.instance.pk:
|
||||
try:
|
||||
validate_plan_change(self.context['request'].event, self.instance, value)
|
||||
except SeatProtected as e:
|
||||
raise ValidationError(str(e))
|
||||
return value
|
||||
|
||||
def validate_seat_category_mapping(self, value):
|
||||
item_cache = {i.pk: i for i in self.context['request'].event.items.all()}
|
||||
result = {}
|
||||
for k, item in value['seat_category_mapping'].items():
|
||||
if item not in item_cache:
|
||||
raise ValidationError('Item \'{id}\' does not exist.'.format(id=item))
|
||||
result[k] = item_cache[item]
|
||||
return {'seat_category_mapping': result}
|
||||
|
||||
@cached_property
|
||||
def meta_properties(self):
|
||||
return {
|
||||
@@ -239,6 +336,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
|
||||
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
|
||||
subevent = super().create(validated_data)
|
||||
|
||||
for item_price_override_data in item_price_overrides_data:
|
||||
@@ -254,6 +352,18 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
value=value
|
||||
)
|
||||
|
||||
# Seats
|
||||
if subevent.seating_plan:
|
||||
if seat_category_mapping is not None:
|
||||
for key, value in seat_category_mapping.items():
|
||||
self.context['request'].event.seat_category_mappings.create(
|
||||
product=value, layout_category=key, subevent=subevent
|
||||
)
|
||||
generate_seats(self.context['request'].event, subevent, subevent.seating_plan, {
|
||||
m.layout_category: m.product
|
||||
for m in self.context['request'].event.seat_category_mappings.select_related('product').filter(subevent=subevent)
|
||||
})
|
||||
|
||||
return subevent
|
||||
|
||||
@transaction.atomic
|
||||
@@ -261,6 +371,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
|
||||
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
|
||||
subevent = super().update(instance, validated_data)
|
||||
|
||||
existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)}
|
||||
@@ -297,6 +408,31 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
if prop.name not in meta_data:
|
||||
current_object.delete()
|
||||
|
||||
# Seats
|
||||
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
|
||||
current_mappings = {
|
||||
m.layout_category: m
|
||||
for m in self.context['request'].event.seat_category_mappings.filter(subevent=subevent)
|
||||
}
|
||||
if not subevent.seating_plan:
|
||||
seat_category_mapping = {}
|
||||
for key, value in seat_category_mapping.items():
|
||||
if key in current_mappings:
|
||||
m = current_mappings.pop(key)
|
||||
m.product = value
|
||||
m.save()
|
||||
else:
|
||||
self.context['request'].event.seat_category_mappings.create(
|
||||
product=value, layout_category=key, subevent=subevent
|
||||
)
|
||||
for m in current_mappings.values():
|
||||
m.delete()
|
||||
if 'seating_plan' in validated_data or seat_category_mapping is not None:
|
||||
generate_seats(self.context['request'].event, subevent, subevent.seating_plan, {
|
||||
m.layout_category: m.product
|
||||
for m in self.context['request'].event.seat_category_mappings.select_related('product').filter(subevent=subevent)
|
||||
})
|
||||
|
||||
return subevent
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
from collections import Counter
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils.timezone import now
|
||||
@@ -15,7 +14,7 @@ from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
|
||||
OrderPosition, Question, QuestionAnswer, SubEvent,
|
||||
OrderPosition, Question, QuestionAnswer, Seat, SubEvent,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
@@ -71,6 +70,13 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field):
|
||||
return [o.identifier for o in instance.options.all()]
|
||||
|
||||
|
||||
class InlineSeatSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Seat
|
||||
fields = ('id', 'name', 'seat_guid')
|
||||
|
||||
|
||||
class AnswerSerializer(I18nAwareModelSerializer):
|
||||
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
|
||||
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
|
||||
@@ -166,12 +172,13 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
downloads = PositionDownloadsField(source='*')
|
||||
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
||||
pdf_data = PdfDataSerializer(source='*')
|
||||
seat = InlineSeatSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data')
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -430,11 +437,12 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
addon_to = serializers.IntegerField(required=False, allow_null=True)
|
||||
secret = serializers.CharField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
seat = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'secret', 'addon_to', 'subevent', 'answers')
|
||||
'secret', 'addon_to', 'subevent', 'answers', 'seat')
|
||||
|
||||
def validate_secret(self, secret):
|
||||
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
@@ -615,8 +623,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
ia = None
|
||||
|
||||
with self.context['event'].lock() as now_dt:
|
||||
quotadiff = Counter()
|
||||
|
||||
free_seats = set()
|
||||
seats_seen = set()
|
||||
consume_carts = validated_data.pop('consume_carts', [])
|
||||
delete_cps = []
|
||||
quota_avail_cache = {}
|
||||
@@ -630,7 +638,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if quota_avail_cache[quota][1] is not None:
|
||||
quota_avail_cache[quota][1] += 1
|
||||
if cp.expires > now_dt:
|
||||
quotadiff.subtract(quotas)
|
||||
if cp.seat:
|
||||
free_seats.add(cp.seat)
|
||||
delete_cps.append(cp)
|
||||
|
||||
errs = [{} for p in positions_data]
|
||||
@@ -658,7 +667,22 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
]
|
||||
|
||||
quotadiff.update(new_quotas)
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
|
||||
if pos_data.get('seat'):
|
||||
if not seated:
|
||||
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
errs[i]['seat'] = ['The specified seat does not exist.']
|
||||
else:
|
||||
pos_data['seat'] = seat
|
||||
if (seat not in free_seats and not seat.is_available()) or seat in seats_seen:
|
||||
errs[i]['seat'] = [ugettext_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.']
|
||||
|
||||
if any(errs):
|
||||
raise ValidationError({'positions': errs})
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import Organizer
|
||||
from pretix.api.serializers.order import CompatibleJSONField
|
||||
from pretix.base.models import Organizer, SeatingPlan
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
|
||||
|
||||
class OrganizerSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Organizer
|
||||
fields = ('name', 'slug')
|
||||
|
||||
|
||||
class SeatingPlanSerializer(I18nAwareModelSerializer):
|
||||
layout = CompatibleJSONField(
|
||||
validators=[SeatingPlanLayoutValidator()]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SeatingPlan
|
||||
fields = ('id', 'name', 'layout')
|
||||
|
||||
@@ -27,7 +27,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
|
||||
model = Voucher
|
||||
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
|
||||
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
|
||||
'tag', 'comment', 'subevent')
|
||||
'tag', 'comment', 'subevent', 'show_hidden_items')
|
||||
read_only_fields = ('id', 'redeemed')
|
||||
list_serializer_class = VoucherListSerializer
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import timedelta
|
||||
|
||||
from django.dispatch import Signal, receiver
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.api.models import ApiCall, WebHookCall
|
||||
from pretix.base.signals import periodic_task
|
||||
@@ -17,10 +18,12 @@ instances.
|
||||
|
||||
|
||||
@receiver(periodic_task)
|
||||
@scopes_disabled()
|
||||
def cleanup_webhook_logs(sender, **kwargs):
|
||||
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()
|
||||
|
||||
|
||||
@receiver(periodic_task)
|
||||
@scopes_disabled()
|
||||
def cleanup_api_logs(sender, **kwargs):
|
||||
ApiCall.objects.filter(created__lte=now() - timedelta(hours=24)).delete()
|
||||
|
||||
@@ -18,6 +18,7 @@ orga_router = routers.DefaultRouter()
|
||||
orga_router.register(r'events', event.EventViewSet)
|
||||
orga_router.register(r'subevents', event.SubEventViewSet)
|
||||
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
|
||||
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
|
||||
|
||||
event_router = routers.DefaultRouter()
|
||||
event_router.register(r'subevents', event.SubEventViewSet)
|
||||
|
||||
@@ -24,7 +24,7 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
|
||||
return CartPosition.objects.filter(
|
||||
event=self.request.event,
|
||||
cart_id__endswith="@api"
|
||||
)
|
||||
).select_related('seat').prefetch_related('answers')
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import DateTimeField
|
||||
@@ -24,11 +25,11 @@ from pretix.base.services.checkin import (
|
||||
)
|
||||
from pretix.helpers.database import FixedOrderBy
|
||||
|
||||
|
||||
class CheckinListFilter(FilterSet):
|
||||
class Meta:
|
||||
model = CheckinList
|
||||
fields = ['subevent']
|
||||
with scopes_disabled():
|
||||
class CheckinListFilter(FilterSet):
|
||||
class Meta:
|
||||
model = CheckinList
|
||||
fields = ['subevent']
|
||||
|
||||
|
||||
class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
@@ -146,15 +147,16 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
return Response(response)
|
||||
|
||||
|
||||
class CheckinOrderPositionFilter(OrderPositionFilter):
|
||||
with scopes_disabled():
|
||||
class CheckinOrderPositionFilter(OrderPositionFilter):
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
return queryset.filter(last_checked_in__isnull=not value)
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
return queryset.filter(last_checked_in__isnull=not value)
|
||||
|
||||
|
||||
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = CheckinListOrderPositionSerializer
|
||||
queryset = OrderPosition.objects.none()
|
||||
queryset = OrderPosition.all.none()
|
||||
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||
ordering = ('attendee_name_cached', 'positionid')
|
||||
ordering_fields = (
|
||||
@@ -229,7 +231,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address'
|
||||
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
@@ -239,7 +241,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order')
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
|
||||
|
||||
if not self.checkinlist.all_products:
|
||||
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.db import transaction
|
||||
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.exceptions import PermissionDenied
|
||||
|
||||
@@ -18,51 +19,51 @@ from pretix.base.models import (
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
with scopes_disabled():
|
||||
class EventFilter(FilterSet):
|
||||
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
|
||||
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
|
||||
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
|
||||
|
||||
class EventFilter(FilterSet):
|
||||
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
|
||||
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
|
||||
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ['is_public', 'live', 'has_subevents']
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ['is_public', 'live', 'has_subevents']
|
||||
|
||||
def ends_after_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
|
||||
def ends_after_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
|
||||
)
|
||||
)
|
||||
)
|
||||
return queryset.filter(expr)
|
||||
|
||||
def is_past_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
|
||||
)
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
def is_future_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
|
||||
def is_past_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
|
||||
)
|
||||
)
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
def is_future_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
|
||||
)
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
|
||||
class EventViewSet(viewsets.ModelViewSet):
|
||||
@@ -72,6 +73,8 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
lookup_url_kwarg = 'event'
|
||||
permission_classes = (EventCRUDPermission,)
|
||||
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
|
||||
ordering = ('slug',)
|
||||
ordering_fields = ('date_from', 'slug')
|
||||
filterset_class = EventFilter
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -83,7 +86,7 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
return qs.prefetch_related(
|
||||
'meta_values', 'meta_values__property'
|
||||
'meta_values', 'meta_values__property', 'seat_category_mappings'
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -180,41 +183,42 @@ class CloneEventViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
|
||||
class SubEventFilter(FilterSet):
|
||||
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
|
||||
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
|
||||
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
|
||||
with scopes_disabled():
|
||||
class SubEventFilter(FilterSet):
|
||||
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
|
||||
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
|
||||
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ['active', 'event__live']
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ['active', 'event__live']
|
||||
|
||||
def ends_after_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
|
||||
)
|
||||
return queryset.filter(expr)
|
||||
|
||||
def is_past_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
|
||||
)
|
||||
if value:
|
||||
def ends_after_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
|
||||
)
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
def is_future_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
def is_past_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
def is_future_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
|
||||
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
@@ -238,12 +242,18 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
event__in=self.request.user.get_events_with_any_permission()
|
||||
)
|
||||
return qs.prefetch_related(
|
||||
'subeventitem_set', 'subeventitemvariation_set'
|
||||
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings'
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
original_data = self.get_serializer(instance=serializer.instance).data
|
||||
super().perform_update(serializer)
|
||||
|
||||
if serializer.data == original_data:
|
||||
# Performance optimization: If nothing was changed, we do not need to save or log anything.
|
||||
# This costs us a few cycles on save, but avoids thousands of lines in our log.
|
||||
return
|
||||
|
||||
serializer.instance.log_action(
|
||||
'pretix.subevent.changed',
|
||||
user=self.request.user,
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.db.models import Q
|
||||
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 viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
@@ -21,19 +22,19 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
with scopes_disabled():
|
||||
class ItemFilter(FilterSet):
|
||||
tax_rate = django_filters.CharFilter(method='tax_rate_qs')
|
||||
|
||||
class ItemFilter(FilterSet):
|
||||
tax_rate = django_filters.CharFilter(method='tax_rate_qs')
|
||||
def tax_rate_qs(self, queryset, name, value):
|
||||
if value in ("0", "None", "0.00"):
|
||||
return queryset.filter(Q(tax_rule__isnull=True) | Q(tax_rule__rate=0))
|
||||
else:
|
||||
return queryset.filter(tax_rule__rate=value)
|
||||
|
||||
def tax_rate_qs(self, queryset, name, value):
|
||||
if value in ("0", "None", "0.00"):
|
||||
return queryset.filter(Q(tax_rule__isnull=True) | Q(tax_rule__rate=0))
|
||||
else:
|
||||
return queryset.filter(tax_rule__rate=value)
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
|
||||
class Meta:
|
||||
model = Item
|
||||
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
|
||||
|
||||
|
||||
class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
@@ -65,7 +66,14 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
return ctx
|
||||
|
||||
def perform_update(self, serializer):
|
||||
original_data = self.get_serializer(instance=serializer.instance).data
|
||||
|
||||
serializer.save(event=self.request.event)
|
||||
|
||||
if serializer.data == original_data:
|
||||
# Performance optimization: If nothing was changed, we do not need to save or log anything.
|
||||
# This costs us a few cycles on save, but avoids thousands of lines in our log.
|
||||
return
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.item.changed',
|
||||
user=self.request.user,
|
||||
@@ -312,10 +320,11 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class QuestionFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ['ask_during_checkin', 'required', 'identifier']
|
||||
with scopes_disabled():
|
||||
class QuestionFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ['ask_during_checkin', 'required', 'identifier']
|
||||
|
||||
|
||||
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
@@ -411,10 +420,11 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class QuotaFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Quota
|
||||
fields = ['subevent']
|
||||
with scopes_disabled():
|
||||
class QuotaFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Quota
|
||||
fields = ['subevent']
|
||||
|
||||
|
||||
class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
@@ -452,9 +462,17 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
return ctx
|
||||
|
||||
def perform_update(self, serializer):
|
||||
original_data = self.get_serializer(instance=serializer.instance).data
|
||||
|
||||
current_subevent = serializer.instance.subevent
|
||||
serializer.save(event=self.request.event)
|
||||
request_subevent = serializer.instance.subevent
|
||||
|
||||
if serializer.data == original_data:
|
||||
# Performance optimization: If nothing was changed, we do not need to save or log anything.
|
||||
# This costs us a few cycles on save, but avoids thousands of lines in our log.
|
||||
return
|
||||
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.quota.changed',
|
||||
user=self.request.user,
|
||||
|
||||
@@ -11,6 +11,7 @@ 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_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import mixins, serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import (
|
||||
@@ -50,17 +51,17 @@ from pretix.base.signals import (
|
||||
)
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
|
||||
with scopes_disabled():
|
||||
class OrderFilter(FilterSet):
|
||||
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
|
||||
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact')
|
||||
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
|
||||
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
|
||||
|
||||
class OrderFilter(FilterSet):
|
||||
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
|
||||
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact')
|
||||
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
|
||||
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
|
||||
|
||||
|
||||
class OrderViewSet(viewsets.ModelViewSet):
|
||||
@@ -92,8 +93,8 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
'positions',
|
||||
OrderPosition.objects.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item__category', 'addon_to',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
'item__category', 'addon_to', 'seat',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation', 'seat'))
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -102,7 +103,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -482,6 +483,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'):
|
||||
serializer.instance.email_known_to_work = False
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.order.contact.changed',
|
||||
user=self.request.user,
|
||||
@@ -530,48 +532,49 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
|
||||
|
||||
class OrderPositionFilter(FilterSet):
|
||||
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
|
||||
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
|
||||
search = django_filters.CharFilter(method='search_qs')
|
||||
with scopes_disabled():
|
||||
class OrderPositionFilter(FilterSet):
|
||||
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
|
||||
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
|
||||
search = django_filters.CharFilter(method='search_qs')
|
||||
|
||||
def search_qs(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(secret__istartswith=value)
|
||||
| Q(attendee_name_cached__icontains=value)
|
||||
| Q(addon_to__attendee_name_cached__icontains=value)
|
||||
| Q(attendee_email__icontains=value)
|
||||
| Q(addon_to__attendee_email__icontains=value)
|
||||
| Q(order__code__istartswith=value)
|
||||
| Q(order__invoice_address__name_cached__icontains=value)
|
||||
| Q(order__email__icontains=value)
|
||||
)
|
||||
def search_qs(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(secret__istartswith=value)
|
||||
| Q(attendee_name_cached__icontains=value)
|
||||
| Q(addon_to__attendee_name_cached__icontains=value)
|
||||
| Q(attendee_email__icontains=value)
|
||||
| Q(addon_to__attendee_email__icontains=value)
|
||||
| Q(order__code__istartswith=value)
|
||||
| Q(order__invoice_address__name_cached__icontains=value)
|
||||
| Q(order__email__icontains=value)
|
||||
)
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
return queryset.filter(checkins__isnull=not value)
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
return queryset.filter(checkins__isnull=not value)
|
||||
|
||||
def attendee_name_qs(self, queryset, name, value):
|
||||
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
|
||||
def attendee_name_qs(self, queryset, name, value):
|
||||
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = {
|
||||
'item': ['exact', 'in'],
|
||||
'variation': ['exact', 'in'],
|
||||
'secret': ['exact'],
|
||||
'order__status': ['exact', 'in'],
|
||||
'addon_to': ['exact', 'in'],
|
||||
'subevent': ['exact', 'in'],
|
||||
'pseudonymization_id': ['exact'],
|
||||
'voucher__code': ['exact'],
|
||||
'voucher': ['exact'],
|
||||
}
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = {
|
||||
'item': ['exact', 'in'],
|
||||
'variation': ['exact', 'in'],
|
||||
'secret': ['exact'],
|
||||
'order__status': ['exact', 'in'],
|
||||
'addon_to': ['exact', 'in'],
|
||||
'subevent': ['exact', 'in'],
|
||||
'pseudonymization_id': ['exact'],
|
||||
'voucher__code': ['exact'],
|
||||
'voucher': ['exact'],
|
||||
}
|
||||
|
||||
|
||||
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPositionSerializer
|
||||
queryset = OrderPosition.objects.none()
|
||||
queryset = OrderPosition.all.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('order__datetime', 'positionid')
|
||||
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
|
||||
@@ -608,13 +611,13 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to'
|
||||
'item', 'variation', 'item__category', 'addon_to', 'seat'
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question'
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer'
|
||||
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
|
||||
)
|
||||
return qs
|
||||
|
||||
@@ -959,22 +962,23 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer.save()
|
||||
|
||||
|
||||
class InvoiceFilter(FilterSet):
|
||||
refers = django_filters.CharFilter(method='refers_qs')
|
||||
number = django_filters.CharFilter(method='nr_qs')
|
||||
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||
with scopes_disabled():
|
||||
class InvoiceFilter(FilterSet):
|
||||
refers = django_filters.CharFilter(method='refers_qs')
|
||||
number = django_filters.CharFilter(method='nr_qs')
|
||||
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||
|
||||
def refers_qs(self, queryset, name, value):
|
||||
return queryset.annotate(
|
||||
refers_nr=Concat('refers__prefix', 'refers__invoice_no')
|
||||
).filter(refers_nr__iexact=value)
|
||||
def refers_qs(self, queryset, name, value):
|
||||
return queryset.annotate(
|
||||
refers_nr=Concat('refers__prefix', 'refers__invoice_no')
|
||||
).filter(refers_nr__iexact=value)
|
||||
|
||||
def nr_qs(self, queryset, name, value):
|
||||
return queryset.filter(nr__iexact=value)
|
||||
def nr_qs(self, queryset, name, value):
|
||||
return queryset.filter(nr__iexact=value)
|
||||
|
||||
class Meta:
|
||||
model = Invoice
|
||||
fields = ['order', 'number', 'is_cancellation', 'refers', 'locale']
|
||||
class Meta:
|
||||
model = Invoice
|
||||
fields = ['order', 'number', 'is_cancellation', 'refers', 'locale']
|
||||
|
||||
|
||||
class RetryException(APIException):
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from rest_framework import viewsets
|
||||
from rest_framework import filters, viewsets
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.organizer import OrganizerSerializer
|
||||
from pretix.base.models import Organizer
|
||||
from pretix.api.serializers.organizer import (
|
||||
OrganizerSerializer, SeatingPlanSerializer,
|
||||
)
|
||||
from pretix.base.models import Organizer, SeatingPlan
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@@ -10,6 +14,9 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Organizer.objects.none()
|
||||
lookup_field = 'slug'
|
||||
lookup_url_kwarg = 'organizer'
|
||||
filter_backends = (filters.OrderingFilter,)
|
||||
ordering = ('slug',)
|
||||
ordering_fields = ('name', 'slug')
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_authenticated:
|
||||
@@ -27,3 +34,50 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return Organizer.objects.filter(pk=self.request.auth.organizer_id)
|
||||
else:
|
||||
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)
|
||||
|
||||
|
||||
class SeatingPlanViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = SeatingPlanSerializer
|
||||
queryset = SeatingPlan.objects.none()
|
||||
permission = 'can_change_organizer_settings'
|
||||
write_permission = 'can_change_organizer_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.seating_plans.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
self.request.organizer.log_action(
|
||||
'pretix.seatingplan.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
|
||||
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.')
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
self.request.organizer.log_action(
|
||||
'pretix.seatingplan.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
|
||||
)
|
||||
return inst
|
||||
|
||||
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.')
|
||||
instance.log_action(
|
||||
'pretix.seatingplan.deleted',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'id': instance.pk}
|
||||
)
|
||||
instance.delete()
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.utils.timezone import now
|
||||
from django_filters.rest_framework import (
|
||||
BooleanFilter, DjangoFilterBackend, FilterSet,
|
||||
)
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
@@ -15,22 +16,22 @@ from rest_framework.response import Response
|
||||
from pretix.api.serializers.voucher import VoucherSerializer
|
||||
from pretix.base.models import Voucher
|
||||
|
||||
with scopes_disabled():
|
||||
class VoucherFilter(FilterSet):
|
||||
active = BooleanFilter(method='filter_active')
|
||||
|
||||
class VoucherFilter(FilterSet):
|
||||
active = BooleanFilter(method='filter_active')
|
||||
class Meta:
|
||||
model = Voucher
|
||||
fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota',
|
||||
'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent']
|
||||
|
||||
class Meta:
|
||||
model = Voucher
|
||||
fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota',
|
||||
'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent']
|
||||
|
||||
def filter_active(self, queryset, name, value):
|
||||
if value:
|
||||
return queryset.filter(Q(redeemed__lt=F('max_usages')) &
|
||||
(Q(valid_until__isnull=True) | Q(valid_until__gt=now())))
|
||||
else:
|
||||
return queryset.filter(Q(redeemed__gte=F('max_usages')) |
|
||||
(Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
|
||||
def filter_active(self, queryset, name, value):
|
||||
if value:
|
||||
return queryset.filter(Q(redeemed__lt=F('max_usages')) &
|
||||
(Q(valid_until__isnull=True) | Q(valid_until__gt=now())))
|
||||
else:
|
||||
return queryset.filter(Q(redeemed__gte=F('max_usages')) |
|
||||
(Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
|
||||
|
||||
|
||||
class VoucherViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import django_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
@@ -10,16 +11,16 @@ from pretix.api.serializers.waitinglist import WaitingListSerializer
|
||||
from pretix.base.models import WaitingListEntry
|
||||
from pretix.base.models.waitinglist import WaitingListException
|
||||
|
||||
with scopes_disabled():
|
||||
class WaitingListFilter(FilterSet):
|
||||
has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs')
|
||||
|
||||
class WaitingListFilter(FilterSet):
|
||||
has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs')
|
||||
def has_voucher_qs(self, queryset, name, value):
|
||||
return queryset.filter(voucher__isnull=not value)
|
||||
|
||||
def has_voucher_qs(self, queryset, name, value):
|
||||
return queryset.filter(voucher__isnull=not value)
|
||||
|
||||
class Meta:
|
||||
model = WaitingListEntry
|
||||
fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent']
|
||||
class Meta:
|
||||
model = WaitingListEntry
|
||||
fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent']
|
||||
|
||||
|
||||
class WaitingListViewSet(viewsets.ModelViewSet):
|
||||
|
||||
+46
-44
@@ -8,6 +8,7 @@ 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_scopes import scope, scopes_disabled
|
||||
from requests import RequestException
|
||||
|
||||
from pretix.api.models import WebHook, WebHookCall, WebHookEventListener
|
||||
@@ -203,51 +204,52 @@ def notify_webhooks(logentry_id: int):
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=9)
|
||||
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
|
||||
# 9 retries with 2**(2*x) timing is roughly 72 hours
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
webhook = WebHook.objects.get(id=webhook_id)
|
||||
with scopes_disabled():
|
||||
webhook = WebHook.objects.get(id=webhook_id)
|
||||
with scope(organizer=webhook.organizer):
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
types = get_all_webhook_events()
|
||||
event_type = types.get(action_type)
|
||||
if not event_type or not webhook.enabled:
|
||||
return # Ignore, e.g. plugin not installed
|
||||
|
||||
types = get_all_webhook_events()
|
||||
event_type = types.get(action_type)
|
||||
if not event_type or not webhook.enabled:
|
||||
return # Ignore, e.g. plugin not installed
|
||||
payload = event_type.build_payload(logentry)
|
||||
t = time.time()
|
||||
|
||||
payload = event_type.build_payload(logentry)
|
||||
t = time.time()
|
||||
|
||||
try:
|
||||
try:
|
||||
resp = requests.post(
|
||||
webhook.target_url,
|
||||
json=payload,
|
||||
allow_redirects=False
|
||||
)
|
||||
WebHookCall.objects.create(
|
||||
webhook=webhook,
|
||||
action_type=logentry.action_type,
|
||||
target_url=webhook.target_url,
|
||||
is_retry=self.request.retries > 0,
|
||||
execution_time=time.time() - t,
|
||||
return_code=resp.status_code,
|
||||
payload=json.dumps(payload),
|
||||
response_body=resp.text[:1024 * 1024],
|
||||
success=200 <= resp.status_code <= 299
|
||||
)
|
||||
if resp.status_code == 410:
|
||||
webhook.enabled = False
|
||||
webhook.save()
|
||||
elif resp.status_code > 299:
|
||||
try:
|
||||
resp = requests.post(
|
||||
webhook.target_url,
|
||||
json=payload,
|
||||
allow_redirects=False
|
||||
)
|
||||
WebHookCall.objects.create(
|
||||
webhook=webhook,
|
||||
action_type=logentry.action_type,
|
||||
target_url=webhook.target_url,
|
||||
is_retry=self.request.retries > 0,
|
||||
execution_time=time.time() - t,
|
||||
return_code=resp.status_code,
|
||||
payload=json.dumps(payload),
|
||||
response_body=resp.text[:1024 * 1024],
|
||||
success=200 <= resp.status_code <= 299
|
||||
)
|
||||
if resp.status_code == 410:
|
||||
webhook.enabled = False
|
||||
webhook.save()
|
||||
elif resp.status_code > 299:
|
||||
raise self.retry(countdown=2 ** (self.request.retries * 2))
|
||||
except RequestException as e:
|
||||
WebHookCall.objects.create(
|
||||
webhook=webhook,
|
||||
action_type=logentry.action_type,
|
||||
target_url=webhook.target_url,
|
||||
is_retry=self.request.retries > 0,
|
||||
execution_time=time.time() - t,
|
||||
return_code=0,
|
||||
payload=json.dumps(payload),
|
||||
response_body=str(e)[:1024 * 1024]
|
||||
)
|
||||
raise self.retry(countdown=2 ** (self.request.retries * 2))
|
||||
except RequestException as e:
|
||||
WebHookCall.objects.create(
|
||||
webhook=webhook,
|
||||
action_type=logentry.action_type,
|
||||
target_url=webhook.target_url,
|
||||
is_retry=self.request.retries > 0,
|
||||
execution_time=time.time() - t,
|
||||
return_code=0,
|
||||
payload=json.dumps(payload),
|
||||
response_body=str(e)[:1024 * 1024]
|
||||
)
|
||||
raise self.retry(countdown=2 ** (self.request.retries * 2))
|
||||
except MaxRetriesExceededError:
|
||||
pass
|
||||
except MaxRetriesExceededError:
|
||||
pass
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.template.loader import get_template
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
from pretix.base.models import Event, Order
|
||||
from pretix.base.models import Event, Order, OrderPosition
|
||||
from pretix.base.signals import register_html_mail_renderers
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
|
||||
@@ -44,7 +44,8 @@ class BaseHTMLMailRenderer:
|
||||
def __str__(self):
|
||||
return self.identifier
|
||||
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None) -> str:
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None,
|
||||
position: OrderPosition=None) -> str:
|
||||
"""
|
||||
This method should generate the HTML part of the email.
|
||||
|
||||
@@ -52,6 +53,7 @@ class BaseHTMLMailRenderer:
|
||||
:param plain_signature: The signature with event organizer contact details in plain text.
|
||||
:param subject: The email subject.
|
||||
:param order: The order if this email is connected to one, otherwise ``None``.
|
||||
:param position: The order position if this email is connected to one, otherwise ``None``.
|
||||
:return: An HTML string
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
@@ -95,7 +97,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
def template_name(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order) -> str:
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order, position: OrderPosition) -> str:
|
||||
body_md = markdown_compile_email(plain_body)
|
||||
htmlctx = {
|
||||
'site': settings.PRETIX_INSTANCE_NAME,
|
||||
@@ -116,6 +118,9 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
if order:
|
||||
htmlctx['order'] = order
|
||||
|
||||
if position:
|
||||
htmlctx['position'] = position
|
||||
|
||||
tpl = get_template(self.template_name)
|
||||
body_html = inline_css(tpl.render(htmlctx))
|
||||
return body_html
|
||||
|
||||
@@ -40,6 +40,7 @@ class AnswerFilesExporter(BaseExporter):
|
||||
if form_data.get('questions'):
|
||||
qs = qs.filter(question__in=form_data['questions'])
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
any = False
|
||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for i in qs:
|
||||
if i.file:
|
||||
@@ -51,9 +52,12 @@ class AnswerFilesExporter(BaseExporter):
|
||||
i.question.pk,
|
||||
os.path.basename(i.file.name).split('.', 1)[1]
|
||||
)
|
||||
any = True
|
||||
zipf.writestr(fname, i.file.read())
|
||||
i.file.close()
|
||||
|
||||
if not any:
|
||||
return None
|
||||
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
|
||||
return '{}_answers.zip'.format(self.event.slug), 'application/zip', zipf.read()
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ class DekodiNREIExporter(BaseExporter):
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
elif p.provider.startswith('stripe'):
|
||||
src = p.info_data.get("source", "{}")
|
||||
src = p.info_data.get("source", p.info_data)
|
||||
payments.append({
|
||||
'PTID': '81',
|
||||
'PTN': 'Stripe',
|
||||
|
||||
@@ -46,6 +46,7 @@ class InvoiceExporter(BaseExporter):
|
||||
qs = qs.filter(date__lte=date_value)
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
any = False
|
||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for i in qs:
|
||||
try:
|
||||
@@ -54,14 +55,19 @@ class InvoiceExporter(BaseExporter):
|
||||
i.refresh_from_db()
|
||||
i.file.open('rb')
|
||||
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
|
||||
any = True
|
||||
i.file.close()
|
||||
except FileNotFoundError:
|
||||
invoice_pdf_task.apply(args=(i.pk,))
|
||||
i.refresh_from_db()
|
||||
i.file.open('rb')
|
||||
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
|
||||
any = True
|
||||
i.file.close()
|
||||
|
||||
if not any:
|
||||
return None
|
||||
|
||||
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
|
||||
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read()
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from django.utils.translation import get_language, ugettext_lazy as _
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country, CountryField
|
||||
|
||||
from pretix.base.forms.widgets import (
|
||||
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
|
||||
@@ -351,6 +352,27 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.request = kwargs.pop('request', None)
|
||||
self.validate_vat_id = kwargs.pop('validate_vat_id')
|
||||
self.all_optional = kwargs.pop('all_optional', False)
|
||||
|
||||
kwargs.setdefault('initial', {})
|
||||
if not kwargs.get('instance') or not kwargs['instance'].country:
|
||||
# Try to guess the initial country from either the country of the merchant
|
||||
# or the locale. This will hopefully save at least some users some scrolling :)
|
||||
locale = get_language()
|
||||
country = event.settings.invoice_address_from_country
|
||||
if not country:
|
||||
valid_countries = countries.countries
|
||||
if '-' in locale:
|
||||
parts = locale.split('-')
|
||||
if parts[1].upper() in valid_countries:
|
||||
country = Country(parts[1].upper())
|
||||
elif parts[0].upper() in valid_countries:
|
||||
country = Country(parts[0].upper())
|
||||
else:
|
||||
if locale in valid_countries:
|
||||
country = Country(locale.upper())
|
||||
|
||||
kwargs['initial']['country'] = country
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
if not event.settings.invoice_address_vatid:
|
||||
del self.fields['vat_id']
|
||||
@@ -402,6 +424,12 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
self.instance.name_parts = data.get('name_parts')
|
||||
|
||||
if all(
|
||||
not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts')
|
||||
) and len(data.get('name_parts', {})) == 1:
|
||||
# Do not save the country if it is the only field set -- we don't know the user even checked it!
|
||||
self.cleaned_data['country'] = ''
|
||||
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import sys
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_scopes import scope, scopes_disabled
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def create_parser(self, *args, **kwargs):
|
||||
parser = super().create_parser(*args, **kwargs)
|
||||
parser.parse_args = lambda x: parser.parse_known_args(x)[0]
|
||||
return parser
|
||||
|
||||
def handle(self, *args, **options):
|
||||
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", *args, **options)
|
||||
|
||||
lookups = {}
|
||||
for flag in flags:
|
||||
lookup, value = flag.lstrip("-").split("=")
|
||||
lookup = lookup.split("__", maxsplit=1)
|
||||
lookups[lookup[0]] = {
|
||||
lookup[1] if len(lookup) > 1 else "pk": value
|
||||
}
|
||||
models = {
|
||||
model_name.split(".")[-1]: model_class
|
||||
for app_name, app_content in apps.all_models.items()
|
||||
for (model_name, model_class) in app_content.items()
|
||||
}
|
||||
scope_options = {
|
||||
app_name: models[app_name].objects.get(**app_value)
|
||||
for app_name, app_value in lookups.items()
|
||||
}
|
||||
with scope(**scope_options):
|
||||
return call_command("shell", *args, **options)
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.1 on 2019-05-15 05:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0120_auto_20190509_0736'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='email_known_to_work',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 2.2.1 on 2019-05-15 13:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.orders
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0121_order_email_known_to_work'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='web_secret',
|
||||
field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_secret, max_length=32),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
# Generated by Django 2.2.1 on 2019-05-30 10:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0122_orderposition_web_secret'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SeatingPlan',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=190)),
|
||||
('layout', models.TextField()),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seating_plans', to='pretixbase.Organizer')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SeatCategoryMapping',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('layout_category', models.CharField(max_length=190)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.Event')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.Item')),
|
||||
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.SubEvent')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Seat',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=190)),
|
||||
('blocked', models.BooleanField(default=False)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.Event')),
|
||||
('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.Item')),
|
||||
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.SubEvent')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='seat',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Seat'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='seating_plan',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='events', to='pretixbase.SeatingPlan'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='seat',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Seat'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='seating_plan',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='subevents', to='pretixbase.SeatingPlan'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2.1 on 2019-05-30 11:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0123_auto_20190530_1035'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='seat',
|
||||
name='seat_guid',
|
||||
field=models.CharField(db_index=True, default=None, max_length=190),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 2.2.1 on 2019-07-07 10:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_show_hidden_items(apps, schema_editor):
|
||||
Voucher = apps.get_model('pretixbase', 'Voucher')
|
||||
Voucher.objects.filter(quota__isnull=False).update(show_hidden_items=False)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0124_seat_seat_guid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='show_hidden_items',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.RunPython(
|
||||
set_show_hidden_items,
|
||||
migrations.RunPython.noop,
|
||||
)
|
||||
]
|
||||
@@ -24,6 +24,7 @@ from .orders import (
|
||||
from .organizer import (
|
||||
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
|
||||
)
|
||||
from .seating import Seat, SeatCategoryMapping, SeatingPlan
|
||||
from .tax import TaxRule
|
||||
from .vouchers import Voucher
|
||||
from .waitinglist import WaitingListEntry
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
@@ -283,6 +284,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
return True
|
||||
return False
|
||||
|
||||
@scopes_disabled()
|
||||
def get_events_with_any_permission(self, request=None):
|
||||
"""
|
||||
Returns a queryset of events the user has any permissions to.
|
||||
@@ -300,6 +302,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
| Q(id__in=self.teams.values_list('limit_events__id', flat=True))
|
||||
)
|
||||
|
||||
@scopes_disabled()
|
||||
def get_events_with_permission(self, permission, request=None):
|
||||
"""
|
||||
Returns a queryset of events the user has a specific permissions to.
|
||||
|
||||
@@ -6,7 +6,9 @@ from django.db import models
|
||||
from django.db.models.constants import LOOKUP_SEP
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from pretix.helpers.json import CustomJSONEncoder
|
||||
|
||||
@@ -113,6 +115,40 @@ class LoggedModel(models.Model, LoggingMixin):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@cached_property
|
||||
def logs_content_type(self):
|
||||
return ContentType.objects.get_for_model(type(self))
|
||||
|
||||
@cached_property
|
||||
def all_logentries_link(self):
|
||||
from pretix.base.models import Event
|
||||
|
||||
if isinstance(self, Event):
|
||||
event = self
|
||||
elif hasattr(self, 'event'):
|
||||
event = self.event
|
||||
else:
|
||||
return None
|
||||
return reverse(
|
||||
'control:event.log',
|
||||
kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
}
|
||||
) + '?content_type={}&object={}'.format(
|
||||
self.logs_content_type.pk,
|
||||
self.pk
|
||||
)
|
||||
|
||||
def top_logentries(self):
|
||||
qs = self.all_logentries()
|
||||
if self.all_logentries_link:
|
||||
qs = qs[:25]
|
||||
return qs
|
||||
|
||||
def top_logentries_has_more(self):
|
||||
return self.all_logentries().count() > 25
|
||||
|
||||
def all_logentries(self):
|
||||
"""
|
||||
Returns all log entries that are attached to this object.
|
||||
@@ -122,7 +158,7 @@ class LoggedModel(models.Model, LoggingMixin):
|
||||
from .log import LogEntry
|
||||
|
||||
return LogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
|
||||
content_type=self.logs_content_type, object_id=self.pk
|
||||
).select_related('user', 'event', 'oauth_application', 'api_token', 'device')
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.db.models import Case, Count, F, OuterRef, Q, Subquery, When
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
@@ -20,6 +21,8 @@ class CheckinList(LoggedModel):
|
||||
'order have not been paid. This only works with pretixdesk '
|
||||
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
ordering = ('subevent__date_from', 'name')
|
||||
|
||||
@@ -167,6 +170,8 @@ class Checkin(models.Model):
|
||||
'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT,
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='position__order__event__organizer')
|
||||
|
||||
class Meta:
|
||||
unique_together = (('list', 'position'),)
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ 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_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
def generate_serial():
|
||||
serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16)
|
||||
while Device.objects.filter(unique_serial=serial).exists():
|
||||
@@ -15,6 +17,7 @@ def generate_serial():
|
||||
return serial
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
def generate_initialization_token():
|
||||
token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
while Device.objects.filter(initialization_token=token).exists():
|
||||
@@ -22,6 +25,7 @@ def generate_initialization_token():
|
||||
return token
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
def generate_api_token():
|
||||
token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
while Device.objects.filter(api_token=token).exists():
|
||||
@@ -71,6 +75,8 @@ class Device(LoggedModel):
|
||||
null=True, blank=True
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='organizer')
|
||||
|
||||
class Meta:
|
||||
unique_together = (('organizer', 'device_id'),)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from django.utils.crypto import get_random_string
|
||||
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_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.models.base import LoggedModel
|
||||
@@ -335,6 +336,10 @@ class Event(EventMixin, LoggedModel):
|
||||
verbose_name=_('Event series'),
|
||||
default=False
|
||||
)
|
||||
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
||||
related_name='events')
|
||||
|
||||
objects = ScopedManager(organizer='organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Event")
|
||||
@@ -345,6 +350,26 @@ class Event(EventMixin, LoggedModel):
|
||||
def __str__(self):
|
||||
return str(self.name)
|
||||
|
||||
@property
|
||||
def free_seats(self):
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
return self.seats.annotate(
|
||||
has_order=Exists(
|
||||
OrderPosition.objects.filter(
|
||||
order__event=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
|
||||
)
|
||||
),
|
||||
has_cart=Exists(
|
||||
CartPosition.objects.filter(
|
||||
event=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
expires__gte=now()
|
||||
)
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, blocked=False)
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
if self.has_subevents:
|
||||
@@ -445,6 +470,7 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
self.plugins = other.plugins
|
||||
self.is_public = other.is_public
|
||||
self.testmode = other.testmode
|
||||
self.save()
|
||||
|
||||
tax_map = {}
|
||||
@@ -527,6 +553,24 @@ class Event(EventMixin, LoggedModel):
|
||||
for i in items:
|
||||
cl.limit_products.add(item_map[i.pk])
|
||||
|
||||
if other.seating_plan:
|
||||
if other.seating_plan.organizer_id == self.organizer_id:
|
||||
self.seating_plan = other.seating_plan
|
||||
else:
|
||||
self.organizer.seating_plans.create(name=other.seating_plan.name, layout=other.seating_plan.layout)
|
||||
self.save()
|
||||
|
||||
for m in other.seat_category_mappings.filter(subevent__isnull=True):
|
||||
m.pk = None
|
||||
m.event = self
|
||||
m.product = item_map[m.product_id]
|
||||
m.save()
|
||||
|
||||
for s in other.seats.filter(subevent__isnull=True):
|
||||
s.pk = None
|
||||
s.event = self
|
||||
s.save()
|
||||
|
||||
for s in other.settings._objects.all():
|
||||
s.object = self
|
||||
s.pk = None
|
||||
@@ -870,10 +914,14 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Frontpage text")
|
||||
)
|
||||
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
|
||||
related_name='subevents')
|
||||
|
||||
items = models.ManyToManyField('Item', through='SubEventItem')
|
||||
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Date in event series")
|
||||
verbose_name_plural = _("Dates in event series")
|
||||
@@ -882,6 +930,28 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
def __str__(self):
|
||||
return '{} - {}'.format(self.name, self.get_date_range_display())
|
||||
|
||||
@property
|
||||
def free_seats(self):
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
return self.seats.annotate(
|
||||
has_order=Exists(
|
||||
OrderPosition.objects.filter(
|
||||
order__event_id=self.event_id,
|
||||
subevent=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
|
||||
)
|
||||
),
|
||||
has_cart=Exists(
|
||||
CartPosition.objects.filter(
|
||||
event_id=self.event_id,
|
||||
subevent=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
expires__gte=now()
|
||||
)
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, blocked=False)
|
||||
|
||||
@cached_property
|
||||
def settings(self):
|
||||
return self.event.settings
|
||||
@@ -940,6 +1010,7 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
raise ValidationError(_('One or more variations do not belong to this event.'))
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
def generate_invite_token():
|
||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import pgettext
|
||||
from django_countries.fields import CountryField
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
|
||||
def invoice_filename(instance, filename: str) -> str:
|
||||
@@ -107,6 +108,8 @@ class Invoice(models.Model):
|
||||
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
|
||||
internal_reference = models.TextField(blank=True)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
@staticmethod
|
||||
def _to_numeric_invoice_number(number):
|
||||
return '{:05d}'.format(int(number))
|
||||
|
||||
@@ -17,11 +17,13 @@ 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_countries.fields import Country
|
||||
from django_scopes import ScopedManager
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.models import fields
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
from pretix.base.signals import quota_availability
|
||||
|
||||
from .event import Event, SubEvent
|
||||
|
||||
@@ -155,28 +157,40 @@ class SubEventItemVariation(models.Model):
|
||||
self.subevent.event.cache.clear()
|
||||
|
||||
|
||||
def filter_available(qs, channel='web', voucher=None, allow_addons=False):
|
||||
q = (
|
||||
# IMPORTANT: If this is updated, also update the ItemVariation query
|
||||
# in models/event.py: EventMixin.annotated()
|
||||
Q(active=True)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
|
||||
)
|
||||
if not allow_addons:
|
||||
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
|
||||
qs = qs.filter(q)
|
||||
|
||||
vouchq = Q(hide_without_voucher=False)
|
||||
if voucher and voucher.show_hidden_items:
|
||||
if voucher.item_id:
|
||||
vouchq = Q(pk=voucher.item_id)
|
||||
elif voucher.quota_id:
|
||||
vouchq = Q(quotas__in=[voucher.quota_id])
|
||||
return qs.filter(vouchq)
|
||||
|
||||
|
||||
class ItemQuerySet(models.QuerySet):
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False):
|
||||
q = (
|
||||
# IMPORTANT: If this is updated, also update the ItemVariation query
|
||||
# in models/event.py: EventMixin.annotated()
|
||||
Q(active=True)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
|
||||
)
|
||||
if not allow_addons:
|
||||
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
|
||||
qs = self.filter(q)
|
||||
return filter_available(self, channel, voucher, allow_addons)
|
||||
|
||||
vouchq = Q(hide_without_voucher=False)
|
||||
if voucher:
|
||||
if voucher.item_id:
|
||||
vouchq |= Q(pk=voucher.item_id)
|
||||
qs = qs.filter(pk=voucher.item_id)
|
||||
elif voucher.quota_id:
|
||||
qs = qs.filter(quotas__in=[voucher.quota_id])
|
||||
return qs.filter(vouchq)
|
||||
|
||||
class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._queryset_class = ItemQuerySet
|
||||
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False):
|
||||
return filter_available(self.get_queryset(), channel, voucher, allow_addons)
|
||||
|
||||
|
||||
class Item(LoggedModel):
|
||||
@@ -226,7 +240,7 @@ class Item(LoggedModel):
|
||||
:type sales_channels: bool
|
||||
"""
|
||||
|
||||
objects = ItemQuerySet.as_manager()
|
||||
objects = ItemQuerySetManager()
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
@@ -328,7 +342,7 @@ class Item(LoggedModel):
|
||||
verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'),
|
||||
default=False,
|
||||
help_text=_('This product will be hidden from the event page until the user enters a voucher '
|
||||
'code that is specifically tied to this product (and not via a quota).')
|
||||
'that unlocks this product.')
|
||||
)
|
||||
require_bundling = models.BooleanField(
|
||||
verbose_name=_('Only sell this product as part of a bundle'),
|
||||
@@ -591,6 +605,8 @@ class ItemVariation(models.Model):
|
||||
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='item__event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Product variation")
|
||||
verbose_name_plural = _("Product variations")
|
||||
@@ -985,6 +1001,8 @@ class Question(LoggedModel):
|
||||
)
|
||||
dependency_value = models.TextField(null=True, blank=True)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Question")
|
||||
verbose_name_plural = _("Questions")
|
||||
@@ -1234,6 +1252,8 @@ class Quota(LoggedModel):
|
||||
cached_availability_paid_orders = models.PositiveIntegerField(null=True, blank=True)
|
||||
cached_availability_time = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Quota")
|
||||
verbose_name_plural = _("Quotas")
|
||||
@@ -1292,6 +1312,9 @@ class Quota(LoggedModel):
|
||||
return _cache[self.pk]
|
||||
now_dt = now_dt or now()
|
||||
res = self._availability(now_dt, count_waitinglist)
|
||||
for recv, resp in quota_availability.send(sender=self.event, quota=self, result=res,
|
||||
count_waitinglist=count_waitinglist):
|
||||
res = resp
|
||||
|
||||
self.event.cache.delete('item_quota_cache')
|
||||
rewrite_cache = count_waitinglist and (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -14,7 +15,7 @@ from django.db import models, transaction
|
||||
from django.db.models import (
|
||||
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.functions import Coalesce, Greatest
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
@@ -25,6 +26,7 @@ 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_countries.fields import Country, CountryField
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
@@ -180,6 +182,12 @@ class Order(LockModel, LoggedModel):
|
||||
default=False
|
||||
)
|
||||
sales_channel = models.CharField(max_length=190, default="web")
|
||||
email_known_to_work = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('E-mail address verified')
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Order")
|
||||
@@ -190,6 +198,8 @@ class Order(LockModel, LoggedModel):
|
||||
return self.full_code
|
||||
|
||||
def gracefully_delete(self, user=None, auth=None):
|
||||
from . import Voucher
|
||||
|
||||
if not self.testmode:
|
||||
raise TypeError("Only test mode orders can be deleted.")
|
||||
self.event.log_action(
|
||||
@@ -198,6 +208,12 @@ class Order(LockModel, LoggedModel):
|
||||
'code': self.code,
|
||||
}
|
||||
)
|
||||
|
||||
if self.status != Order.STATUS_CANCELED:
|
||||
for position in self.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
|
||||
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order=self).delete()
|
||||
OrderFee.all.filter(order=self).delete()
|
||||
@@ -206,6 +222,9 @@ class Order(LockModel, LoggedModel):
|
||||
self.event.cache.delete('complain_testmode_orders')
|
||||
self.delete()
|
||||
|
||||
def email_confirm_hash(self):
|
||||
return hashlib.sha256(settings.SECRET_KEY.encode() + self.secret.encode()).hexdigest()[:9]
|
||||
|
||||
@property
|
||||
def fees(self):
|
||||
"""
|
||||
@@ -215,6 +234,7 @@ class Order(LockModel, LoggedModel):
|
||||
return self.all_fees(manager='objects')
|
||||
|
||||
@cached_property
|
||||
@scopes_disabled()
|
||||
def count_positions(self):
|
||||
if hasattr(self, 'pcnt'):
|
||||
return self.pcnt or 0
|
||||
@@ -238,6 +258,7 @@ class Order(LockModel, LoggedModel):
|
||||
return None
|
||||
|
||||
@property
|
||||
@scopes_disabled()
|
||||
def payment_refund_sum(self):
|
||||
payment_sum = self.payments.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
|
||||
@@ -249,6 +270,7 @@ class Order(LockModel, LoggedModel):
|
||||
return payment_sum - refund_sum
|
||||
|
||||
@property
|
||||
@scopes_disabled()
|
||||
def pending_sum(self):
|
||||
total = self.total
|
||||
if self.status == Order.STATUS_CANCELED:
|
||||
@@ -423,6 +445,7 @@ class Order(LockModel, LoggedModel):
|
||||
return round_decimal(fee, self.event.currency)
|
||||
|
||||
@property
|
||||
@scopes_disabled()
|
||||
def user_cancel_allowed(self) -> bool:
|
||||
"""
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
@@ -607,7 +630,7 @@ class Order(LockModel, LoggedModel):
|
||||
), tz)
|
||||
return term_last
|
||||
|
||||
def _can_be_paid(self, count_waitinglist=True, ignore_date=False) -> Union[bool, str]:
|
||||
def _can_be_paid(self, count_waitinglist=True, ignore_date=False, force=False) -> Union[bool, str]:
|
||||
error_messages = {
|
||||
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
|
||||
"payment settings is over."),
|
||||
@@ -615,29 +638,37 @@ class Order(LockModel, LoggedModel):
|
||||
"payments should be accepted in the payment settings."),
|
||||
'require_approval': _('This order is not yet approved by the event organizer.')
|
||||
}
|
||||
if self.require_approval:
|
||||
return error_messages['require_approval']
|
||||
term_last = self.payment_term_last
|
||||
if term_last and not ignore_date:
|
||||
if now() > term_last:
|
||||
return error_messages['late_lastdate']
|
||||
if not force:
|
||||
if self.require_approval:
|
||||
return error_messages['require_approval']
|
||||
term_last = self.payment_term_last
|
||||
if term_last and not ignore_date:
|
||||
if now() > term_last:
|
||||
return error_messages['late_lastdate']
|
||||
|
||||
if self.status == self.STATUS_PENDING:
|
||||
return True
|
||||
if not self.event.settings.get('payment_term_accept_late') and not ignore_date:
|
||||
if not self.event.settings.get('payment_term_accept_late') and not ignore_date and not force:
|
||||
return error_messages['late']
|
||||
|
||||
return self._is_still_available(count_waitinglist=count_waitinglist)
|
||||
return self._is_still_available(count_waitinglist=count_waitinglist, force=force)
|
||||
|
||||
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True) -> Union[bool, str]:
|
||||
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False) -> Union[bool, str]:
|
||||
error_messages = {
|
||||
'unavailable': _('The ordered product "{item}" is no longer available.'),
|
||||
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
|
||||
}
|
||||
now_dt = now_dt or now()
|
||||
positions = self.positions.all().select_related('item', 'variation')
|
||||
positions = self.positions.all().select_related('item', 'variation', 'seat')
|
||||
quota_cache = {}
|
||||
try:
|
||||
for i, op in enumerate(positions):
|
||||
if op.seat:
|
||||
if not op.seat.is_available(ignore_orderpos=op):
|
||||
raise Quota.QuotaExceededException(error_messages['seat_unavailable'].format(seat=op.seat))
|
||||
if force:
|
||||
continue
|
||||
|
||||
quotas = list(op.quotas)
|
||||
if len(quotas) == 0:
|
||||
raise Quota.QuotaExceededException(error_messages['unavailable'].format(
|
||||
@@ -665,7 +696,7 @@ class Order(LockModel, LoggedModel):
|
||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
auth=None, attach_tickets=False):
|
||||
auth=None, attach_tickets=False, position: 'OrderPosition'=None):
|
||||
"""
|
||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||
|
||||
@@ -682,6 +713,9 @@ class Order(LockModel, LoggedModel):
|
||||
:param headers: Dictionary with additional mail headers
|
||||
:param sender: Custom email sender.
|
||||
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
||||
:param position: An order position this refers to. If given, no invoices will be attached, the tickets will
|
||||
only be attached for this position and child positions, the link will only point to the
|
||||
position and the attendee email will be used if available.
|
||||
"""
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
|
||||
@@ -693,12 +727,16 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
with language(self.locale):
|
||||
recipient = self.email
|
||||
if position and position.attendee_email:
|
||||
recipient = position.attendee_email
|
||||
|
||||
try:
|
||||
email_content = render_mail(template, context)
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers, sender,
|
||||
invoices=invoices, attach_tickets=attach_tickets
|
||||
self.event, self.locale, self, headers=headers, sender=sender,
|
||||
invoices=invoices, attach_tickets=attach_tickets,
|
||||
position=position
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
@@ -710,6 +748,7 @@ class Order(LockModel, LoggedModel):
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'position': position.positionid if position else None,
|
||||
'recipient': recipient,
|
||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||
'attach_tickets': attach_tickets,
|
||||
@@ -729,9 +768,10 @@ class Order(LockModel, LoggedModel):
|
||||
email_template = self.event.settings.mail_text_resend_link
|
||||
email_context = {
|
||||
'event': self.event.name,
|
||||
'url': build_absolute_uri(self.event, 'presale:event.order', kwargs={
|
||||
'url': build_absolute_uri(self.event, 'presale:event.order.open', kwargs={
|
||||
'order': self.code,
|
||||
'secret': self.secret
|
||||
'secret': self.secret,
|
||||
'hash': self.email_confirm_hash()
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
@@ -797,6 +837,8 @@ class QuestionAnswer(models.Model):
|
||||
max_length=255
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='question__event__organizer')
|
||||
|
||||
@property
|
||||
def backend_file_url(self):
|
||||
if self.file:
|
||||
@@ -904,6 +946,8 @@ class AbstractPosition(models.Model):
|
||||
:type voucher: Voucher
|
||||
:param meta_info: Additional meta information on the position, JSON-encoded.
|
||||
:type meta_info: str
|
||||
:param seat: Seat, if reserved seating is used.
|
||||
:type seat: Seat
|
||||
"""
|
||||
subevent = models.ForeignKey(
|
||||
SubEvent,
|
||||
@@ -950,6 +994,9 @@ class AbstractPosition(models.Model):
|
||||
verbose_name=_("Meta information"),
|
||||
null=True, blank=True
|
||||
)
|
||||
seat = models.ForeignKey(
|
||||
'Seat', null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -961,6 +1008,10 @@ class AbstractPosition(models.Model):
|
||||
else:
|
||||
return {}
|
||||
|
||||
@meta_info_data.setter
|
||||
def meta_info_data(self, d):
|
||||
self.meta_info = json.dumps(d)
|
||||
|
||||
def cache_answers(self, all=True):
|
||||
"""
|
||||
Creates two properties on the object.
|
||||
@@ -1116,6 +1167,8 @@ class OrderPayment(models.Model):
|
||||
)
|
||||
migrated = models.BooleanField(default=False)
|
||||
|
||||
objects = ScopedManager(organizer='order__event__organizer')
|
||||
|
||||
class Meta:
|
||||
ordering = ('local_id',)
|
||||
|
||||
@@ -1143,8 +1196,8 @@ class OrderPayment(models.Model):
|
||||
|
||||
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
|
||||
from pretix.base.signals import order_paid
|
||||
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date)
|
||||
if not force and can_be_paid is not True:
|
||||
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
|
||||
if can_be_paid is not True:
|
||||
self.order.log_action('pretix.event.order.quotaexceeded', {
|
||||
'message': can_be_paid
|
||||
}, user=user, auth=auth)
|
||||
@@ -1163,7 +1216,8 @@ 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 confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='', ignore_date=False, lock=True):
|
||||
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):
|
||||
"""
|
||||
Marks the payment as complete. If possible, this also marks the order as paid if no further
|
||||
payment is required
|
||||
@@ -1184,8 +1238,6 @@ class OrderPayment(models.Model):
|
||||
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
|
||||
"""
|
||||
from pretix.base.services.invoices import generate_invoice, invoice_qualified
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
with transaction.atomic():
|
||||
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||
@@ -1194,7 +1246,7 @@ class OrderPayment(models.Model):
|
||||
return
|
||||
|
||||
locked_instance.state = self.PAYMENT_STATE_CONFIRMED
|
||||
locked_instance.payment_date = now()
|
||||
locked_instance.payment_date = payment_date or now()
|
||||
locked_instance.info = self.info # required for backwards compatibility
|
||||
locked_instance.save(update_fields=['state', 'payment_date', 'info'])
|
||||
|
||||
@@ -1249,35 +1301,77 @@ class OrderPayment(models.Model):
|
||||
)
|
||||
|
||||
if send_mail:
|
||||
with language(self.order.locale):
|
||||
try:
|
||||
invoice_name = self.order.invoice_address.name
|
||||
invoice_company = self.order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = self.order.event.settings.mail_text_order_paid
|
||||
email_context = {
|
||||
'event': self.order.event.name,
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
|
||||
'order': self.order.code,
|
||||
'secret': self.order.secret
|
||||
}),
|
||||
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
'payment_info': mail_text
|
||||
}
|
||||
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
|
||||
try:
|
||||
self.order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_paid', user,
|
||||
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order paid email could not be sent')
|
||||
self._send_paid_mail(invoice, user, mail_text)
|
||||
if self.order.event.settings.mail_send_order_paid_attendee:
|
||||
for p in self.order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != self.order.email:
|
||||
self._send_paid_mail_attendee(p, user)
|
||||
|
||||
def _send_paid_mail_attendee(self, position, user):
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
with language(self.order.locale):
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.order.event.settings.name_scheme]
|
||||
email_template = self.order.event.settings.mail_text_order_paid_attendee
|
||||
email_context = {
|
||||
'event': self.order.event.name,
|
||||
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order.position', kwargs={
|
||||
'order': self.order.code,
|
||||
'secret': position.web_secret,
|
||||
'position': position.positionid
|
||||
}),
|
||||
'attendee_name': position.attendee_name,
|
||||
}
|
||||
for f, l, w in name_scheme['fields']:
|
||||
email_context['attendee_name_%s' % f] = position.attendee_name_parts.get(f, '')
|
||||
|
||||
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
|
||||
try:
|
||||
self.order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_paid', user,
|
||||
invoices=[], position=position,
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order paid email could not be sent')
|
||||
|
||||
def _send_paid_mail(self, invoice, user, mail_text):
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
with language(self.order.locale):
|
||||
try:
|
||||
invoice_name = self.order.invoice_address.name
|
||||
invoice_company = self.order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = self.order.event.settings.mail_text_order_paid
|
||||
email_context = {
|
||||
'event': self.order.event.name,
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order.open', kwargs={
|
||||
'order': self.order.code,
|
||||
'secret': self.order.secret,
|
||||
'hash': self.order.email_confirm_hash()
|
||||
}),
|
||||
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
'payment_info': mail_text
|
||||
}
|
||||
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
|
||||
try:
|
||||
self.order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_paid', user,
|
||||
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order paid email could not be sent')
|
||||
|
||||
@property
|
||||
def refunded_amount(self):
|
||||
@@ -1431,6 +1525,8 @@ class OrderRefund(models.Model):
|
||||
null=True, blank=True
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='order__event__organizer')
|
||||
|
||||
class Meta:
|
||||
ordering = ('local_id',)
|
||||
|
||||
@@ -1492,7 +1588,7 @@ class OrderRefund(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ActivePositionManager(models.Manager):
|
||||
class ActivePositionManager(ScopedManager(organizer='order__event__organizer').__class__):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(canceled=False)
|
||||
|
||||
@@ -1569,7 +1665,7 @@ class OrderFee(models.Model):
|
||||
)
|
||||
canceled = models.BooleanField(default=False)
|
||||
|
||||
all = models.Manager()
|
||||
all = ScopedManager(organizer='order__event__organizer')
|
||||
objects = ActivePositionManager()
|
||||
|
||||
@property
|
||||
@@ -1666,6 +1762,7 @@ class OrderPosition(AbstractPosition):
|
||||
verbose_name=_('Tax value')
|
||||
)
|
||||
secret = models.CharField(max_length=64, default=generate_position_secret, db_index=True)
|
||||
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
|
||||
pseudonymization_id = models.CharField(
|
||||
max_length=16,
|
||||
unique=True,
|
||||
@@ -1673,7 +1770,7 @@ class OrderPosition(AbstractPosition):
|
||||
)
|
||||
canceled = models.BooleanField(default=False)
|
||||
|
||||
all = models.Manager()
|
||||
all = ScopedManager(organizer='order__event__organizer')
|
||||
objects = ActivePositionManager()
|
||||
|
||||
class Meta:
|
||||
@@ -1773,6 +1870,7 @@ class OrderPosition(AbstractPosition):
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@scopes_disabled()
|
||||
def assign_pseudonymization_id(self):
|
||||
# This omits some character pairs completely because they are hard to read even on screens (1/I and O/0)
|
||||
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
|
||||
@@ -1789,6 +1887,60 @@ class OrderPosition(AbstractPosition):
|
||||
def event(self):
|
||||
return self.order.event
|
||||
|
||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
auth=None, attach_tickets=False):
|
||||
"""
|
||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||
|
||||
* Call ``pretix.base.services.mail.mail`` with useful values for the ``event``, ``locale``, ``recipient`` and
|
||||
``order`` parameters.
|
||||
|
||||
* Create a ``LogEntry`` with the email contents.
|
||||
|
||||
:param subject: Subject of the email
|
||||
:param template: LazyI18nString or template filename, see ``pretix.base.services.mail.mail`` for more details
|
||||
:param context: Dictionary to use for rendering the template
|
||||
:param log_entry_type: Key to be used for the log entry
|
||||
:param user: Administrative user who triggered this mail to be sent
|
||||
:param headers: Dictionary with additional mail headers
|
||||
:param sender: Custom email sender.
|
||||
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
||||
"""
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
|
||||
if not self.email:
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.locale):
|
||||
recipient = self.email
|
||||
try:
|
||||
email_content = render_mail(template, context)
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers, sender,
|
||||
invoices=invoices, attach_tickets=attach_tickets
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
else:
|
||||
self.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
auth=auth,
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'recipient': recipient,
|
||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||
'attach_tickets': attach_tickets,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CartPosition(AbstractPosition):
|
||||
"""
|
||||
@@ -1826,6 +1978,8 @@ class CartPosition(AbstractPosition):
|
||||
)
|
||||
is_bundled = models.BooleanField(default=False)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Cart position")
|
||||
verbose_name_plural = _("Cart positions")
|
||||
@@ -1875,6 +2029,8 @@ class InvoiceAddress(models.Model):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='order__event__organizer')
|
||||
|
||||
def save(self, **kwargs):
|
||||
if self.order:
|
||||
self.order.touch()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import json
|
||||
from collections import namedtuple
|
||||
|
||||
import jsonschema
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent
|
||||
|
||||
|
||||
@deconstructible
|
||||
class SeatingPlanLayoutValidator:
|
||||
def __call__(self, value):
|
||||
if not isinstance(value, dict):
|
||||
try:
|
||||
val = json.loads(value)
|
||||
except ValueError:
|
||||
raise ValidationError(_('Your layout file is not a valid JSON file.'))
|
||||
else:
|
||||
val = value
|
||||
with open(finders.find('seating/seating-plan.schema.json'), 'r') as f:
|
||||
schema = json.loads(f.read())
|
||||
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)))
|
||||
|
||||
|
||||
class SeatingPlan(LoggedModel):
|
||||
"""
|
||||
Represents an abstract seating plan, without relation to any event.
|
||||
"""
|
||||
name = models.CharField(max_length=190, verbose_name=_('Name'))
|
||||
organizer = models.ForeignKey(Organizer, related_name='seating_plans', on_delete=models.CASCADE)
|
||||
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
|
||||
|
||||
Category = namedtuple('Categrory', 'name')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def layout_data(self):
|
||||
return json.loads(self.layout)
|
||||
|
||||
@layout_data.setter
|
||||
def layout_data(self, v):
|
||||
self.layout = json.dumps(v)
|
||||
|
||||
def get_categories(self):
|
||||
return [
|
||||
self.Category(name=c['name'])
|
||||
for c in self.layout_data['categories']
|
||||
]
|
||||
|
||||
def iter_all_seats(self):
|
||||
for z in self.layout_data['zones']:
|
||||
for r in z['rows']:
|
||||
for s in r['seats']:
|
||||
yield self.RawSeat(
|
||||
number=s['seat_number'],
|
||||
guid=s['seat_guid'],
|
||||
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
|
||||
row=r['row_number'],
|
||||
category=s['category']
|
||||
)
|
||||
|
||||
|
||||
class SeatCategoryMapping(models.Model):
|
||||
"""
|
||||
Input seating plans have abstract "categories", such as "Balcony seat", etc. This model maps them to actual
|
||||
pretix product on a per-(sub)event level.
|
||||
"""
|
||||
event = models.ForeignKey(Event, related_name='seat_category_mappings', on_delete=models.CASCADE)
|
||||
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seat_category_mappings', on_delete=models.CASCADE)
|
||||
layout_category = models.CharField(max_length=190)
|
||||
product = models.ForeignKey(Item, related_name='seat_category_mappings', on_delete=models.CASCADE)
|
||||
|
||||
|
||||
class Seat(models.Model):
|
||||
"""
|
||||
This model is used to represent every single specific seat within an (sub)event that can be selected. It's mainly
|
||||
used for internal bookkeeping and not to be modified by users directly.
|
||||
"""
|
||||
event = models.ForeignKey(Event, related_name='seats', on_delete=models.CASCADE)
|
||||
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=190)
|
||||
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)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def is_available(self, ignore_cart=None, ignore_orderpos=None):
|
||||
from .orders import Order
|
||||
|
||||
if self.blocked:
|
||||
return False
|
||||
opqs = self.orderposition_set.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
|
||||
cpqs = self.cartposition_set.filter(expires__gte=now())
|
||||
if ignore_cart:
|
||||
cpqs = cpqs.exclude(pk=ignore_cart.pk)
|
||||
if ignore_orderpos:
|
||||
opqs = opqs.exclude(pk=ignore_orderpos.pk)
|
||||
return not opqs.exists() and not cpqs.exists()
|
||||
@@ -8,6 +8,9 @@ from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.models import SeatCategoryMapping
|
||||
|
||||
from ..decimal import round_decimal
|
||||
from .base import LoggedModel
|
||||
@@ -23,6 +26,7 @@ def _generate_random_code(prefix=None):
|
||||
return get_random_string(length=settings.ENTROPY['voucher_code'], allowed_chars=charset)
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
def generate_code(prefix=None):
|
||||
while True:
|
||||
code = _generate_random_code(prefix=prefix)
|
||||
@@ -172,6 +176,12 @@ class Voucher(LoggedModel):
|
||||
help_text=_("The text entered in this field will not be visible to the user and is available for your "
|
||||
"convenience.")
|
||||
)
|
||||
show_hidden_items = models.BooleanField(
|
||||
verbose_name=_("Shows hidden products that match this voucher"),
|
||||
default=True
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Voucher")
|
||||
@@ -391,3 +401,11 @@ class Voucher(LoggedModel):
|
||||
"""
|
||||
|
||||
return Order.objects.filter(all_positions__voucher__in=[self]).distinct()
|
||||
|
||||
def seating_available(self):
|
||||
kwargs = {}
|
||||
if self.subevent:
|
||||
kwargs['subevent'] = self.subevent
|
||||
if self.quota_id:
|
||||
return SeatCategoryMapping.objects.filter(product__quotas__pk=self.quota_id, **kwargs).exists()
|
||||
return self.item.seat_category_mappings.filter(**kwargs).exists()
|
||||
|
||||
@@ -4,6 +4,7 @@ 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_scopes import ScopedManager
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Voucher
|
||||
@@ -67,6 +68,8 @@ class WaitingListEntry(LoggedModel):
|
||||
)
|
||||
priority = models.IntegerField(default=0)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Waiting list entry")
|
||||
verbose_name_plural = _("Waiting list entries")
|
||||
|
||||
@@ -238,6 +238,11 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"TIME_FORMAT"
|
||||
) if ev.date_admission else ""
|
||||
}),
|
||||
("seat", {
|
||||
"label": _("Seat name"),
|
||||
"editor_sample": _("3, 4-5"),
|
||||
"evaluate": lambda op, order, ev: str(op.seat if op.seat else _('General admission'))
|
||||
}),
|
||||
))
|
||||
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ class RelativeDateWrapper:
|
||||
else:
|
||||
base_date = getattr(event, self.data.base_date_name) or event.date_from
|
||||
|
||||
oldoffset = base_date.utcoffset()
|
||||
oldoffset = base_date.astimezone(tz).utcoffset()
|
||||
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before)
|
||||
if self.data.time:
|
||||
new_date = new_date.replace(
|
||||
|
||||
@@ -6,15 +6,16 @@ from typing import List, Optional
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import DatabaseError, transaction
|
||||
from django.db.models import Q
|
||||
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_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation,
|
||||
Voucher,
|
||||
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, Seat,
|
||||
SeatCategoryMapping, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import OrderFee
|
||||
@@ -23,7 +24,7 @@ from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.checkin import _save_answers
|
||||
from pretix.base.services.locking import LockTimeoutException, NoLockManager
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.tasks import ProfiledTask
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.celery_app import app
|
||||
@@ -90,15 +91,20 @@ error_messages = {
|
||||
'product %(base)s.'),
|
||||
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
|
||||
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
|
||||
'seat_required': _('You need to select a specific seat.'),
|
||||
'seat_invalid': _('Please select a valid seat.'),
|
||||
'seat_forbidden': _('You can not select a seat for this position.'),
|
||||
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'),
|
||||
'seat_multiple': _('You can not select the same seat multiple times.'),
|
||||
}
|
||||
|
||||
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
|
||||
'addon_to', 'subevent', 'includes_tax', 'bundled'))
|
||||
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
||||
'quotas', 'subevent'))
|
||||
'quotas', 'subevent', 'seat'))
|
||||
order = {
|
||||
RemoveOperation: 10,
|
||||
ExtendOperation: 20,
|
||||
@@ -116,6 +122,7 @@ class CartManager:
|
||||
self._items_cache = {}
|
||||
self._subevents_cache = {}
|
||||
self._variations_cache = {}
|
||||
self._seated_cache = {}
|
||||
self._expiry = None
|
||||
self.invoice_address = invoice_address
|
||||
self._widget_data = widget_data or {}
|
||||
@@ -127,6 +134,11 @@ class CartManager:
|
||||
Q(cart_id=self.cart_id) & Q(event=self.event)
|
||||
).select_related('item', 'subevent')
|
||||
|
||||
def _is_seated(self, item, subevent):
|
||||
if (item, subevent) not in self._seated_cache:
|
||||
self._seated_cache[item, subevent] = item.seat_category_mappings.filter(subevent=subevent).exists()
|
||||
return self._seated_cache[item, subevent]
|
||||
|
||||
def _calculate_expiry(self):
|
||||
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
||||
|
||||
@@ -187,6 +199,8 @@ class CartManager:
|
||||
i.pk: i
|
||||
for i in self.event.items.select_related('category').prefetch_related(
|
||||
'addons', 'bundles', 'addons__addon_category', 'quotas'
|
||||
).annotate(
|
||||
has_variations=Count('variations'),
|
||||
).filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)
|
||||
@@ -211,10 +225,7 @@ class CartManager:
|
||||
|
||||
def _check_item_constraints(self, op):
|
||||
if isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
|
||||
if op.item.require_voucher and op.voucher is None:
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if op.item.hide_without_voucher and (op.voucher is None or op.voucher.item is None or op.voucher.item.pk != op.item.pk):
|
||||
if (op.item.require_voucher or op.item.hide_without_voucher) and (op.voucher is None or not op.voucher.show_hidden_items):
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if not op.item.is_available() or (op.variation and not op.variation.active):
|
||||
@@ -223,6 +234,12 @@ class CartManager:
|
||||
if self._sales_channel not in op.item.sales_channels:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
if op.item.has_variations and not op.variation:
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
if op.variation and op.variation.item_id != op.item.pk:
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
|
||||
@@ -238,6 +255,16 @@ class CartManager:
|
||||
if op.subevent and op.subevent.presale_has_ended:
|
||||
raise CartError(error_messages['ended'])
|
||||
|
||||
seated = self._is_seated(op.item, op.subevent)
|
||||
if seated and (not op.seat or op.seat.blocked):
|
||||
raise CartError(error_messages['seat_invalid'])
|
||||
elif op.seat and not seated:
|
||||
raise CartError(error_messages['seat_forbidden'])
|
||||
elif op.seat and op.seat.product != op.item:
|
||||
raise CartError(error_messages['seat_invalid'])
|
||||
elif op.seat and op.count > 1:
|
||||
raise CartError('Invalid request: A seat can only be bought once.')
|
||||
|
||||
if op.subevent:
|
||||
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||
if tlv:
|
||||
@@ -300,6 +327,13 @@ class CartManager:
|
||||
def extend_expired_positions(self):
|
||||
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
|
||||
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
|
||||
).annotate(
|
||||
requires_seat=Exists(
|
||||
SeatCategoryMapping.objects.filter(
|
||||
Q(product=OuterRef('item'))
|
||||
& (Q(subevent=OuterRef('subevent')) if self.event.has_subevents else Q(subevent__isnull=True))
|
||||
)
|
||||
)
|
||||
).prefetch_related(
|
||||
'item__quotas',
|
||||
'variation__quotas',
|
||||
@@ -312,6 +346,8 @@ class CartManager:
|
||||
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
|
||||
continue
|
||||
|
||||
cp.item.requires_seat = cp.requires_seat
|
||||
|
||||
if cp.is_bundled:
|
||||
try:
|
||||
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
|
||||
@@ -358,7 +394,7 @@ class CartManager:
|
||||
|
||||
op = self.ExtendOperation(
|
||||
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
|
||||
price=price, quotas=quotas, subevent=cp.subevent
|
||||
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
|
||||
@@ -377,12 +413,6 @@ class CartManager:
|
||||
operations = []
|
||||
|
||||
for i in items:
|
||||
# Check whether the specified items are part of what we just fetched from the database
|
||||
# If they are not, the user supplied item IDs which either do not exist or belong to
|
||||
# a different event
|
||||
if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
if self.event.has_subevents:
|
||||
if not i.get('subevent'):
|
||||
raise CartError(error_messages['subevent_required'])
|
||||
@@ -390,6 +420,24 @@ class CartManager:
|
||||
else:
|
||||
subevent = None
|
||||
|
||||
# When a seat is given, we ignore the item that was given, since we can infer it from the
|
||||
# seat. The variation is still relevant, though!
|
||||
seat = None
|
||||
if i.get('seat'):
|
||||
try:
|
||||
seat = (subevent or self.event).seats.get(seat_guid=i.get('seat'))
|
||||
except Seat.DoesNotExist:
|
||||
raise CartError(error_messages['seat_invalid'])
|
||||
i['item'] = seat.product_id
|
||||
if i['item'] not in self._items_cache:
|
||||
self._update_items_cache([i['item']], [i['variation']])
|
||||
|
||||
# Check whether the specified items are part of what we just fetched from the database
|
||||
# If they are not, the user supplied item IDs which either do not exist or belong to
|
||||
# a different event
|
||||
if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
item = self._items_cache[i['item']]
|
||||
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
|
||||
voucher = None
|
||||
@@ -445,7 +493,7 @@ class CartManager:
|
||||
bop = self.AddOperation(
|
||||
count=bundle.count, item=bitem, variation=bvar, price=bprice,
|
||||
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
|
||||
includes_tax=bool(bprice.rate), bundled=[]
|
||||
includes_tax=bool(bprice.rate), bundled=[], seat=None
|
||||
)
|
||||
self._check_item_constraints(bop)
|
||||
bundled.append(bop)
|
||||
@@ -454,7 +502,7 @@ class CartManager:
|
||||
|
||||
op = self.AddOperation(
|
||||
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
|
||||
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled
|
||||
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
@@ -560,7 +608,7 @@ class CartManager:
|
||||
|
||||
op = self.AddOperation(
|
||||
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
||||
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[]
|
||||
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=cp.seat
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
@@ -634,7 +682,7 @@ class CartManager:
|
||||
Q(voucher=voucher) & Q(event=self.event) &
|
||||
Q(expires__gte=self.now_dt)
|
||||
).exclude(pk__in=[
|
||||
op.position.voucher_id for op in self._operations if isinstance(op, self.ExtendOperation)
|
||||
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
|
||||
])
|
||||
cart_count = redeemed_in_carts.count()
|
||||
v_avail = voucher.max_usages - voucher.redeemed - cart_count
|
||||
@@ -686,6 +734,7 @@ class CartManager:
|
||||
err = err or self._check_min_per_product()
|
||||
|
||||
self._operations.sort(key=lambda a: self.order[type(a)])
|
||||
seats_seen = set()
|
||||
|
||||
for op in self._operations:
|
||||
if isinstance(op, self.RemoveOperation):
|
||||
@@ -699,6 +748,11 @@ class CartManager:
|
||||
# Create a CartPosition for as much items as we can
|
||||
requested_count = quota_available_count = voucher_available_count = op.count
|
||||
|
||||
if op.seat:
|
||||
if op.seat in seats_seen:
|
||||
err = err or error_messages['seat_multiple']
|
||||
seats_seen.add(op.seat)
|
||||
|
||||
if op.quotas:
|
||||
quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas))
|
||||
|
||||
@@ -744,12 +798,16 @@ class CartManager:
|
||||
available_count = 0
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.seat and not op.seat.is_available():
|
||||
available_count = 0
|
||||
err = err or error_messages['seat_unavailable']
|
||||
|
||||
for k in range(available_count):
|
||||
cp = CartPosition(
|
||||
event=self.event, item=op.item, variation=op.variation,
|
||||
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
|
||||
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
|
||||
subevent=op.subevent, includes_tax=op.includes_tax
|
||||
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat
|
||||
)
|
||||
if self.event.settings.attendee_names_asked:
|
||||
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
|
||||
@@ -788,7 +846,11 @@ class CartManager:
|
||||
|
||||
new_cart_positions.append(cp)
|
||||
elif isinstance(op, self.ExtendOperation):
|
||||
if available_count == 1:
|
||||
if op.seat and not op.seat.is_available(ignore_cart=op.position):
|
||||
err = err or error_messages['seat_unavailable']
|
||||
op.position.addons.all().delete()
|
||||
op.position.delete()
|
||||
elif available_count == 1:
|
||||
op.position.expires = self._expiry
|
||||
op.position.price = op.price.gross
|
||||
try:
|
||||
@@ -819,6 +881,9 @@ class CartManager:
|
||||
# If any quotas are affected that are not unlimited, we lock
|
||||
return True
|
||||
|
||||
if any(getattr(o, 'seat', False) for o in self._operations):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def commit(self):
|
||||
@@ -902,23 +967,22 @@ def get_fees(event, request, total, invoice_address, provider):
|
||||
return fees
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
|
||||
invoice_address: int=None, widget_data=None, sales_channel='web') -> None:
|
||||
"""
|
||||
Adds a list of items to a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param items: A list of dicts with the keys item, variation, count, custom_price, voucher
|
||||
:param items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID
|
||||
:param cart_id: Session ID of a guest
|
||||
:raises CartError: On any error that occured
|
||||
"""
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
|
||||
ia = False
|
||||
if invoice_address:
|
||||
try:
|
||||
ia = InvoiceAddress.objects.get(pk=invoice_address)
|
||||
with scopes_disabled():
|
||||
ia = InvoiceAddress.objects.get(pk=invoice_address)
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
@@ -934,8 +998,8 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def remove_cart_position(self, event: int, position: int, cart_id: str=None, locale='en') -> None:
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
@@ -943,7 +1007,6 @@ def remove_cart_position(self, event: int, position: int, cart_id: str=None, loc
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
@@ -955,15 +1018,14 @@ def remove_cart_position(self, event: int, position: int, cart_id: str=None, loc
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def clear_cart(self, event: Event, cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
@@ -975,8 +1037,8 @@ def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en',
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en',
|
||||
invoice_address: int=None, sales_channel='web') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
@@ -985,12 +1047,11 @@ def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, loc
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
|
||||
ia = False
|
||||
if invoice_address:
|
||||
try:
|
||||
ia = InvoiceAddress.objects.get(pk=invoice_address)
|
||||
with scopes_disabled():
|
||||
ia = InvoiceAddress.objects.get(pk=invoice_address)
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
try:
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import timedelta
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.models import CachedCombinedTicket, CachedTicket
|
||||
|
||||
@@ -10,6 +11,7 @@ from ..signals import periodic_task
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def clean_cart_positions(sender, **kwargs):
|
||||
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=False):
|
||||
cp.delete()
|
||||
@@ -20,12 +22,14 @@ def clean_cart_positions(sender, **kwargs):
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def clean_cached_files(sender, **kwargs):
|
||||
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()):
|
||||
cf.delete()
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def clean_cached_tickets(sender, **kwargs):
|
||||
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(days=30)):
|
||||
cf.delete()
|
||||
|
||||
@@ -2,24 +2,33 @@ 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 pretix.base.i18n import language
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import CachedFile, Event, cachedfile_name
|
||||
from pretix.base.services.tasks import ProfiledTask
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.base.signals import register_data_exporters
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
|
||||
event = Event.objects.get(id=event)
|
||||
class ExportError(LazyLocaleException):
|
||||
pass
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, throws=(ExportError,))
|
||||
def export(event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
|
||||
file = CachedFile.objects.get(id=fileid)
|
||||
with language(event.settings.locale), override(event.settings.timezone):
|
||||
responses = register_data_exporters.send(event)
|
||||
for receiver, response in responses:
|
||||
ex = response(event)
|
||||
if ex.identifier == provider:
|
||||
file.filename, file.type, data = ex.render(form_data)
|
||||
d = ex.render(form_data)
|
||||
if d is None:
|
||||
raise ExportError(
|
||||
ugettext('Your export did not contain any data.')
|
||||
)
|
||||
file.filename, file.type, data = d
|
||||
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
|
||||
file.save()
|
||||
return file.pk
|
||||
|
||||
@@ -15,6 +15,7 @@ from django.utils import timezone
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
from django_countries.fields import Country
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.i18n import language
|
||||
@@ -244,20 +245,23 @@ def generate_invoice(order: Order, trigger_pdf=True):
|
||||
|
||||
@app.task(base=TransactionAwareTask)
|
||||
def invoice_pdf_task(invoice: int):
|
||||
i = Invoice.objects.get(pk=invoice)
|
||||
if i.shredded:
|
||||
return None
|
||||
if i.file:
|
||||
i.file.delete()
|
||||
with language(i.locale):
|
||||
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
|
||||
i.file.save(fname, ContentFile(fcontent))
|
||||
i.save()
|
||||
return i.file.name
|
||||
with scopes_disabled():
|
||||
i = Invoice.objects.get(pk=invoice)
|
||||
with scope(organizer=i.order.event.organizer):
|
||||
if i.shredded:
|
||||
return None
|
||||
if i.file:
|
||||
i.file.delete()
|
||||
with language(i.locale):
|
||||
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
|
||||
i.file.save(fname, ContentFile(fcontent))
|
||||
i.save()
|
||||
return i.file.name
|
||||
|
||||
|
||||
def invoice_qualified(order: Order):
|
||||
if order.total == Decimal('0.00') or order.require_approval:
|
||||
if order.total == Decimal('0.00') or order.require_approval or \
|
||||
order.sales_channel not in order.event.settings.get('invoice_generate_sales_channels'):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import smtplib
|
||||
import warnings
|
||||
from email.utils import formataddr
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
@@ -9,11 +10,14 @@ from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import get_template
|
||||
from django.utils.translation import ugettext as _
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.email import ClassicMailRenderer
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Event, Invoice, InvoiceAddress, Order
|
||||
from pretix.base.models import (
|
||||
Event, Invoice, InvoiceAddress, Order, OrderPosition,
|
||||
)
|
||||
from pretix.base.services.invoices import invoice_pdf_task
|
||||
from pretix.base.services.tasks import TransactionAwareTask
|
||||
from pretix.base.services.tickets import get_tickets_for_order
|
||||
@@ -38,8 +42,8 @@ class SendMailException(Exception):
|
||||
|
||||
def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, event: Event=None, locale: str=None,
|
||||
order: Order=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
attach_tickets=False):
|
||||
order: Order=None, position: OrderPosition=None, headers: dict=None, sender: str=None,
|
||||
invoices: list=None, attach_tickets=False):
|
||||
"""
|
||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||
|
||||
@@ -60,6 +64,9 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
:param order: The order this email is related to (optional). If set, this will be used to include a link to the
|
||||
order below the email.
|
||||
|
||||
:param order: The order position this email is related to (optional). If set, this will be used to include a link
|
||||
to the order position instead of the order below the email.
|
||||
|
||||
:param headers: A dict of custom mail headers to add to the mail
|
||||
|
||||
:param locale: The locale to be used while evaluating the subject and the template
|
||||
@@ -100,7 +107,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
subject = str(subject).format_map(context)
|
||||
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
|
||||
if event:
|
||||
sender = formataddr((str(event.name), sender))
|
||||
sender_name = event.settings.mail_from_name or str(event.name)
|
||||
sender = formataddr((sender_name, sender))
|
||||
else:
|
||||
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
|
||||
|
||||
@@ -111,7 +119,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
if event:
|
||||
renderer = event.get_html_mail_renderer()
|
||||
if event.settings.mail_bcc:
|
||||
bcc.append(event.settings.mail_bcc)
|
||||
for bcc_mail in event.settings.mail_bcc.split(','):
|
||||
bcc.append(bcc_mail.strip())
|
||||
|
||||
if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail and not headers.get('Reply-To'):
|
||||
headers['Reply-To'] = event.settings.contact_mail
|
||||
@@ -130,9 +139,26 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
body_plain += signature
|
||||
body_plain += "\r\n\r\n-- \r\n"
|
||||
|
||||
if order:
|
||||
if order.testmode:
|
||||
subject = "[TESTMODE] " + subject
|
||||
if order and order.testmode:
|
||||
subject = "[TESTMODE] " + subject
|
||||
|
||||
if order and position:
|
||||
body_plain += _(
|
||||
"You are receiving this email because someone placed an order for {event} for you."
|
||||
).format(event=event.name)
|
||||
body_plain += "\r\n"
|
||||
body_plain += _(
|
||||
"You can view your order details at the following URL:\n{orderurl}."
|
||||
).replace("\n", "\r\n").format(
|
||||
event=event.name, orderurl=build_absolute_uri(
|
||||
order.event, 'presale:event.order.position', kwargs={
|
||||
'order': order.code,
|
||||
'secret': position.web_secret,
|
||||
'position': position.positionid,
|
||||
}
|
||||
)
|
||||
)
|
||||
elif order:
|
||||
body_plain += _(
|
||||
"You are receiving this email because you placed an order for {event}."
|
||||
).format(event=event.name)
|
||||
@@ -141,16 +167,24 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
"You can view your order details at the following URL:\n{orderurl}."
|
||||
).replace("\n", "\r\n").format(
|
||||
event=event.name, orderurl=build_absolute_uri(
|
||||
order.event, 'presale:event.order', kwargs={
|
||||
order.event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}
|
||||
)
|
||||
)
|
||||
body_plain += "\r\n"
|
||||
|
||||
try:
|
||||
body_html = renderer.render(content_plain, signature, str(subject), order)
|
||||
try:
|
||||
body_html = renderer.render(content_plain, signature, str(subject), order, position)
|
||||
except TypeError:
|
||||
# Backwards compatibility
|
||||
warnings.warn('E-mail renderer called without position argument because position argument is not '
|
||||
'supported.',
|
||||
DeprecationWarning)
|
||||
body_html = renderer.render(content_plain, signature, str(subject), order)
|
||||
except:
|
||||
logger.exception('Could not render HTML body')
|
||||
body_html = None
|
||||
@@ -164,8 +198,9 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
sender=sender,
|
||||
event=event.id if event else None,
|
||||
headers=headers,
|
||||
invoices=[i.pk for i in invoices] if invoices else [],
|
||||
invoices=[i.pk for i in invoices] if invoices and not position else [],
|
||||
order=order.pk if order else None,
|
||||
position=position.pk if position else None,
|
||||
attach_tickets=attach_tickets
|
||||
)
|
||||
|
||||
@@ -180,98 +215,107 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
|
||||
@app.task(base=TransactionAwareTask, bind=True)
|
||||
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
event: int=None, headers: dict=None, bcc: List[str]=None, invoices: List[int]=None,
|
||||
order: int=None, attach_tickets=False) -> bool:
|
||||
event: int=None, position: int=None, headers: dict=None, bcc: List[str]=None,
|
||||
invoices: List[int]=None, order: int=None, attach_tickets=False) -> bool:
|
||||
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||
if html is not None:
|
||||
email.attach_alternative(html, "text/html")
|
||||
if invoices:
|
||||
invoices = Invoice.objects.filter(pk__in=invoices)
|
||||
for inv in invoices:
|
||||
if inv.file:
|
||||
try:
|
||||
email.attach(
|
||||
'{}.pdf'.format(inv.number),
|
||||
inv.file.file.read(),
|
||||
'application/pdf'
|
||||
)
|
||||
except:
|
||||
logger.exception('Could not attach invoice to email')
|
||||
pass
|
||||
|
||||
if event:
|
||||
event = Event.objects.get(id=event)
|
||||
with scopes_disabled():
|
||||
event = Event.objects.get(id=event)
|
||||
backend = event.get_mail_backend()
|
||||
cm = lambda: scope(organizer=event.organizer) # noqa
|
||||
else:
|
||||
backend = get_connection(fail_silently=False)
|
||||
cm = lambda: scopes_disabled() # noqa
|
||||
|
||||
if event:
|
||||
if order:
|
||||
try:
|
||||
order = event.orders.get(pk=order)
|
||||
except Order.DoesNotExist:
|
||||
order = None
|
||||
else:
|
||||
if attach_tickets:
|
||||
args = []
|
||||
attach_size = 0
|
||||
for name, ct in get_tickets_for_order(order):
|
||||
content = ct.file.read()
|
||||
args.append((name, content, ct.type))
|
||||
attach_size += len(content)
|
||||
|
||||
if attach_size < 4 * 1024 * 1024:
|
||||
# Do not attach more than 4MB, it will bounce way to often.
|
||||
for a in args:
|
||||
try:
|
||||
email.attach(*a)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.attachments.skipped',
|
||||
data={
|
||||
'subject': 'Attachments skipped',
|
||||
'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size),
|
||||
'recipient': '',
|
||||
'invoices': [],
|
||||
}
|
||||
with cm():
|
||||
if invoices:
|
||||
invoices = Invoice.objects.filter(pk__in=invoices)
|
||||
for inv in invoices:
|
||||
if inv.file:
|
||||
try:
|
||||
email.attach(
|
||||
'{}.pdf'.format(inv.number),
|
||||
inv.file.file.read(),
|
||||
'application/pdf'
|
||||
)
|
||||
except:
|
||||
logger.exception('Could not attach invoice to email')
|
||||
pass
|
||||
if event:
|
||||
if order:
|
||||
try:
|
||||
order = event.orders.get(pk=order)
|
||||
except Order.DoesNotExist:
|
||||
order = None
|
||||
else:
|
||||
if position:
|
||||
try:
|
||||
position = order.positions.get(pk=position)
|
||||
except OrderPosition.DoesNotExist:
|
||||
attach_tickets = False
|
||||
if attach_tickets:
|
||||
args = []
|
||||
attach_size = 0
|
||||
for name, ct in get_tickets_for_order(order, base_position=position):
|
||||
content = ct.file.read()
|
||||
args.append((name, content, ct.type))
|
||||
attach_size += len(content)
|
||||
|
||||
email = email_filter.send_chained(event, 'message', message=email, order=order)
|
||||
if attach_size < 4 * 1024 * 1024:
|
||||
# Do not attach more than 4MB, it will bounce way to often.
|
||||
for a in args:
|
||||
try:
|
||||
email.attach(*a)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.attachments.skipped',
|
||||
data={
|
||||
'subject': 'Attachments skipped',
|
||||
'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size),
|
||||
'recipient': '',
|
||||
'invoices': [],
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
backend.send_messages([email])
|
||||
except smtplib.SMTPResponseException as e:
|
||||
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
|
||||
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
|
||||
logger.exception('Error sending email')
|
||||
email = email_filter.send_chained(event, 'message', message=email, order=order)
|
||||
|
||||
if order:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.error',
|
||||
data={
|
||||
'subject': 'SMTP code {}'.format(e.smtp_code),
|
||||
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
|
||||
'recipient': '',
|
||||
'invoices': [],
|
||||
}
|
||||
)
|
||||
try:
|
||||
backend.send_messages([email])
|
||||
except smtplib.SMTPResponseException as e:
|
||||
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
|
||||
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
|
||||
logger.exception('Error sending email')
|
||||
|
||||
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||
except Exception as e:
|
||||
if order:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.error',
|
||||
data={
|
||||
'subject': 'Internal error',
|
||||
'message': str(e),
|
||||
'recipient': '',
|
||||
'invoices': [],
|
||||
}
|
||||
)
|
||||
logger.exception('Error sending email')
|
||||
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||
if order:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.error',
|
||||
data={
|
||||
'subject': 'SMTP code {}'.format(e.smtp_code),
|
||||
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
|
||||
'recipient': '',
|
||||
'invoices': [],
|
||||
}
|
||||
)
|
||||
|
||||
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||
except Exception as e:
|
||||
if order:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.error',
|
||||
data={
|
||||
'subject': 'Internal error',
|
||||
'message': str(e),
|
||||
'recipient': '',
|
||||
'invoices': [],
|
||||
}
|
||||
)
|
||||
logger.exception('Error sending email')
|
||||
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||
|
||||
|
||||
def mail_send(*args, **kwargs):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.conf import settings
|
||||
from django.template.loader import get_template
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
from pretix.base.i18n import language
|
||||
@@ -12,6 +13,7 @@ from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
|
||||
@app.task(base=TransactionAwareTask)
|
||||
@scopes_disabled()
|
||||
def notify(logentry_id: int):
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
if not logentry.event:
|
||||
@@ -66,17 +68,22 @@ def notify(logentry_id: int):
|
||||
@app.task(base=ProfiledTask)
|
||||
def send_notification(logentry_id: int, action_type: str, user_id: int, method: str):
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
types = get_all_notification_types(logentry.event)
|
||||
notification_type = types.get(action_type)
|
||||
if not notification_type:
|
||||
return # Ignore, e.g. plugin not active for this event
|
||||
if logentry.event:
|
||||
sm = lambda: scope(organizer=logentry.event.organizer) # noqa
|
||||
else:
|
||||
sm = lambda: scopes_disabled() # noqa
|
||||
with sm():
|
||||
user = User.objects.get(id=user_id)
|
||||
types = get_all_notification_types(logentry.event)
|
||||
notification_type = types.get(action_type)
|
||||
if not notification_type:
|
||||
return # Ignore, e.g. plugin not active for this event
|
||||
|
||||
with language(user.locale):
|
||||
notification = notification_type.build_notification(logentry)
|
||||
with language(user.locale):
|
||||
notification = notification_type.build_notification(logentry)
|
||||
|
||||
if method == "mail":
|
||||
send_notification_mail(notification, user)
|
||||
if method == "mail":
|
||||
send_notification_mail(notification, user)
|
||||
|
||||
|
||||
def send_notification_mail(notification: Notification, user: User):
|
||||
@@ -104,7 +111,11 @@ def send_notification_mail(notification: Notification, user: User):
|
||||
|
||||
mail_send_task.apply_async(kwargs={
|
||||
'to': [user.email],
|
||||
'subject': '[{}] {}'.format(settings.PRETIX_INSTANCE_NAME, notification.title),
|
||||
'subject': '[{}] {}: {}'.format(
|
||||
settings.PRETIX_INSTANCE_NAME,
|
||||
notification.event.settings.mail_prefix or notification.event.slug.upper(),
|
||||
notification.title
|
||||
),
|
||||
'body': body_plain,
|
||||
'html': body_html,
|
||||
'sender': settings.MAIL_FROM,
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.api.models import OAuthApplication
|
||||
from pretix.base.i18n import (
|
||||
@@ -23,7 +24,7 @@ from pretix.base.i18n import (
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment,
|
||||
OrderPosition, Quota, User, Voucher,
|
||||
OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import ItemBundle
|
||||
@@ -42,11 +43,12 @@ from pretix.base.services.invoices import (
|
||||
from pretix.base.services.locking import LockTimeoutException, NoLockManager
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.tasks import ProfiledTask
|
||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import (
|
||||
allow_ticket_download, order_approved, order_canceled, order_changed,
|
||||
order_denied, order_expired, order_fee_calculation, order_placed,
|
||||
periodic_task,
|
||||
periodic_task, validate_order,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.models import modelcopy
|
||||
@@ -80,6 +82,8 @@ error_messages = {
|
||||
'affected positions have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
|
||||
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -222,9 +226,10 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
|
||||
'date': LazyDate(order.expires),
|
||||
'event': order.event.name,
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
@@ -282,9 +287,10 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
|
||||
'date': LazyDate(order.expires),
|
||||
'event': order.event.name,
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}),
|
||||
'comment': comment,
|
||||
'invoice_name': invoice_name,
|
||||
@@ -375,9 +381,10 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
email_context = {
|
||||
'event': order.event.name,
|
||||
'code': order.code,
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
})
|
||||
}
|
||||
with language(order.locale):
|
||||
@@ -423,6 +430,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
products_seen = Counter()
|
||||
changed_prices = {}
|
||||
deleted_positions = set()
|
||||
seats_seen = set()
|
||||
|
||||
def delete(cp):
|
||||
# Delete a cart position, including parents and children, if applicable
|
||||
@@ -485,17 +493,32 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
delete(cp)
|
||||
break
|
||||
|
||||
if (cp.requires_seat and not cp.seat) or (cp.seat and not cp.requires_seat) or (cp.seat and cp.seat.product != cp.item) or cp.seat in seats_seen:
|
||||
err = err or error_messages['seat_invalid']
|
||||
delete(cp)
|
||||
break
|
||||
if cp.seat:
|
||||
seats_seen.add(cp.seat)
|
||||
|
||||
if cp.item.require_voucher and cp.voucher is None:
|
||||
delete(cp)
|
||||
err = err or error_messages['voucher_required']
|
||||
break
|
||||
|
||||
if cp.item.hide_without_voucher and (cp.voucher is None or cp.voucher.item is None
|
||||
or cp.voucher.item.pk != cp.item.pk):
|
||||
if cp.item.hide_without_voucher and (cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item.pk, cp.variation.pk)):
|
||||
delete(cp)
|
||||
cp.delete()
|
||||
err = error_messages['voucher_required']
|
||||
break
|
||||
|
||||
if cp.seat:
|
||||
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every time, since we absolutely
|
||||
# can not overbook a seat.
|
||||
if not cp.seat.is_available(ignore_cart=cp) or cp.seat.blocked:
|
||||
err = err or error_messages['seat_unavailable']
|
||||
cp.delete()
|
||||
continue
|
||||
|
||||
if cp.expires >= now_dt and not cp.voucher:
|
||||
# Other checks are not necessary
|
||||
continue
|
||||
@@ -531,7 +554,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
continue
|
||||
|
||||
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
|
||||
positions[i] = cp
|
||||
cp.price = price.gross
|
||||
cp.includes_tax = bool(price.rate)
|
||||
cp.save()
|
||||
@@ -555,7 +577,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
break
|
||||
|
||||
if quota_ok:
|
||||
positions[i] = cp
|
||||
cp.expires = now_dt + timedelta(
|
||||
minutes=event.settings.get('reservation_time', as_type=int))
|
||||
cp.save()
|
||||
@@ -644,10 +665,77 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
return order, p
|
||||
|
||||
|
||||
def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'):
|
||||
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
|
||||
invoice):
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
|
||||
event = Event.objects.get(id=event)
|
||||
if pprov:
|
||||
payment_info = str(pprov.order_pending_mail_render(order))
|
||||
else:
|
||||
payment_info = None
|
||||
|
||||
email_context = {
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': event.currency,
|
||||
'total_with_currency': LazyCurrencyNumber(order.total, event.currency),
|
||||
'date': LazyDate(order.expires),
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}),
|
||||
'payment_info': payment_info,
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_subject = _('Your order: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
log_entry,
|
||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order received email could not be sent')
|
||||
|
||||
|
||||
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str):
|
||||
name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
email_context = {
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order.position', kwargs={
|
||||
'order': order.code,
|
||||
'secret': position.web_secret,
|
||||
'position': position.positionid
|
||||
}),
|
||||
'attendee_name': position.attendee_name,
|
||||
}
|
||||
for f, l, w in name_scheme['fields']:
|
||||
email_context['attendee_name_%s' % f] = position.attendee_name_parts.get(f, '')
|
||||
|
||||
email_subject = _('Your event registration: %(code)s') % {'code': order.code}
|
||||
|
||||
try:
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
log_entry,
|
||||
invoices=[],
|
||||
attach_tickets=True,
|
||||
position=position
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order received email could not be sent to attendee')
|
||||
|
||||
|
||||
def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'):
|
||||
if payment_provider:
|
||||
pprov = event.get_payment_providers().get(payment_provider)
|
||||
if not pprov:
|
||||
@@ -661,22 +749,35 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
addr = None
|
||||
if address is not None:
|
||||
try:
|
||||
addr = InvoiceAddress.objects.get(pk=address)
|
||||
with scopes_disabled():
|
||||
addr = InvoiceAddress.objects.get(pk=address)
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
positions = CartPosition.objects.filter(id__in=position_ids, event=event)
|
||||
positions = CartPosition.objects.annotate(
|
||||
requires_seat=Exists(
|
||||
SeatCategoryMapping.objects.filter(
|
||||
Q(product=OuterRef('item'))
|
||||
& (Q(subevent=OuterRef('subevent')) if event.has_subevents else Q(subevent__isnull=True))
|
||||
)
|
||||
)
|
||||
).filter(
|
||||
id__in=position_ids, event=event
|
||||
)
|
||||
|
||||
validate_order.send(event, payment_provider=pprov, email=email, positions=positions,
|
||||
locale=locale, invoice_address=addr, meta_info=meta_info)
|
||||
|
||||
lockfn = NoLockManager
|
||||
locked = False
|
||||
if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2))).exists():
|
||||
if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2)) | Q(seat__isnull=False)).exists():
|
||||
# Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date,
|
||||
# creating this order shouldn't be prone to any race conditions and we don't need to lock the event.
|
||||
locked = True
|
||||
lockfn = event.lock
|
||||
|
||||
with lockfn() as now_dt:
|
||||
positions = list(positions.select_related('item', 'variation', 'subevent', 'addon_to').prefetch_related('addons'))
|
||||
positions = list(positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons'))
|
||||
if len(positions) == 0:
|
||||
raise OrderError(error_messages['empty'])
|
||||
if len(position_ids) != len(positions):
|
||||
@@ -705,54 +806,32 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
if order.require_approval:
|
||||
email_template = event.settings.mail_text_order_placed_require_approval
|
||||
log_entry = 'pretix.event.order.email.order_placed_require_approval'
|
||||
|
||||
email_attendees = False
|
||||
elif free_order_flow:
|
||||
email_template = event.settings.mail_text_order_free
|
||||
log_entry = 'pretix.event.order.email.order_free'
|
||||
|
||||
email_attendees = event.settings.mail_send_order_free_attendee
|
||||
email_attendees_template = event.settings.mail_text_order_free_attendee
|
||||
else:
|
||||
email_template = event.settings.mail_text_order_placed
|
||||
log_entry = 'pretix.event.order.email.order_placed'
|
||||
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_attendees = event.settings.mail_send_order_placed_attendee
|
||||
email_attendees_template = event.settings.mail_text_order_placed_attendee
|
||||
|
||||
if pprov:
|
||||
payment_info = str(pprov.order_pending_mail_render(order))
|
||||
else:
|
||||
payment_info = None
|
||||
|
||||
email_context = {
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': event.currency,
|
||||
'total_with_currency': LazyCurrencyNumber(order.total, event.currency),
|
||||
'date': LazyDate(order.expires),
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'payment_info': payment_info,
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_subject = _('Your order: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
log_entry,
|
||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order received email could not be sent')
|
||||
_order_placed_email(event, order, pprov, email_template, log_entry, invoice)
|
||||
if email_attendees:
|
||||
for p in order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry)
|
||||
|
||||
return order.id
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def expire_orders(sender, **kwargs):
|
||||
eventcache = {}
|
||||
|
||||
@@ -767,6 +846,7 @@ def expire_orders(sender, **kwargs):
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def send_expiry_warnings(sender, **kwargs):
|
||||
eventcache = {}
|
||||
today = now().replace(hour=0, minute=0, second=0)
|
||||
@@ -800,9 +880,10 @@ def send_expiry_warnings(sender, **kwargs):
|
||||
email_template = eventsettings.mail_text_order_expire_warning
|
||||
email_context = {
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order.open', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
'secret': o.secret,
|
||||
'hash': o.email_confirm_hash()
|
||||
}),
|
||||
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
|
||||
'invoice_name': invoice_name,
|
||||
@@ -823,6 +904,7 @@ def send_expiry_warnings(sender, **kwargs):
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def send_download_reminders(sender, **kwargs):
|
||||
today = now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
@@ -851,9 +933,10 @@ def send_download_reminders(sender, **kwargs):
|
||||
email_template = e.settings.mail_text_download_reminder
|
||||
email_context = {
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order.open', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
'secret': o.secret,
|
||||
'hash': o.email_confirm_hash()
|
||||
}),
|
||||
}
|
||||
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
|
||||
@@ -866,6 +949,31 @@ def send_download_reminders(sender, **kwargs):
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
|
||||
if e.settings.mail_send_download_reminder_attendee:
|
||||
name_scheme = PERSON_NAME_SCHEMES[e.settings.name_scheme]
|
||||
for p in o.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
|
||||
email_template = e.settings.mail_text_download_reminder_attendee
|
||||
email_context = {
|
||||
'event': e.name,
|
||||
'url': build_absolute_uri(e, 'presale:event.order.position', kwargs={
|
||||
'order': o.code,
|
||||
'secret': p.web_secret,
|
||||
'position': p.positionid
|
||||
}),
|
||||
'attendee_name': p.attendee_name,
|
||||
}
|
||||
for f, l, w in name_scheme['fields']:
|
||||
email_context['attendee_name_%s' % f] = p.attendee_name_parts.get(f, '')
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.download_reminder_sent',
|
||||
attach_tickets=True, position=p
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent to attendee')
|
||||
|
||||
|
||||
class OrderChangeManager:
|
||||
error_messages = {
|
||||
@@ -880,12 +988,17 @@ class OrderChangeManager:
|
||||
'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'),
|
||||
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
|
||||
'subevent_required': _('You need to choose a subevent for the new position.'),
|
||||
'seat_unavailable': _('The selected seat "{seat}" is not available.'),
|
||||
'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'),
|
||||
'seat_required': _('The selected product requires you to select a seat.'),
|
||||
'seat_forbidden': _('The selected product does not allow to select a seat.'),
|
||||
}
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
|
||||
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
|
||||
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
|
||||
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
|
||||
CancelOperation = namedtuple('CancelOperation', ('position',))
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent'))
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat'))
|
||||
SplitOperation = namedtuple('SplitOperation', ('position',))
|
||||
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
|
||||
|
||||
@@ -898,6 +1011,7 @@ class OrderChangeManager:
|
||||
self._committed = False
|
||||
self._totaldiff = 0
|
||||
self._quotadiff = Counter()
|
||||
self._seatdiff = Counter()
|
||||
self._operations = []
|
||||
self.notify = notify
|
||||
self._invoice_dirty = False
|
||||
@@ -915,6 +1029,13 @@ class OrderChangeManager:
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.ItemOperation(position, item, variation))
|
||||
|
||||
def change_seat(self, position: OrderPosition, seat: Seat):
|
||||
if position.seat:
|
||||
self._seatdiff.subtract([position.seat])
|
||||
if seat:
|
||||
self._seatdiff.update([seat])
|
||||
self._operations.append(self.SeatOperation(position, seat))
|
||||
|
||||
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
|
||||
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
|
||||
invoice_address=self._invoice_address)
|
||||
@@ -970,12 +1091,14 @@ class OrderChangeManager:
|
||||
self._totaldiff += -position.price
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.CancelOperation(position))
|
||||
if position.seat:
|
||||
self._seatdiff.subtract([position.seat])
|
||||
|
||||
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
|
||||
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
|
||||
subevent: SubEvent = None):
|
||||
subevent: SubEvent = None, seat: Seat = None):
|
||||
if price is None:
|
||||
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
|
||||
else:
|
||||
@@ -994,6 +1117,14 @@ class OrderChangeManager:
|
||||
if self.order.event.has_subevents and not subevent:
|
||||
raise OrderError(self.error_messages['subevent_required'])
|
||||
|
||||
seated = item.seat_category_mappings.filter(subevent=subevent).exists()
|
||||
if seated and not seat:
|
||||
raise OrderError(self.error_messages['seat_required'])
|
||||
elif not seated and seat:
|
||||
raise OrderError(self.error_messages['seat_forbidden'])
|
||||
if seat and subevent and seat.subevent_id != subevent:
|
||||
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=seat.name))
|
||||
|
||||
new_quotas = (variation.quotas.filter(subevent=subevent)
|
||||
if variation else item.quotas.filter(subevent=subevent))
|
||||
if not new_quotas:
|
||||
@@ -1004,7 +1135,9 @@ class OrderChangeManager:
|
||||
|
||||
self._totaldiff += price.gross
|
||||
self._quotadiff.update(new_quotas)
|
||||
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent))
|
||||
if seat:
|
||||
self._seatdiff.update([seat])
|
||||
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat))
|
||||
|
||||
def split(self, position: OrderPosition):
|
||||
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
|
||||
@@ -1012,6 +1145,26 @@ class OrderChangeManager:
|
||||
|
||||
self._operations.append(self.SplitOperation(position))
|
||||
|
||||
def _check_seats(self):
|
||||
for seat, diff in self._seatdiff.items():
|
||||
if diff <= 0:
|
||||
continue
|
||||
if not seat.is_available() or diff > 1:
|
||||
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
|
||||
|
||||
if self.event.has_subevents:
|
||||
state = {}
|
||||
for p in self.order.positions.all():
|
||||
state[p] = {'seat': p.seat, 'subevent': p.subevent}
|
||||
for op in self._operations:
|
||||
if isinstance(op, self.SeatOperation):
|
||||
state[op.position]['seat'] = op.seat
|
||||
elif isinstance(op, self.SubeventOperation):
|
||||
state[op.position]['subevent'] = op.subevent
|
||||
for v in state.values():
|
||||
if v['seat'] and v['seat'].subevent_id != v['subevent'].pk:
|
||||
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=v['seat'].name))
|
||||
|
||||
def _check_quotas(self):
|
||||
for quota, diff in self._quotadiff.items():
|
||||
if diff <= 0:
|
||||
@@ -1078,7 +1231,7 @@ class OrderChangeManager:
|
||||
raise OrderError(self.error_messages['paid_to_free_exceeded'])
|
||||
|
||||
def _perform_operations(self):
|
||||
nextposid = self.order.positions.aggregate(m=Max('positionid'))['m'] + 1
|
||||
nextposid = self.order.all_positions.aggregate(m=Max('positionid'))['m'] + 1
|
||||
split_positions = []
|
||||
|
||||
for op in self._operations:
|
||||
@@ -1098,6 +1251,17 @@ class OrderChangeManager:
|
||||
op.position.variation = op.variation
|
||||
op.position._calculate_tax()
|
||||
op.position.save()
|
||||
elif isinstance(op, self.SeatOperation):
|
||||
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'old_seat': op.position.seat.name if op.position.seat else "-",
|
||||
'new_seat': op.seat.name if op.seat else "-",
|
||||
'old_seat_id': op.position.seat.pk if op.position.seat else None,
|
||||
'new_seat_id': op.seat.pk if op.seat else None,
|
||||
})
|
||||
op.position.seat = op.seat
|
||||
op.position.save()
|
||||
elif isinstance(op, self.SubeventOperation):
|
||||
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
|
||||
'position': op.position.pk,
|
||||
@@ -1151,7 +1315,7 @@ class OrderChangeManager:
|
||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||
price=op.price.gross, order=self.order, tax_rate=op.price.rate,
|
||||
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
|
||||
positionid=nextposid, subevent=op.subevent
|
||||
positionid=nextposid, subevent=op.subevent, seat=op.seat
|
||||
)
|
||||
nextposid += 1
|
||||
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
|
||||
@@ -1162,6 +1326,7 @@ class OrderChangeManager:
|
||||
'price': op.price.gross,
|
||||
'positionid': pos.positionid,
|
||||
'subevent': op.subevent.pk if op.subevent else None,
|
||||
'seat': op.seat.pk if op.seat else None,
|
||||
})
|
||||
elif isinstance(op, self.SplitOperation):
|
||||
split_positions.append(op.position)
|
||||
@@ -1350,9 +1515,10 @@ class OrderChangeManager:
|
||||
email_template = order.event.settings.mail_text_order_changed
|
||||
email_context = {
|
||||
'event': order.event.name,
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
@@ -1385,6 +1551,7 @@ class OrderChangeManager:
|
||||
raise OrderError(self.error_messages['not_pending_or_paid'])
|
||||
if check_quotas:
|
||||
self._check_quotas()
|
||||
self._check_seats()
|
||||
self._check_complete_cancel()
|
||||
self._perform_operations()
|
||||
self._recalculate_total_and_payment_fee()
|
||||
@@ -1418,8 +1585,8 @@ class OrderChangeManager:
|
||||
return pprov
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def perform_order(self, event: Event, payment_provider: str, positions: List[str],
|
||||
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
|
||||
sales_channel: str='web'):
|
||||
with language(locale):
|
||||
@@ -1434,6 +1601,7 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
@scopes_disabled()
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||
device=None, cancellation_fee=None, try_auto_refund=False):
|
||||
try:
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import F, Max, OuterRef, Q, Subquery
|
||||
from django.conf import settings
|
||||
from django.db.models import Max, Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.models import LogEntry, Quota
|
||||
from pretix.base.models import Event, LogEntry
|
||||
from pretix.celery_app import app
|
||||
|
||||
from ..signals import periodic_task
|
||||
@@ -17,20 +18,27 @@ def build_all_quota_caches(sender, **kwargs):
|
||||
|
||||
|
||||
@app.task
|
||||
@scopes_disabled()
|
||||
def refresh_quota_caches():
|
||||
last_activity = LogEntry.objects.filter(
|
||||
event=OuterRef('event_id'),
|
||||
# Active events
|
||||
active = LogEntry.objects.using(settings.DATABASE_REPLICA).filter(
|
||||
datetime__gt=now() - timedelta(days=7)
|
||||
).order_by().values('event').annotate(
|
||||
m=Max('datetime')
|
||||
).values(
|
||||
'm'
|
||||
last_activity=Max('datetime')
|
||||
)
|
||||
quotas = Quota.objects.annotate(
|
||||
last_activity=Subquery(last_activity, output_field=models.DateTimeField())
|
||||
).filter(
|
||||
Q(cached_availability_time__isnull=True) |
|
||||
Q(cached_availability_time__lt=F('last_activity')) |
|
||||
Q(cached_availability_time__lt=now() - timedelta(hours=2), last_activity__gt=now() - timedelta(days=7))
|
||||
).select_related('subevent')
|
||||
for q in quotas:
|
||||
q.availability()
|
||||
for a in active:
|
||||
try:
|
||||
e = Event.objects.using(settings.DATABASE_REPLICA).get(pk=a['event'])
|
||||
except Event.DoesNotExist:
|
||||
continue
|
||||
quotas = e.quotas.filter(
|
||||
Q(cached_availability_time__isnull=True) |
|
||||
Q(cached_availability_time__lt=a['last_activity']) |
|
||||
Q(cached_availability_time__lt=now() - timedelta(hours=2))
|
||||
).filter(
|
||||
Q(subevent__isnull=True) |
|
||||
Q(subevent__date_to__isnull=False, subevent__date_to__gte=now() - timedelta(days=14)) |
|
||||
Q(subevent__date_from__gte=now() - timedelta(days=14))
|
||||
)
|
||||
for q in quotas:
|
||||
q.availability()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from django.db.models import Count
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import CartPosition, Seat
|
||||
|
||||
|
||||
class SeatProtected(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def validate_plan_change(event, subevent, plan):
|
||||
current_taken_seats = set(
|
||||
event.seats.select_related('product')
|
||||
.annotate(has_op=Count('orderposition'))
|
||||
.filter(subevent=subevent, has_op=True)
|
||||
.values_list('seat_guid', flat=True)
|
||||
)
|
||||
new_seats = {
|
||||
ss.guid for ss in plan.iter_all_seats()
|
||||
} if plan else set()
|
||||
leftovers = list(current_taken_seats - new_seats)
|
||||
if leftovers:
|
||||
raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is '
|
||||
'already sold.').format(leftovers[0]))
|
||||
|
||||
|
||||
def generate_seats(event, subevent, plan, mapping):
|
||||
current_seats = {
|
||||
s.seat_guid: s for s in
|
||||
event.seats.select_related('product').annotate(has_op=Count('orderposition')).filter(subevent=subevent)
|
||||
}
|
||||
create_seats = []
|
||||
if plan:
|
||||
for ss in plan.iter_all_seats():
|
||||
p = mapping.get(ss.category)
|
||||
if ss.guid in current_seats:
|
||||
seat = current_seats.pop(ss.guid)
|
||||
if seat.product != p:
|
||||
seat.product = p
|
||||
seat.save()
|
||||
else:
|
||||
create_seats.append(Seat(
|
||||
event=event,
|
||||
subevent=subevent,
|
||||
seat_guid=ss.guid,
|
||||
name=ss.name,
|
||||
product=p,
|
||||
))
|
||||
|
||||
for s in current_seats.values():
|
||||
if s.has_op:
|
||||
raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is '
|
||||
'already sold.').format(s.name))
|
||||
|
||||
Seat.objects.bulk_create(create_seats)
|
||||
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()
|
||||
Seat.objects.filter(pk__in=[s.pk for s in current_seats.values()]).delete()
|
||||
@@ -11,14 +11,13 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import CachedFile, Event, cachedfile_name
|
||||
from pretix.base.services.tasks import ProfiledTask
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.base.shredder import ShredError
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def export(event: str, shredders: List[str]) -> None:
|
||||
event = Event.objects.get(id=event)
|
||||
@app.task(base=ProfiledEventTask)
|
||||
def export(event: Event, shredders: List[str]) -> None:
|
||||
known_shredders = event.get_data_shredders()
|
||||
|
||||
with NamedTemporaryFile() as rawfile:
|
||||
@@ -63,9 +62,8 @@ def export(event: str, shredders: List[str]) -> None:
|
||||
return cf.pk
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, throws=(ShredError,))
|
||||
def shred(event: str, fileid: str, confirm_code: str) -> None:
|
||||
event = Event.objects.get(id=event)
|
||||
@app.task(base=ProfiledEventTask, throws=(ShredError,))
|
||||
def shred(event: Event, fileid: str, confirm_code: str) -> None:
|
||||
known_shredders = event.get_data_shredders()
|
||||
try:
|
||||
cf = CachedFile.objects.get(pk=fileid)
|
||||
|
||||
@@ -14,10 +14,12 @@ import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django_scopes import scope, scopes_disabled
|
||||
|
||||
from pretix.base.metrics import (
|
||||
pretix_task_duration_seconds, pretix_task_runs_total,
|
||||
)
|
||||
from pretix.base.models import Event
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
@@ -61,6 +63,35 @@ class ProfiledTask(app.Task):
|
||||
return super().on_success(retval, task_id, args, kwargs)
|
||||
|
||||
|
||||
class EventTask(app.Task):
|
||||
def __call__(self, *args, **kwargs):
|
||||
if 'event_id' in kwargs:
|
||||
event_id = kwargs.get('event_id')
|
||||
with scopes_disabled():
|
||||
event = Event.objects.select_related('organizer').get(pk=event_id)
|
||||
del kwargs['event_id']
|
||||
kwargs['event'] = event
|
||||
elif 'event' in kwargs:
|
||||
event_id = kwargs.get('event')
|
||||
with scopes_disabled():
|
||||
event = Event.objects.select_related('organizer').get(pk=event_id)
|
||||
kwargs['event'] = event
|
||||
else:
|
||||
args = list(args)
|
||||
event_id = args[0]
|
||||
with scopes_disabled():
|
||||
event = Event.objects.select_related('organizer').get(pk=event_id)
|
||||
args[0] = event
|
||||
|
||||
with scope(organizer=event.organizer):
|
||||
ret = super().__call__(*args, **kwargs)
|
||||
return ret
|
||||
|
||||
|
||||
class ProfiledEventTask(ProfiledTask, EventTask):
|
||||
pass
|
||||
|
||||
|
||||
class TransactionAwareTask(ProfiledTask):
|
||||
"""
|
||||
Task class which is aware of django db transactions and only executes tasks
|
||||
|
||||
@@ -4,13 +4,14 @@ import os
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
|
||||
OrderPosition,
|
||||
)
|
||||
from pretix.base.services.tasks import ProfiledTask
|
||||
from pretix.base.services.tasks import EventTask, ProfiledTask
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import allow_ticket_download, register_ticket_outputs
|
||||
from pretix.celery_app import app
|
||||
@@ -57,10 +58,11 @@ def generate_order(order: int, provider: str):
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def generate(model: str, pk: int, provider: str):
|
||||
if model == 'order':
|
||||
return generate_order(pk, provider)
|
||||
elif model == 'orderposition':
|
||||
return generate_orderposition(pk, provider)
|
||||
with scopes_disabled():
|
||||
if model == 'order':
|
||||
return generate_order(pk, provider)
|
||||
elif model == 'orderposition':
|
||||
return generate_orderposition(pk, provider)
|
||||
|
||||
|
||||
class DummyRollbackException(Exception):
|
||||
@@ -96,7 +98,7 @@ def preview(event: int, provider: str):
|
||||
return prov.generate(p)
|
||||
|
||||
|
||||
def get_tickets_for_order(order):
|
||||
def get_tickets_for_order(order, base_position=None):
|
||||
can_download = all([r for rr, r in allow_ticket_download.send(order.event, order=order)])
|
||||
if not can_download:
|
||||
return []
|
||||
@@ -111,13 +113,20 @@ def get_tickets_for_order(order):
|
||||
|
||||
tickets = []
|
||||
|
||||
positions = list(order.positions_with_tickets)
|
||||
if base_position:
|
||||
# Only the given position and its children
|
||||
positions = [
|
||||
p for p in positions if p.pk == base_position.pk or p.addon_to_id == base_position.pk
|
||||
]
|
||||
|
||||
for p in providers:
|
||||
if not p.is_enabled:
|
||||
continue
|
||||
|
||||
if p.multi_download_enabled:
|
||||
if p.multi_download_enabled and not base_position:
|
||||
try:
|
||||
if len(list(order.positions_with_tickets)) == 0:
|
||||
if len(positions) == 0:
|
||||
continue
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=order, provider=p.identifier, file__isnull=False
|
||||
@@ -136,7 +145,7 @@ def get_tickets_for_order(order):
|
||||
except:
|
||||
logger.exception('Failed to generate ticket.')
|
||||
else:
|
||||
for pos in order.positions_with_tickets:
|
||||
for pos in positions:
|
||||
try:
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=pos, provider=p.identifier, file__isnull=False
|
||||
@@ -145,7 +154,7 @@ def get_tickets_for_order(order):
|
||||
retval = generate_orderposition(pos.pk, p.identifier)
|
||||
if not retval:
|
||||
continue
|
||||
ct = CachedCombinedTicket.objects.get(pk=retval)
|
||||
ct = CachedTicket.objects.get(pk=retval)
|
||||
tickets.append((
|
||||
"{}-{}-{}-{}{}".format(
|
||||
order.event.slug.upper(), order.code, pos.positionid, ct.provider, ct.extension,
|
||||
@@ -158,9 +167,8 @@ def get_tickets_for_order(order):
|
||||
return tickets
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def invalidate_cache(event: int, item: int=None, provider: str=None, order: int=None, **kwargs):
|
||||
event = Event.objects.get(id=event)
|
||||
@app.task(base=EventTask)
|
||||
def invalidate_cache(event: Event, item: int=None, provider: str=None, order: int=None, **kwargs):
|
||||
qs = CachedTicket.objects.filter(order_position__order__event=event)
|
||||
qsc = CachedCombinedTicket.objects.filter(order__event=event)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import requests
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _, ugettext_noop
|
||||
from django_scopes import scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix import __version__
|
||||
@@ -29,6 +30,7 @@ def run_update_check(sender, **kwargs):
|
||||
|
||||
|
||||
@app.task
|
||||
@scopes_disabled()
|
||||
def update_check():
|
||||
gs = GlobalSettingsObject()
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import sys
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.models import Event, User, WaitingListEntry
|
||||
from pretix.base.models.waitinglist import WaitingListException
|
||||
from pretix.base.services.tasks import ProfiledTask
|
||||
from pretix.base.services.tasks import EventTask
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None):
|
||||
event = Event.objects.get(id=event_id)
|
||||
@app.task(base=EventTask)
|
||||
def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None):
|
||||
if user_id:
|
||||
user = User.objects.get(id=user_id)
|
||||
else:
|
||||
@@ -69,6 +69,7 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def process_waitinglist(sender, **kwargs):
|
||||
qs = Event.objects.filter(
|
||||
live=True
|
||||
|
||||
@@ -145,6 +145,10 @@ DEFAULTS = {
|
||||
'default': 'False',
|
||||
'type': str
|
||||
},
|
||||
'invoice_generate_sales_channels': {
|
||||
'default': json.dumps(['web']),
|
||||
'type': list
|
||||
},
|
||||
'invoice_address_from': {
|
||||
'default': '',
|
||||
'type': str
|
||||
@@ -297,6 +301,10 @@ DEFAULTS = {
|
||||
'default': settings.MAIL_FROM,
|
||||
'type': str
|
||||
},
|
||||
'mail_from_name': {
|
||||
'default': None,
|
||||
'type': str
|
||||
},
|
||||
'mail_text_signature': {
|
||||
'type': LazyI18nString,
|
||||
'default': ""
|
||||
@@ -323,6 +331,18 @@ The list is as follows:
|
||||
|
||||
{orders}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_text_order_free_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello {attendee_name},
|
||||
|
||||
you have been registered for {event} successfully.
|
||||
|
||||
You can view the details and status of your ticket here:
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
@@ -339,6 +359,10 @@ You can change your order details and view the status of your order at
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_send_order_free_attendee': {
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
},
|
||||
'mail_text_order_placed_require_approval': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||
@@ -365,6 +389,22 @@ of {total_with_currency}. Please complete your payment before {date}.
|
||||
You can change your order details and view the status of your order at
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_send_order_placed_attendee': {
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
},
|
||||
'mail_text_order_placed_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello {attendee_name},
|
||||
|
||||
a ticket for {event} has been ordered for you.
|
||||
|
||||
You can view the details and status of your ticket here:
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
@@ -391,6 +431,22 @@ we successfully received your payment for {event}. Thank you!
|
||||
You can change your order details and view the status of your order at
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_send_order_paid_attendee': {
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
},
|
||||
'mail_text_order_paid_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello {attendee_name},
|
||||
|
||||
a ticket for {event} that has been ordered for you is now paid.
|
||||
|
||||
You can view the details and status of your ticket here:
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
@@ -492,6 +548,22 @@ Your {event} team"""))
|
||||
'type': int,
|
||||
'default': None
|
||||
},
|
||||
'mail_send_download_reminder_attendee': {
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
},
|
||||
'mail_text_download_reminder_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello {attendee_name},
|
||||
|
||||
you are registered for {event}.
|
||||
|
||||
If you did not do so already, you can download your ticket here:
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_text_download_reminder': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||
|
||||
@@ -240,6 +240,19 @@ subclass of pretix.base.exporter.BaseExporter
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
validate_order = EventPluginSignal(
|
||||
providing_args=["payment_provider", "positions", "email", "locale", "invoice_address",
|
||||
"meta_info"]
|
||||
)
|
||||
"""
|
||||
This signal is sent out when the user tries to confirm the order, before we actually create
|
||||
the order. It allows you to inspect the cart positions. Your return value will be ignored,
|
||||
but you can raise an OrderError with an appropriate exception message if you like to block
|
||||
the order. We strongly discourage making changes to the order here.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
validate_cart = EventPluginSignal(
|
||||
providing_args=["positions"]
|
||||
)
|
||||
@@ -502,3 +515,29 @@ dictionaries as values that contain keys like in the following example::
|
||||
The evaluate member will be called with the order position, order and event as arguments. The event might
|
||||
also be a subevent, if applicable.
|
||||
"""
|
||||
|
||||
|
||||
timeline_events = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to collect events for the time line shown on event dashboards. You are passed
|
||||
a ``subevent`` argument which might be none and you are expected to return a list of instances of
|
||||
``pretix.base.timeline.TimelineEvent``, which is a ``namedtuple`` with the fields ``event``, ``subevent``,
|
||||
``datetime``, ``description`` and ``edit_url``.
|
||||
"""
|
||||
|
||||
|
||||
quota_availability = EventPluginSignal(
|
||||
providing_args=['quota', 'result', 'count_waitinglist']
|
||||
)
|
||||
"""
|
||||
This signal allows you to modify the availability of a quota. You are passed the ``quota`` and an
|
||||
``availability`` result calculated by pretix code or other plugins. ``availability`` is a tuple
|
||||
with the first entry being one of the ``Quota.AVAILABILITY_*`` constants and the second entry being
|
||||
the number of available tickets (or ``None`` for unlimited). You are expected to return a value
|
||||
of the same time. The parameter ``count_waitinglists`` specifies whether waiting lists should be taken
|
||||
into account.
|
||||
|
||||
**Warning: Use this signal with great caution, it allows you to screw up the performance of the
|
||||
system really bad.** Also, keep in mind that your response is subject to caching and out-of-date
|
||||
quotas might be used for display (not for actual order processing).
|
||||
"""
|
||||
|
||||
@@ -23,13 +23,23 @@
|
||||
<table cellpadding="20"><tr><td>
|
||||
<![endif]-->
|
||||
<div class="content">
|
||||
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
|
||||
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
|
||||
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
|
||||
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
|
||||
<a href="{% abseventurl event "presale:event.order" order=order.code secret=order.secret %}">
|
||||
{% trans "View order details" %}
|
||||
</a>
|
||||
{% if position %}
|
||||
{% trans "You are receiving this email because someone signed you up for the following event:" %}<br>
|
||||
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
|
||||
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
|
||||
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
|
||||
<a href="{% abseventurl event "presale:event.order.position" order=order.code secret=position.web_secret position=position.positionid %}">
|
||||
{% trans "View registration details" %}
|
||||
</a>
|
||||
{% else %}
|
||||
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
|
||||
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
|
||||
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
|
||||
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
|
||||
<a href="{% abseventurl event "presale:event.order.open" hash=order.email_confirm_hash order=order.code secret=order.secret %}">
|
||||
{% trans "View order details" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!--[if gte mso 9]>
|
||||
</td></tr></table>
|
||||
|
||||
@@ -47,7 +47,7 @@ ALLOWED_TAGS = [
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
'a': ['href', 'title'],
|
||||
'a': ['href', 'title', 'class'],
|
||||
'abbr': ['title'],
|
||||
'acronym': ['title'],
|
||||
'table': ['width'],
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
from collections import namedtuple
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import make_aware
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.signals import timeline_events
|
||||
|
||||
TimelineEvent = namedtuple('TimelineEvent', ('event', 'subevent', 'datetime', 'description', 'edit_url'))
|
||||
|
||||
|
||||
def timeline_for_event(event, subevent=None):
|
||||
tl = []
|
||||
ev = subevent or event
|
||||
if subevent:
|
||||
ev_edit_url = reverse(
|
||||
'control:event.subevent', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'subevent': subevent.pk
|
||||
}
|
||||
)
|
||||
else:
|
||||
ev_edit_url = reverse(
|
||||
'control:event.settings', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug
|
||||
}
|
||||
)
|
||||
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=ev.date_from,
|
||||
description=pgettext_lazy('timeline', 'Your event starts'),
|
||||
edit_url=ev_edit_url
|
||||
))
|
||||
|
||||
if ev.date_to:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=ev.date_to,
|
||||
description=pgettext_lazy('timeline', 'Your event ends'),
|
||||
edit_url=ev_edit_url
|
||||
))
|
||||
|
||||
if ev.date_admission:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=ev.date_admission,
|
||||
description=pgettext_lazy('timeline', 'Admissions for your event start'),
|
||||
edit_url=ev_edit_url
|
||||
))
|
||||
|
||||
if ev.presale_start:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=ev.presale_start,
|
||||
description=pgettext_lazy('timeline', 'Start of ticket sales'),
|
||||
edit_url=ev_edit_url
|
||||
))
|
||||
|
||||
if ev.presale_end:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=ev.presale_end,
|
||||
description=pgettext_lazy('timeline', 'End of ticket sales'),
|
||||
edit_url=ev_edit_url
|
||||
))
|
||||
|
||||
rd = event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper)
|
||||
if rd:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=rd.datetime(ev),
|
||||
description=pgettext_lazy('timeline', 'Customers can no longer modify their orders'),
|
||||
edit_url=ev_edit_url
|
||||
))
|
||||
|
||||
rd = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||
if rd:
|
||||
d = make_aware(datetime.combine(
|
||||
rd.date(ev),
|
||||
time(hour=23, minute=59, second=59)
|
||||
), event.timezone)
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=d,
|
||||
description=pgettext_lazy('timeline', 'No more payments can be completed'),
|
||||
edit_url=reverse('control:event.settings.payment', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug
|
||||
})
|
||||
))
|
||||
|
||||
rd = event.settings.get('ticket_download_date', as_type=RelativeDateWrapper)
|
||||
if rd and event.settings.ticket_download:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=rd.datetime(ev),
|
||||
description=pgettext_lazy('timeline', 'Tickets can be downloaded'),
|
||||
edit_url=reverse('control:event.settings.tickets', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug
|
||||
})
|
||||
))
|
||||
|
||||
rd = event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper)
|
||||
if rd and event.settings.cancel_allow_user:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=rd.datetime(ev),
|
||||
description=pgettext_lazy('timeline', 'Customers can no longer cancel free or unpaid orders'),
|
||||
edit_url=reverse('control:event.settings.tickets', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug
|
||||
})
|
||||
))
|
||||
|
||||
rd = event.settings.get('cancel_allow_user_paid_until', as_type=RelativeDateWrapper)
|
||||
if rd and event.settings.cancel_allow_user_paid:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=rd.datetime(ev),
|
||||
description=pgettext_lazy('timeline', 'Customers can no longer cancel paid orders'),
|
||||
edit_url=reverse('control:event.settings.tickets', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug
|
||||
})
|
||||
))
|
||||
|
||||
if not event.has_subevents:
|
||||
days = event.settings.get('mail_days_download_reminder', as_type=int)
|
||||
if days is not None:
|
||||
reminder_date = (ev.date_from - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=reminder_date,
|
||||
description=pgettext_lazy('timeline', 'Download reminders are being sent out'),
|
||||
edit_url=reverse('control:event.settings.mail', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug
|
||||
})
|
||||
))
|
||||
|
||||
for p in event.items.filter(Q(available_from__isnull=False) | Q(available_until__isnull=False)):
|
||||
if p.available_from:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=p.available_from,
|
||||
description=pgettext_lazy('timeline', 'Product "{name}" becomes available').format(name=str(p)),
|
||||
edit_url=reverse('control:event.item', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'item': p.pk,
|
||||
})
|
||||
))
|
||||
if p.available_until:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=p.available_until,
|
||||
description=pgettext_lazy('timeline', 'Product "{name}" becomes unavailable').format(name=str(p)),
|
||||
edit_url=reverse('control:event.item', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'item': p.pk,
|
||||
})
|
||||
))
|
||||
|
||||
pprovs = event.get_payment_providers()
|
||||
# This is a special case, depending on payment providers not overriding BasePaymentProvider by too much, but it's
|
||||
# preferrable to having all plugins implement this spearately.
|
||||
for pprov in pprovs.values():
|
||||
if not pprov.settings.get('_enabled', as_type=bool):
|
||||
continue
|
||||
availability_date = pprov.settings.get('_availability_date', as_type=RelativeDateWrapper)
|
||||
if availability_date:
|
||||
d = make_aware(datetime.combine(
|
||||
availability_date.date(ev),
|
||||
time(hour=23, minute=59, second=59)
|
||||
), event.timezone)
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=d,
|
||||
description=pgettext_lazy('timeline', 'Payment provider "{name}" can no longer be selected').format(
|
||||
name=str(pprov.verbose_name)
|
||||
),
|
||||
edit_url=reverse('control:event.settings.payment.provider', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'provider': pprov.identifier,
|
||||
})
|
||||
))
|
||||
|
||||
for recv, resp in timeline_events.send(sender=event, subevent=subevent):
|
||||
tl += resp
|
||||
|
||||
return sorted(tl, key=lambda e: e.datetime)
|
||||
@@ -3,6 +3,7 @@ import hmac
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from .. import metrics
|
||||
|
||||
@@ -15,6 +16,7 @@ def unauthed_response():
|
||||
return response
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
def serve_metrics(request):
|
||||
if not settings.METRICS_ENABLED:
|
||||
return unauthed_response()
|
||||
|
||||
@@ -4,7 +4,7 @@ from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import Prefetch, QuerySet
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from pretix.base.forms.questions import (
|
||||
@@ -89,19 +89,20 @@ class BaseQuestionsViewMixin:
|
||||
elif k == 'attendee_email':
|
||||
form.pos.attendee_email = v if v != '' else None
|
||||
form.pos.save()
|
||||
elif k.startswith('question_') and v is not None:
|
||||
elif k.startswith('question_'):
|
||||
field = form.fields[k]
|
||||
if hasattr(field, 'answer'):
|
||||
# We already have a cached answer object, so we don't
|
||||
# have to create a new one
|
||||
if v == '' or v is None or (isinstance(field, forms.FileField) and v is False):
|
||||
if v == '' or v is None or (isinstance(field, forms.FileField) and v is False) \
|
||||
or (isinstance(v, QuerySet) and not v.exists()):
|
||||
if field.answer.file:
|
||||
field.answer.file.delete()
|
||||
field.answer.delete()
|
||||
else:
|
||||
self._save_to_answer(field, field.answer, v)
|
||||
field.answer.save()
|
||||
elif v != '':
|
||||
elif v != '' and v is not None:
|
||||
answer = QuestionAnswer(
|
||||
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
|
||||
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.urls import Resolver404, get_script_prefix, resolve
|
||||
from django.utils.translation import get_language
|
||||
from django_scopes import scope
|
||||
|
||||
from pretix.base.models.auth import StaffSession
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
@@ -53,10 +54,11 @@ def contextprocessor(request):
|
||||
ctx['has_domain'] = request.event.organizer.domains.exists()
|
||||
|
||||
if not request.event.testmode:
|
||||
complain_testmode_orders = request.event.cache.get('complain_testmode_orders')
|
||||
if complain_testmode_orders is None:
|
||||
complain_testmode_orders = request.event.orders.filter(testmode=True).exists()
|
||||
request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30)
|
||||
with scope(organizer=request.organizer):
|
||||
complain_testmode_orders = request.event.cache.get('complain_testmode_orders')
|
||||
if complain_testmode_orders is None:
|
||||
complain_testmode_orders = request.event.orders.filter(testmode=True).exists()
|
||||
request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30)
|
||||
ctx['complain_testmode_orders'] = complain_testmode_orders
|
||||
else:
|
||||
ctx['complain_testmode_orders'] = False
|
||||
|
||||
@@ -200,3 +200,7 @@ class SplitDateTimeField(forms.SplitDateTimeField):
|
||||
result = datetime.datetime.combine(*data_list)
|
||||
return from_current_timezone(result)
|
||||
return None
|
||||
|
||||
|
||||
class FontSelect(forms.RadioSelect):
|
||||
option_template_name = 'pretixcontrol/font_option.html'
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from django import forms
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import pgettext_lazy
|
||||
from django_scopes.forms import (
|
||||
SafeModelChoiceField, SafeModelMultipleChoiceField,
|
||||
)
|
||||
|
||||
from pretix.base.models.checkin import CheckinList
|
||||
from pretix.control.forms.widgets import Select2
|
||||
@@ -44,3 +47,7 @@ class CheckinListForm(forms.ModelForm):
|
||||
'data-inverse-dependency': '<[name$=all_products]'
|
||||
}),
|
||||
}
|
||||
field_classes = {
|
||||
'limit_products': SafeModelMultipleChoiceField,
|
||||
'subevent': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import RegexValidator, validate_email
|
||||
from django.db.models import Q
|
||||
from django.forms import formset_factory
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import get_current_timezone_name
|
||||
@@ -18,15 +19,17 @@ from i18nfield.forms import (
|
||||
)
|
||||
from pytz import common_timezones, timezone
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
|
||||
from pretix.base.models import Event, Organizer, TaxRule
|
||||
from pretix.base.models.event import EventMetaValue, SubEvent
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, MultipleLanguagesWidget, SingleLanguageWidget, SlugWidget,
|
||||
SplitDateTimeField, SplitDateTimePickerWidget,
|
||||
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
|
||||
SlugWidget, SplitDateTimeField, SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.control.forms.widgets import Select2
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.plugins.banktransfer.payment import BankTransfer
|
||||
from pretix.presale.style import get_fonts
|
||||
@@ -51,16 +54,28 @@ class EventWizardFoundationForm(forms.Form):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user')
|
||||
self.session = kwargs.pop('session')
|
||||
super().__init__(*args, **kwargs)
|
||||
qs = Organizer.objects.all()
|
||||
if not self.user.has_active_staff_session(self.session.session_key):
|
||||
qs = qs.filter(
|
||||
id__in=self.user.teams.filter(can_create_events=True).values_list('organizer', flat=True)
|
||||
)
|
||||
self.fields['organizer'] = forms.ModelChoiceField(
|
||||
label=_("Organizer"),
|
||||
queryset=Organizer.objects.filter(
|
||||
id__in=self.user.teams.filter(can_create_events=True).values_list('organizer', flat=True)
|
||||
queryset=qs,
|
||||
widget=Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:organizers.select2') + '?can_create=1',
|
||||
'data-placeholder': _('Organizer')
|
||||
}
|
||||
),
|
||||
widget=forms.RadioSelect,
|
||||
empty_label=None,
|
||||
required=True
|
||||
)
|
||||
self.fields['organizer'].widget.choices = self.fields['organizer'].choices
|
||||
|
||||
if len(self.fields['organizer'].choices) == 1:
|
||||
self.fields['organizer'].initial = self.fields['organizer'].queryset.first()
|
||||
|
||||
@@ -116,6 +131,7 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
self.locales = kwargs.get('locales')
|
||||
self.has_subevents = kwargs.pop('has_subevents')
|
||||
kwargs.pop('user')
|
||||
kwargs.pop('session')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.initial['timezone'] = get_current_timezone_name()
|
||||
self.fields['locale'].choices = [(a, b) for a, b in settings.LANGUAGES if a in self.locales]
|
||||
@@ -173,7 +189,9 @@ class EventChoiceField(forms.ModelChoiceField):
|
||||
class EventWizardCopyForm(forms.Form):
|
||||
|
||||
@staticmethod
|
||||
def copy_from_queryset(user):
|
||||
def copy_from_queryset(user, session):
|
||||
if user.has_active_staff_session(session.session_key):
|
||||
return Event.objects.all()
|
||||
return Event.objects.filter(
|
||||
Q(organizer_id__in=user.teams.filter(
|
||||
all_events=True, can_change_event_settings=True, can_change_items=True
|
||||
@@ -185,16 +203,25 @@ class EventWizardCopyForm(forms.Form):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.pop('organizer')
|
||||
kwargs.pop('locales')
|
||||
self.session = kwargs.pop('session')
|
||||
kwargs.pop('has_subevents')
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['copy_from_event'] = EventChoiceField(
|
||||
label=_("Copy configuration from"),
|
||||
queryset=EventWizardCopyForm.copy_from_queryset(self.user),
|
||||
widget=forms.RadioSelect,
|
||||
queryset=EventWizardCopyForm.copy_from_queryset(self.user, self.session),
|
||||
widget=Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:events.typeahead') + '?can_copy=1',
|
||||
'data-placeholder': _('Do not copy')
|
||||
}
|
||||
),
|
||||
empty_label=_('Do not copy'),
|
||||
required=False
|
||||
)
|
||||
self.fields['copy_from_event'].widget.choices = self.fields['copy_from_event'].choices
|
||||
|
||||
|
||||
class EventMetaValueForm(forms.ModelForm):
|
||||
@@ -664,6 +691,13 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
),
|
||||
help_text=_("Invoices will never be automatically generated for free orders.")
|
||||
)
|
||||
invoice_generate_sales_channels = forms.MultipleChoiceField(
|
||||
label=_('Generate invoices for Sales channels'),
|
||||
choices=[],
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
help_text=_("If you have enabled invoice generation in the previous setting, you can limit it here to specific "
|
||||
"sales channels.")
|
||||
)
|
||||
invoice_attendee_name = forms.BooleanField(
|
||||
label=_("Show attendee names on invoices"),
|
||||
required=False
|
||||
@@ -779,6 +813,16 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
self.fields['invoice_numbers_prefix'].widget.attrs['placeholder'] = event.slug.upper() + '-'
|
||||
locale_names = dict(settings.LANGUAGES)
|
||||
self.fields['invoice_language'].choices = [('__user__', _('The user\'s language'))] + [(a, locale_names[a]) for a in event.settings.locales]
|
||||
self.fields['invoice_generate_sales_channels'].choices = (
|
||||
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
|
||||
)
|
||||
|
||||
|
||||
def multimail_validate(val):
|
||||
s = val.split(',')
|
||||
for part in s:
|
||||
validate_email(part.strip())
|
||||
return s
|
||||
|
||||
|
||||
class MailSettingsForm(SettingsForm):
|
||||
@@ -790,12 +834,20 @@ class MailSettingsForm(SettingsForm):
|
||||
)
|
||||
mail_from = forms.EmailField(
|
||||
label=_("Sender address"),
|
||||
help_text=_("Sender address for outgoing emails")
|
||||
help_text=_("Sender address for outgoing emails"),
|
||||
)
|
||||
mail_bcc = forms.EmailField(
|
||||
mail_from_name = forms.CharField(
|
||||
label=_("Sender name"),
|
||||
help_text=_("Sender name used in conjunction with the sender address for outgoing emails. "
|
||||
"Defaults to your event name."),
|
||||
required=False
|
||||
)
|
||||
mail_bcc = forms.CharField(
|
||||
label=_("Bcc address"),
|
||||
help_text=_("All emails will be sent to this address as a Bcc copy"),
|
||||
required=False
|
||||
validators=[multimail_validate],
|
||||
required=False,
|
||||
max_length=255
|
||||
)
|
||||
|
||||
mail_text_signature = I18nFormField(
|
||||
@@ -818,7 +870,7 @@ class MailSettingsForm(SettingsForm):
|
||||
)
|
||||
|
||||
mail_text_order_placed = I18nFormField(
|
||||
label=_("Text"),
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {total_with_currency}, {total}, {currency}, {date}, "
|
||||
@@ -826,20 +878,62 @@ class MailSettingsForm(SettingsForm):
|
||||
validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}',
|
||||
'{payment_info}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_send_order_placed_attendee = forms.BooleanField(
|
||||
label=_("Send an email to attendees"),
|
||||
help_text=_('If the order contains attendees with email addresses different from the person who orders the '
|
||||
'tickets, the following email will be sent out to the attendees.'),
|
||||
required=False,
|
||||
)
|
||||
mail_text_order_placed_attendee = I18nFormField(
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {attendee_name}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])],
|
||||
)
|
||||
|
||||
mail_text_order_paid = I18nFormField(
|
||||
label=_("Text"),
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}, {payment_info}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}', '{payment_info}'])]
|
||||
)
|
||||
mail_send_order_paid_attendee = forms.BooleanField(
|
||||
label=_("Send an email to attendees"),
|
||||
help_text=_('If the order contains attendees with email addresses different from the person who orders the '
|
||||
'tickets, the following email will be sent out to the attendees.'),
|
||||
required=False,
|
||||
)
|
||||
mail_text_order_paid_attendee = I18nFormField(
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {attendee_name}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])],
|
||||
)
|
||||
|
||||
mail_text_order_free = I18nFormField(
|
||||
label=_("Text"),
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_send_order_free_attendee = forms.BooleanField(
|
||||
label=_("Send an email to attendees"),
|
||||
help_text=_('If the order contains attendees with email addresses different from the person who orders the '
|
||||
'tickets, the following email will be sent out to the attendees.'),
|
||||
required=False,
|
||||
)
|
||||
mail_text_order_free_attendee = I18nFormField(
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {attendee_name}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])],
|
||||
)
|
||||
|
||||
mail_text_order_changed = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
@@ -899,12 +993,25 @@ class MailSettingsForm(SettingsForm):
|
||||
'{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_download_reminder = I18nFormField(
|
||||
label=_("Text"),
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}'])]
|
||||
)
|
||||
mail_send_download_reminder_attendee = forms.BooleanField(
|
||||
label=_("Send an email to attendees"),
|
||||
help_text=_('If the order contains attendees with email addresses different from the person who orders the '
|
||||
'tickets, the following email will be sent out to the attendees.'),
|
||||
required=False,
|
||||
)
|
||||
mail_text_download_reminder_attendee = I18nFormField(
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {attendee_name}, {event}, {url}"),
|
||||
validators=[PlaceholderValidator(['{attendee_name}', '{event}', '{url}'])]
|
||||
)
|
||||
mail_days_download_reminder = forms.IntegerField(
|
||||
label=_("Number of days"),
|
||||
required=False,
|
||||
@@ -985,13 +1092,26 @@ class MailSettingsForm(SettingsForm):
|
||||
(r.identifier, r.verbose_name) for r in event.get_html_mail_renderers().values()
|
||||
]
|
||||
keys = list(event.meta_data.keys())
|
||||
for k, v in self.fields.items():
|
||||
name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
for k, v in list(self.fields.items()):
|
||||
if k.startswith('mail_text_'):
|
||||
v.help_text = str(v.help_text) + ', ' + ', '.join({
|
||||
'{meta_' + p + '}' for p in keys
|
||||
})
|
||||
v.validators[0].limit_value += ['{meta_' + p + '}' for p in keys]
|
||||
|
||||
if '{attendee_name}' in v.validators[0].limit_value:
|
||||
for f, l, w in name_scheme['fields']:
|
||||
if f == 'full_name':
|
||||
continue
|
||||
v.help_text = str(v.help_text) + ', ' + '{attendee_name_%s}' % f
|
||||
v.validators[0].limit_value += ['{attendee_name_' + f + '}']
|
||||
|
||||
if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
|
||||
# If we don't ask for attendee emails, we can't send them anything and we don't need to clutter
|
||||
# the user interface with it
|
||||
del self.fields[k]
|
||||
|
||||
def clean(self):
|
||||
data = self.cleaned_data
|
||||
if not data.get('smtp_password') and data.get('smtp_username'):
|
||||
@@ -1046,6 +1166,7 @@ class DisplaySettingsForm(SettingsForm):
|
||||
choices=[
|
||||
('Open Sans', 'Open Sans')
|
||||
],
|
||||
widget=FontSelect,
|
||||
help_text=_('Only respected by modern browsers.')
|
||||
)
|
||||
frontpage_text = I18nFormField(
|
||||
|
||||
@@ -132,7 +132,7 @@ class OrderFilterForm(FilterForm):
|
||||
matching_positions = OrderPosition.objects.filter(
|
||||
Q(order=OuterRef('pk')) & Q(
|
||||
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
|
||||
| Q(secret__istartswith=u)
|
||||
| Q(secret__istartswith=u) | Q(voucher__code__icontains=u)
|
||||
)
|
||||
).values('id')
|
||||
|
||||
@@ -177,11 +177,9 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
orders = {'code': 'code', 'email': 'email', 'total': 'total',
|
||||
'datetime': 'datetime', 'status': 'status'}
|
||||
|
||||
item = forms.ModelChoiceField(
|
||||
item = forms.ChoiceField(
|
||||
label=_('Products'),
|
||||
queryset=Item.objects.none(),
|
||||
required=False,
|
||||
empty_label=_('All products')
|
||||
)
|
||||
subevent = forms.ModelChoiceField(
|
||||
label=pgettext_lazy('subevent', 'Date'),
|
||||
@@ -241,12 +239,28 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
elif 'subevent':
|
||||
del self.fields['subevent']
|
||||
|
||||
choices = [('', _('All products'))]
|
||||
for i in self.event.items.prefetch_related('variations').all():
|
||||
variations = list(i.variations.all())
|
||||
if variations:
|
||||
choices.append((str(i.pk), _('{product} – Any variation').format(product=i.name)))
|
||||
for v in variations:
|
||||
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (i.name, v.value)))
|
||||
else:
|
||||
choices.append((str(i.pk), i.name))
|
||||
self.fields['item'].choices = choices
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
qs = super().filter_qs(qs)
|
||||
|
||||
if fdata.get('item'):
|
||||
qs = qs.filter(all_positions__item=fdata.get('item'), all_positions__canceled=False).distinct()
|
||||
item = fdata.get('item')
|
||||
if item:
|
||||
if '-' in item:
|
||||
var = item.split('-')[1]
|
||||
qs = qs.filter(all_positions__variation_id=var, all_positions__canceled=False).distinct()
|
||||
else:
|
||||
qs = qs.filter(all_positions__item_id=fdata.get('item'), all_positions__canceled=False).distinct()
|
||||
|
||||
if fdata.get('subevent'):
|
||||
qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False).distinct()
|
||||
|
||||
@@ -6,6 +6,9 @@ from django.urls import reverse
|
||||
from django.utils.translation import (
|
||||
pgettext_lazy, ugettext as __, ugettext_lazy as _,
|
||||
)
|
||||
from django_scopes.forms import (
|
||||
SafeModelChoiceField, SafeModelMultipleChoiceField,
|
||||
)
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
@@ -94,6 +97,10 @@ class QuestionForm(I18nModelForm):
|
||||
),
|
||||
'dependency_value': forms.Select,
|
||||
}
|
||||
field_classes = {
|
||||
'items': SafeModelMultipleChoiceField,
|
||||
'dependency_question': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class QuestionOptionForm(I18nModelForm):
|
||||
@@ -159,6 +166,9 @@ class QuotaForm(I18nModelForm):
|
||||
'size',
|
||||
'subevent'
|
||||
]
|
||||
field_classes = {
|
||||
'subevent': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
creating = not self.instance.pk
|
||||
@@ -206,6 +216,8 @@ class ItemCreateForm(I18nModelForm):
|
||||
empty_label=_('Do not copy'),
|
||||
required=False
|
||||
)
|
||||
if self.event.tax_rules.exists():
|
||||
self.fields['tax_rule'].required = True
|
||||
|
||||
if not self.event.has_subevents:
|
||||
choices = [
|
||||
@@ -364,6 +376,8 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'over 65. This ticket includes access to all parts of the event, except the VIP '
|
||||
'area.'
|
||||
)
|
||||
if self.event.tax_rules.exists():
|
||||
self.fields['tax_rule'].required = True
|
||||
self.fields['description'].widget.attrs['rows'] = '4'
|
||||
self.fields['sales_channels'] = forms.MultipleChoiceField(
|
||||
label=_('Sales channels'),
|
||||
|
||||
@@ -9,7 +9,10 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator
|
||||
from pretix.base.models import InvoiceAddress, ItemAddOn, Order, OrderPosition
|
||||
from pretix.base.forms.widgets import DatePickerWidget
|
||||
from pretix.base.models import (
|
||||
InvoiceAddress, ItemAddOn, Order, OrderPosition, Seat,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.control.forms.widgets import Select2
|
||||
@@ -116,6 +119,12 @@ class MarkPaidForm(ConfirmPaymentForm):
|
||||
localize=True,
|
||||
label=_('Payment amount'),
|
||||
)
|
||||
payment_date = forms.DateField(
|
||||
required=True,
|
||||
label=_('Payment date'),
|
||||
widget=DatePickerWidget(),
|
||||
initial=now
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -185,10 +194,16 @@ class OrderPositionAddForm(forms.Form):
|
||||
label=_('Product')
|
||||
)
|
||||
addon_to = forms.ModelChoiceField(
|
||||
OrderPosition.objects.none(),
|
||||
OrderPosition.all.none(),
|
||||
required=False,
|
||||
label=_('Add-on to'),
|
||||
)
|
||||
seat = forms.ModelChoiceField(
|
||||
Seat.objects.none(),
|
||||
required=False,
|
||||
label=_('Seat'),
|
||||
empty_label=_('General admission')
|
||||
)
|
||||
price = forms.DecimalField(
|
||||
required=False,
|
||||
max_digits=10, decimal_places=2,
|
||||
@@ -234,6 +249,19 @@ class OrderPositionAddForm(forms.Form):
|
||||
else:
|
||||
del self.fields['addon_to']
|
||||
|
||||
self.fields['seat'].queryset = order.event.seats.all()
|
||||
self.fields['seat'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'seat',
|
||||
'data-select2-url': reverse('control:event.seats.select2', kwargs={
|
||||
'event': order.event.slug,
|
||||
'organizer': order.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('General admission')
|
||||
}
|
||||
)
|
||||
self.fields['seat'].widget.choices = self.fields['seat'].choices
|
||||
|
||||
if order.event.has_subevents:
|
||||
self.fields['subevent'].queryset = order.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
@@ -262,6 +290,11 @@ class OrderPositionChangeForm(forms.Form):
|
||||
required=False,
|
||||
empty_label=_('(Unchanged)')
|
||||
)
|
||||
seat = forms.ModelChoiceField(
|
||||
Seat.objects.none(),
|
||||
required=False,
|
||||
empty_label=_('(Unchanged)')
|
||||
)
|
||||
price = forms.DecimalField(
|
||||
required=False,
|
||||
max_digits=10, decimal_places=2,
|
||||
@@ -285,10 +318,7 @@ class OrderPositionChangeForm(forms.Form):
|
||||
instance = kwargs.pop('instance')
|
||||
initial = kwargs.get('initial', {})
|
||||
|
||||
if instance.item.tax_rule and not instance.item.tax_rule.price_includes_tax:
|
||||
initial['price'] = instance.price - instance.tax_value
|
||||
else:
|
||||
initial['price'] = instance.price
|
||||
initial['price'] = instance.price
|
||||
|
||||
kwargs['initial'] = initial
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -308,6 +338,22 @@ class OrderPositionChangeForm(forms.Form):
|
||||
else:
|
||||
del self.fields['subevent']
|
||||
|
||||
if instance.seat:
|
||||
self.fields['seat'].queryset = instance.order.event.seats.all()
|
||||
self.fields['seat'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'seat',
|
||||
'data-select2-url': reverse('control:event.seats.select2', kwargs={
|
||||
'event': instance.order.event.slug,
|
||||
'organizer': instance.order.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('(Unchanged)')
|
||||
}
|
||||
)
|
||||
self.fields['seat'].widget.choices = self.fields['seat'].choices
|
||||
else:
|
||||
del self.fields['seat']
|
||||
|
||||
choices = [
|
||||
('', _('(Unchanged)'))
|
||||
]
|
||||
@@ -340,7 +386,7 @@ class OrderContactForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['email']
|
||||
fields = ['email', 'email_known_to_work']
|
||||
|
||||
|
||||
class OrderLocaleForm(forms.ModelForm):
|
||||
|
||||
@@ -6,13 +6,16 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.api.webhooks import get_all_webhook_events
|
||||
from pretix.base.forms import I18nModelForm, SettingsForm
|
||||
from pretix.base.models import Device, Organizer, Team
|
||||
from pretix.control.forms import ExtFileField, MultipleLanguagesWidget
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, FontSelect, MultipleLanguagesWidget,
|
||||
)
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
@@ -147,6 +150,9 @@ class TeamForm(forms.ModelForm):
|
||||
'data-inverse-dependency': '#id_all_events'
|
||||
}),
|
||||
}
|
||||
field_classes = {
|
||||
'limit_events': SafeModelMultipleChoiceField
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
@@ -175,6 +181,9 @@ class DeviceForm(forms.ModelForm):
|
||||
'data-inverse-dependency': '#id_all_events'
|
||||
}),
|
||||
}
|
||||
field_classes = {
|
||||
'limit_events': SafeModelMultipleChoiceField
|
||||
}
|
||||
|
||||
|
||||
class OrganizerSettingsForm(SettingsForm):
|
||||
@@ -260,6 +269,7 @@ class OrganizerDisplaySettingsForm(SettingsForm):
|
||||
choices=[
|
||||
('Open Sans', 'Open Sans')
|
||||
],
|
||||
widget=FontSelect,
|
||||
help_text=_('Only respected by modern browsers.')
|
||||
)
|
||||
favicon = ExtFileField(
|
||||
@@ -304,3 +314,6 @@ class WebHookForm(forms.ModelForm):
|
||||
'data-inverse-dependency': '#id_all_events'
|
||||
}),
|
||||
}
|
||||
field_classes = {
|
||||
'limit_events': SafeModelMultipleChoiceField
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class SubEventForm(I18nModelForm):
|
||||
'presale_start',
|
||||
'presale_end',
|
||||
'location',
|
||||
'frontpage_text'
|
||||
'frontpage_text',
|
||||
]
|
||||
field_classes = {
|
||||
'date_from': SplitDateTimeField,
|
||||
|
||||
@@ -48,12 +48,14 @@ class UserEditForm(forms.ModelForm):
|
||||
'email',
|
||||
'require_2fa',
|
||||
'is_active',
|
||||
'is_staff'
|
||||
'is_staff',
|
||||
'last_login'
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['email'].required = True
|
||||
self.fields['last_login'].disabled = True
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db.models.functions import Lower
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_scopes.forms import SafeModelChoiceField
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
from pretix.base.models import Item, Voucher
|
||||
@@ -31,10 +32,11 @@ class VoucherForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
|
||||
'comment', 'max_usages', 'price_mode', 'subevent'
|
||||
'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items'
|
||||
]
|
||||
field_classes = {
|
||||
'valid_until': SplitDateTimeField,
|
||||
'subevent': SafeModelChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'valid_until': SplitDateTimePickerWidget(),
|
||||
@@ -146,6 +148,14 @@ class VoucherForm(I18nModelForm):
|
||||
data, self.instance.event,
|
||||
self.instance.quota, self.instance.item, self.instance.variation
|
||||
)
|
||||
if self.instance.quota:
|
||||
if all(i.hide_without_voucher for i in self.instance.quota.items.all()):
|
||||
raise ValidationError({
|
||||
'itemvar': [
|
||||
_('The quota you selected only contains hidden products. Hidden products can currently only be '
|
||||
'shown by using vouchers that directly apply to the product, not via a quota.')
|
||||
]
|
||||
})
|
||||
Voucher.clean_subevent(
|
||||
data, self.instance.event
|
||||
)
|
||||
@@ -187,10 +197,11 @@ class VoucherBulkForm(VoucherForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
|
||||
'max_usages', 'price_mode', 'subevent'
|
||||
'max_usages', 'price_mode', 'subevent', 'show_hidden_items'
|
||||
]
|
||||
field_classes = {
|
||||
'valid_until': SplitDateTimeField,
|
||||
'subevent': SafeModelChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'valid_until': SplitDateTimePickerWidget(),
|
||||
|
||||
@@ -42,6 +42,12 @@ def _display_order_changed(event: Event, logentry: LogEntry):
|
||||
old_price=money_filter(Decimal(data['old_price']), event.currency),
|
||||
new_price=money_filter(Decimal(data['new_price']), event.currency),
|
||||
)
|
||||
elif logentry.action_type == 'pretix.event.order.changed.seat':
|
||||
return text + ' ' + _('Position #{posid}: Seat "{old_seat}" changed '
|
||||
'to "{new_seat}".').format(
|
||||
posid=data.get('positionid', '?'),
|
||||
old_seat=data.get('old_seat'), new_seat=data.get('new_seat'),
|
||||
)
|
||||
elif logentry.action_type == 'pretix.event.order.changed.subevent':
|
||||
old_se = str(event.subevents.get(pk=data['old_subevent']))
|
||||
new_se = str(event.subevents.get(pk=data['new_subevent']))
|
||||
@@ -251,6 +257,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.item.addons.added': _('An add-on has been added to this product.'),
|
||||
'pretix.event.item.addons.removed': _('An add-on has been removed from this product.'),
|
||||
'pretix.event.item.addons.changed': _('An add-on has been changed on this product.'),
|
||||
'pretix.event.item.bundles.added': _('A bundled item has been added to this product.'),
|
||||
'pretix.event.item.bundles.removed': _('A bundled item has been removed from this product.'),
|
||||
'pretix.event.item.bundles.changed': _('A bundled item has been changed on this product.'),
|
||||
'pretix.event.quota.added': _('The quota has been added.'),
|
||||
'pretix.event.quota.deleted': _('The quota has been deleted.'),
|
||||
'pretix.event.quota.changed': _('The quota has been changed.'),
|
||||
|
||||
@@ -4,10 +4,11 @@ from django.conf import settings
|
||||
from django.contrib.auth import REDIRECT_FIELD_NAME, logout
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect, resolve_url
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import get_script_prefix, resolve, reverse
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.translation import ugettext as _
|
||||
from django_scopes import scope
|
||||
from hijack.templatetags.hijack_tags import is_hijacked
|
||||
|
||||
from pretix.base.models import Event, Organizer
|
||||
@@ -17,7 +18,7 @@ from pretix.helpers.security import (
|
||||
)
|
||||
|
||||
|
||||
class PermissionMiddleware(MiddlewareMixin):
|
||||
class PermissionMiddleware:
|
||||
"""
|
||||
This middleware enforces all requests to the control app to require login.
|
||||
Additionally, it enforces all requests to "control:event." URLs
|
||||
@@ -34,6 +35,23 @@ class PermissionMiddleware(MiddlewareMixin):
|
||||
"user.settings.notifications.off",
|
||||
)
|
||||
|
||||
EXCEPTIONS_2FA = (
|
||||
"user.settings.2fa",
|
||||
"user.settings.2fa.add",
|
||||
"user.settings.2fa.enable",
|
||||
"user.settings.2fa.disable",
|
||||
"user.settings.2fa.regenemergency",
|
||||
"user.settings.2fa.confirm.totp",
|
||||
"user.settings.2fa.confirm.u2f",
|
||||
"user.settings.2fa.delete",
|
||||
"auth.logout",
|
||||
"user.reauth"
|
||||
)
|
||||
|
||||
def __init__(self, get_response=None):
|
||||
self.get_response = get_response
|
||||
super().__init__()
|
||||
|
||||
def _login_redirect(self, request):
|
||||
# Taken from django/contrib/auth/decorators.py
|
||||
path = request.build_absolute_uri()
|
||||
@@ -52,19 +70,19 @@ class PermissionMiddleware(MiddlewareMixin):
|
||||
return redirect_to_login(
|
||||
path, resolved_login_url, REDIRECT_FIELD_NAME)
|
||||
|
||||
def process_request(self, request):
|
||||
def __call__(self, request):
|
||||
url = resolve(request.path_info)
|
||||
url_name = url.url_name
|
||||
|
||||
if not request.path.startswith(get_script_prefix() + 'control'):
|
||||
# This middleware should only touch the /control subpath
|
||||
return
|
||||
return self.get_response(request)
|
||||
|
||||
if hasattr(request, 'organizer'):
|
||||
# If the user is on a organizer's subdomain, he should be redirected to pretix
|
||||
return redirect(urljoin(settings.SITE_URL, request.get_full_path()))
|
||||
if url_name in self.EXCEPTIONS:
|
||||
return
|
||||
return self.get_response(request)
|
||||
if not request.user.is_authenticated:
|
||||
return self._login_redirect(request)
|
||||
|
||||
@@ -78,11 +96,16 @@ class PermissionMiddleware(MiddlewareMixin):
|
||||
if url_name not in ('user.reauth', 'auth.logout'):
|
||||
return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
|
||||
|
||||
if not request.user.require_2fa and settings.PRETIX_OBLIGATORY_2FA \
|
||||
and url_name not in self.EXCEPTIONS_2FA:
|
||||
return redirect(reverse('control:user.settings.2fa'))
|
||||
|
||||
if 'event' in url.kwargs and 'organizer' in url.kwargs:
|
||||
request.event = Event.objects.filter(
|
||||
slug=url.kwargs['event'],
|
||||
organizer__slug=url.kwargs['organizer'],
|
||||
).select_related('organizer').first()
|
||||
with scope(organizer=None):
|
||||
request.event = Event.objects.filter(
|
||||
slug=url.kwargs['event'],
|
||||
organizer__slug=url.kwargs['organizer'],
|
||||
).select_related('organizer').first()
|
||||
if not request.event or not request.user.has_event_permission(request.event.organizer, request.event,
|
||||
request=request):
|
||||
raise Http404(_("The selected event was not found or you "
|
||||
@@ -104,6 +127,12 @@ class PermissionMiddleware(MiddlewareMixin):
|
||||
else:
|
||||
request.orgapermset = request.user.get_organizer_permission_set(request.organizer)
|
||||
|
||||
with scope(organizer=getattr(request, 'organizer', None)):
|
||||
r = self.get_response(request)
|
||||
if isinstance(r, TemplateResponse):
|
||||
r = r.render()
|
||||
return r
|
||||
|
||||
|
||||
class AuditLogMiddleware:
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ def get_event_navigation(request: HttpRequest):
|
||||
|
||||
merge_in(nav, sorted(
|
||||
sum((list(a[1]) for a in nav_event.send(request.event, request=request)), []),
|
||||
key=lambda r: r['label']
|
||||
key=lambda r: (1 if r.get('parent') else 0, r['label'])
|
||||
))
|
||||
|
||||
return nav
|
||||
@@ -391,7 +391,7 @@ def get_global_navigation(request):
|
||||
|
||||
merge_in(nav, sorted(
|
||||
sum((list(a[1]) for a in nav_global.send(request, request=request)), []),
|
||||
key=lambda r: r['label']
|
||||
key=lambda r: (1 if r.get('parent') else 0, r['label'])
|
||||
))
|
||||
return nav
|
||||
|
||||
@@ -464,7 +464,7 @@ def get_organizer_navigation(request):
|
||||
merge_in(nav, sorted(
|
||||
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
|
||||
[]),
|
||||
key=lambda r: r['label']
|
||||
key=lambda r: (1 if r.get('parent') else 0, r['label'])
|
||||
))
|
||||
return nav
|
||||
|
||||
@@ -474,6 +474,8 @@ def merge_in(nav, newnav):
|
||||
if 'parent' in item:
|
||||
parents = [n for n in nav if n['url'] == item['parent']]
|
||||
if parents:
|
||||
if 'children' not in parents[0]:
|
||||
parents[0]['children'] = []
|
||||
parents[0]['children'].append(item)
|
||||
else:
|
||||
nav.append(item)
|
||||
|
||||
@@ -36,6 +36,12 @@ on the type of navigation. You should also return an ``active`` key with a boole
|
||||
set to ``True``, when this item should be marked as active. The ``request`` object
|
||||
will have an attribute ``event``.
|
||||
|
||||
You can optionally create sub-items to create hierarchical navigation. There are two
|
||||
ways to achieve this: Either you specify a key ``children`` on your top navigation item
|
||||
that contains a list of navigation items (as dictionaries), or you specify a ``parent``
|
||||
key with the ``url`` value of the designated parent item.
|
||||
The latter method also allows you to register navigation items as a sub-item of existing ones.
|
||||
|
||||
If you use this, you should read the documentation on :ref:`how to deal with URLs <urlconf>`
|
||||
in pretix.
|
||||
|
||||
@@ -73,6 +79,12 @@ a fontawesome icon name with the key ``icon``, it will be respected depending
|
||||
on the type of navigation. You should also return an ``active`` key with a boolean
|
||||
set to ``True``, when this item should be marked as active.
|
||||
|
||||
You can optionally create sub-items to create hierarchical navigation. There are two
|
||||
ways to achieve this: Either you specify a key ``children`` on your top navigation item
|
||||
that contains a list of navigation items (as dictionaries), or you specify a ``parent``
|
||||
key with the ``url`` value of the designated parent item.
|
||||
The latter method also allows you to register navigation items as a sub-item of existing ones.
|
||||
|
||||
If you use this, you should read the documentation on :ref:`how to deal with URLs <urlconf>`
|
||||
in pretix.
|
||||
|
||||
@@ -173,6 +185,12 @@ should contain at least the keys ``label`` and ``url``. You should also return
|
||||
an ``active`` key with a boolean set to ``True``, when this item should be marked
|
||||
as active.
|
||||
|
||||
You can optionally create sub-items to create hierarchical navigation. There are two
|
||||
ways to achieve this: Either you specify a key ``children`` on your top navigation item
|
||||
that contains a list of navigation items (as dictionaries), or you specify a ``parent``
|
||||
key with the ``url`` value of the designated parent item.
|
||||
The latter method also allows you to register navigation items as a sub-item of existing ones.
|
||||
|
||||
If your linked view should stay in the tab-like context of this page, we recommend
|
||||
that you use ``pretix.control.views.organizer.OrganizerDetailViewMixin`` for your view
|
||||
and your template inherits from ``pretixcontrol/organizers/base.html``.
|
||||
@@ -219,6 +237,24 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
|
||||
A second keyword argument ``request`` will contain the request object.
|
||||
"""
|
||||
|
||||
nav_item = EventPluginSignal(
|
||||
providing_args=['request', 'item']
|
||||
)
|
||||
"""
|
||||
This signal is sent out to include tab links on the settings page of an item.
|
||||
Receivers are expected to return a list of dictionaries. The dictionaries
|
||||
should contain at least the keys ``label`` and ``url``. You should also return
|
||||
an ``active`` key with a boolean set to ``True``, when this item should be marked
|
||||
as active.
|
||||
|
||||
If your linked view should stay in the tab-like context of this page, we recommend
|
||||
that you use ``pretix.control.views.item.ItemDetailMixin`` for your view
|
||||
and your template inherits from ``pretixcontrol/item/base.html``.
|
||||
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
A second keyword argument ``request`` will contain the request object.
|
||||
"""
|
||||
|
||||
event_settings_widget = EventPluginSignal(
|
||||
providing_args=['request']
|
||||
)
|
||||
@@ -243,6 +279,22 @@ styles. It is advisable to set a prefix for your form to avoid clashes with othe
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
subevent_forms = EventPluginSignal(
|
||||
providing_args=['request', 'subevent']
|
||||
)
|
||||
"""
|
||||
This signal allows you to return additional forms that should be rendered on the subevent creation
|
||||
or modification page. You are passed ``request`` and ``subevent`` arguments and are expected to return
|
||||
an instance of a form class that you bind yourself when appropriate. Your form will be executed
|
||||
as part of the standard validation and rendering cycle and rendered using default bootstrap
|
||||
styles. It is advisable to set a prefix for your form to avoid clashes with other plugins.
|
||||
|
||||
``subevent`` can be ``None`` during creation. Before ``save()`` is called, a ``subevent`` property of
|
||||
your form instance will automatically being set to the subevent that has just been created.
|
||||
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
oauth_application_registered = Signal(
|
||||
providing_args=["user", "application"]
|
||||
)
|
||||
|
||||
@@ -145,7 +145,11 @@
|
||||
<a href="{{ nav.url }}" title="{{ nav.title }}" {% if nav.active %}class="active"{% endif %}
|
||||
{% if nav.children %}class="dropdown-toggle" data-toggle="dropdown"{% endif %}>
|
||||
{% if nav.icon %}
|
||||
<span class="fa fa-{{ nav.icon }}"></span>
|
||||
{% if "<svg" in nav.icon %}
|
||||
{{ nav.icon|safe }}
|
||||
{% else %}
|
||||
<span class="fa fa-{{ nav.icon }}"></span>
|
||||
{% endif %}
|
||||
<span class="visible-xs-inline">{{ nav.label }}</span>
|
||||
{% else %}
|
||||
{{ nav.label }}
|
||||
@@ -242,7 +246,10 @@
|
||||
<span class="caret"></span></a>
|
||||
{% endif %}
|
||||
<ul class="dropdown-menu event-dropdown" role="menu" data-event-typeahead
|
||||
data-source="{% url "control:nav.typeahead" %}">
|
||||
data-source="{% url "control:nav.typeahead" %}"
|
||||
{% if request.event %}
|
||||
data-organizer="{{ request.organizer.id }}"
|
||||
{% endif %}>
|
||||
<li class="query-holder">
|
||||
<div class="form-box">
|
||||
<input type="text" class="form-control" id="event-dropdown-field"
|
||||
@@ -267,7 +274,11 @@
|
||||
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
|
||||
{% if nav.children %}class="has-children"{% endif %}>
|
||||
{% if nav.icon %}
|
||||
<i class="fa fa-{{ nav.icon }} fa-fw"></i>
|
||||
{% if "<svg" in nav.icon %}
|
||||
{{ nav.icon|safe }}
|
||||
{% else %}
|
||||
<span class="fa fa-fw fa-{{ nav.icon }}"></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{{ nav.label }}
|
||||
</a>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load hierarkey_form %}
|
||||
{% block custom_header %}
|
||||
{{ block.super }}
|
||||
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
|
||||
{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Display settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user