forked from CGM_Public/pretix_original
Compare commits
265 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e89aaf4059 | ||
|
|
db270b3bf2 | ||
|
|
d8b78c3a7a | ||
|
|
67c448a29e | ||
|
|
5b7906f2a1 | ||
|
|
0612d42607 | ||
|
|
83f866034a | ||
|
|
b1fa214869 | ||
|
|
aa53b5235a | ||
|
|
61a13256a0 | ||
|
|
64e2336014 | ||
|
|
3411abd1e6 | ||
|
|
2a34e54fae | ||
|
|
9863dc35d6 | ||
|
|
690883a198 | ||
|
|
d8ded08a46 | ||
|
|
4aab5daa57 | ||
|
|
e87628c902 | ||
|
|
3c7bf46268 | ||
|
|
a1dacb1897 | ||
|
|
08d5626704 | ||
|
|
c8a1481f93 | ||
|
|
e7c4121745 | ||
|
|
35ddd8dd28 | ||
|
|
e2ec6eb156 | ||
|
|
42edc4c3aa | ||
|
|
1cb2f99f3a | ||
|
|
d4146e08b1 | ||
|
|
79ae9b6501 | ||
|
|
c23f71a19c | ||
|
|
53053f19e4 | ||
|
|
a42b2d76f6 | ||
|
|
51392f73a8 | ||
|
|
465a5b01b9 | ||
|
|
74a6004613 | ||
|
|
f9fc33eba1 | ||
|
|
363dc74c31 | ||
|
|
efb598e93a | ||
|
|
bcfaf2801d | ||
|
|
98db417fe6 | ||
|
|
a03ffd949e | ||
|
|
88ef46dee9 |
@@ -273,6 +273,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
|
||||
-----------------
|
||||
|
||||
|
||||
@@ -192,7 +192,8 @@ that you have a deep understanding of the semantics of your replication mechanis
|
||||
|
||||
If you do have a replica, you *can* tell pretix about it :ref:`in your configuration <config-replica>`.
|
||||
This way, pretix can offload complex read-only queries to the replica when it is safe to do so.
|
||||
As of pretix 2.6, this is mainly used for search queries in the backend and therefore does not really accelerate sales throughput, but we plan on expanding this in the future.
|
||||
As of pretix 2.7, this is mainly used for search queries in the backend and for rendering the
|
||||
product list and event lists in the frontend, but we plan on expanding this in the future.
|
||||
|
||||
Therefore, for now our clear recommendation is: Try to scale your database vertically and put
|
||||
it on the most powerful machine you have available.
|
||||
@@ -207,30 +208,29 @@ Having some memory available is good in case of e.g. lots of tasks queuing up du
|
||||
|
||||
Feel free to set up a redis cluster for availability – but you won't need it for performance in a long time.
|
||||
|
||||
The locking issue
|
||||
-----------------
|
||||
The limitations
|
||||
---------------
|
||||
|
||||
Currently, there is one big issue with scaling pretix. We take the reliability and correctness
|
||||
of pretix' core features very seriously and one part of this is that we want to make absolutely
|
||||
sure that pretix never sells the wrong number of tickets or tickets it otherwise shouldn't sell.
|
||||
Up to a certain point, pretix scales really well. However, there are a few things that we consider
|
||||
even more important than scalability, and those are correctness and reliability. We want you to be
|
||||
able to trust that pretix will not sell more tickets than you intended or run into similar error
|
||||
cases.
|
||||
|
||||
However, due to pretix flexibility and complexity, ensuring this in a high-concurrency environment is really hard.
|
||||
We're not just counting down integers, since quota availability in pretix depends on a multitude of factors and requires a handful of complex database queries to calculate.
|
||||
We can't rely on database-level locking alone to get this right.
|
||||
Combined with pretix' flexibility and complexity, especially around vouchers and quotas, this creates
|
||||
some hard issues. In many cases, we need to fall back to event-global locking for some actions which
|
||||
are likely to run with high concurrency and cause harm.
|
||||
|
||||
Therefore, we currently use a very drastic option:
|
||||
During relevant actions that typically occur with high concurrency, such as creating carts and completing orders, we create a system-wide lock for the event and all other such
|
||||
actions need to wait. In essence, this means **for a given event we're only ever selling
|
||||
one ticket at a time**.
|
||||
For every event, only one of these locking actions can be run at the same time. Examples for this are
|
||||
adding products limited by a quota to a cart, adding items to a cart using a voucher or placing an order
|
||||
consisting of cart positions that don't have a valid reservation for much longer. In these cases, it is
|
||||
currently not realistically possible to exceed selling **approx. 500 orders per minute per event**, even
|
||||
if you add more hardware.
|
||||
If you have an unlimited number of tickets, we can apply fewer locking and we've reached **approx.
|
||||
1500 orders per minute per event** in benchmarks, although even more should be possible.
|
||||
|
||||
Therefore, it is currently very unlikely that you will be able to exceed **300-400 tickets per minute per event**.
|
||||
|
||||
For events up to a few thousand tickets, this isn't a problem and it's not even desirable to be able to sell much faster: If all tickets are reserved in the first minute, the shop shows a big "sold out" and everyone else goes away. Fifteen to thirty minutes later, depending on your settings, the first shopping carts will expire because people didn't actually go through with the purchase and tickets will become available again. This leads to a much more frustrating shopping experience for those trying to get a ticket than if tickets are sold at a slower pace and the first reservations expire before the last reservations are made. Not selling tickets through quickly (e.g. through a queue system) can do a lot to smooth out this process.
|
||||
|
||||
That said, we still want to fix this of course and make it possible to achieve much higher
|
||||
throughput rates. We have some plans on how to soften this limitation, but they require
|
||||
lots of time and effort to be realized. If you want to use pretix for an event with
|
||||
10,000+ tickets that will be sold out within minutes, get in touch, we will make it work ;)
|
||||
We're working to reduce the number of cases in which this is relevant and thereby improve the possible
|
||||
throughput. If you want to use pretix for an event with 10,000+ tickets that are likely to be sold out
|
||||
within minutes, please get in touch to discuss possible solutions. We'll work something out for you!
|
||||
|
||||
|
||||
.. _object storage cluster: https://behind.pretix.eu/2018/03/20/high-available-cdn/
|
||||
131
doc/api/resources/billing_invoices.rst
Normal file
131
doc/api/resources/billing_invoices.rst
Normal file
@@ -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.
|
||||
@@ -50,6 +50,10 @@ 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.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -112,6 +116,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
|
||||
@@ -246,7 +253,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.
|
||||
|
||||
@@ -23,3 +23,4 @@ Resources and endpoints
|
||||
waitinglist
|
||||
carts
|
||||
webhooks
|
||||
billing_invoices
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ type string The expected ty
|
||||
* ``D`` – date
|
||||
* ``H`` – time
|
||||
* ``W`` – date and time
|
||||
* ``CC`` – country code (ISO 3666-1 alpha-2)
|
||||
required boolean If ``true``, the question needs to be filled out.
|
||||
position integer An integer, used for sorting
|
||||
items list of integers List of item IDs this question is assigned to.
|
||||
@@ -38,6 +39,8 @@ identifier string An arbitrary st
|
||||
ask_during_checkin boolean If ``true``, this question will not be asked while
|
||||
buying the ticket, but will show up when redeeming
|
||||
the ticket instead.
|
||||
hidden boolean If ``true``, the question will only be shown in the
|
||||
backend.
|
||||
options list of objects In case of question type ``C`` or ``M``, this lists the
|
||||
available objects. Only writable during creation,
|
||||
use separate endpoint to modify this later.
|
||||
@@ -68,6 +71,10 @@ dependency_value string The value ``dep
|
||||
Write methods have been added. The attribute ``identifier`` has been added to both the resource itself and the
|
||||
options resource. The ``position`` attribute has been added to the options resource.
|
||||
|
||||
.. versionchanged:: 2.7
|
||||
|
||||
The attribute ``hidden`` and the question type ``CC`` have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -110,6 +117,7 @@ Endpoints
|
||||
"position": 1,
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"options": [
|
||||
@@ -177,6 +185,7 @@ Endpoints
|
||||
"position": 1,
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"options": [
|
||||
@@ -228,6 +237,7 @@ Endpoints
|
||||
"items": [1, 2],
|
||||
"position": 1,
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"options": [
|
||||
@@ -261,6 +271,7 @@ Endpoints
|
||||
"position": 1,
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"options": [
|
||||
@@ -332,6 +343,7 @@ Endpoints
|
||||
"position": 2,
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"options": [
|
||||
|
||||
@@ -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
|
||||
item_copy_data, register_sales_channels, register_global_settings
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
@@ -20,17 +20,13 @@ 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
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: 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, checkout_flow_steps, order_info, order_meta_from_request, position_info
|
||||
|
||||
Request flow
|
||||
""""""""""""
|
||||
@@ -53,7 +49,7 @@ Backend
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: logentry_display, logentry_object_link, requiredaction_display
|
||||
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events
|
||||
|
||||
Vouchers
|
||||
""""""""
|
||||
|
||||
@@ -49,15 +49,19 @@ description string A more verbose description of what your
|
||||
visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated.
|
||||
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
|
||||
for an event by system administrators / superusers.
|
||||
compatibility string Specifier for compatible pretix versions.
|
||||
================== ==================== ===========================================================
|
||||
|
||||
A working example would be::
|
||||
|
||||
from django.apps import AppConfig
|
||||
try:
|
||||
from pretix.base.plugins import PluginConfig
|
||||
except ImportError:
|
||||
raise RuntimeError("Please use pretix 2.7 or above to run this plugin!")
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class PaypalApp(AppConfig):
|
||||
class PaypalApp(PluginConfig):
|
||||
name = 'pretix_paypal'
|
||||
verbose_name = _("PayPal")
|
||||
|
||||
@@ -68,6 +72,7 @@ A working example would be::
|
||||
visible = True
|
||||
restricted = False
|
||||
description = _("This plugin allows you to receive payments via PayPal")
|
||||
compatibility = "pretix>=2.7.0"
|
||||
|
||||
|
||||
default_app_config = 'pretix_paypal.PaypalApp'
|
||||
|
||||
@@ -23,7 +23,7 @@ Organizers and events
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.Event
|
||||
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, active_subevents, invoice_renderer, settings
|
||||
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, invoice_renderer, settings
|
||||
|
||||
.. autoclass:: pretix.base.models.SubEvent
|
||||
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running
|
||||
|
||||
@@ -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``
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ renderer
|
||||
renderers
|
||||
reportlab
|
||||
SaaS
|
||||
scalability
|
||||
screenshot
|
||||
scss
|
||||
searchable
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.6.0"
|
||||
__version__ = "2.8.0"
|
||||
|
||||
@@ -164,6 +164,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 +174,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
|
||||
|
||||
@@ -207,7 +207,8 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_value')
|
||||
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_value',
|
||||
'hidden')
|
||||
|
||||
def validate_identifier(self, value):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
|
||||
@@ -72,6 +72,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):
|
||||
|
||||
@@ -482,6 +482,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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from rest_framework import viewsets
|
||||
from rest_framework import filters, viewsets
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.organizer import OrganizerSerializer
|
||||
@@ -10,6 +10,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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -30,13 +30,13 @@ class DekodiNREIExporter(BaseExporter):
|
||||
for l in invoice.lines.all():
|
||||
positions.append({
|
||||
'ADes': l.description.replace("<br />", "\n"),
|
||||
'ANetA': round(float(l.net_value), 2),
|
||||
'ANetA': round(float((-1 if invoice.is_cancellation else 1) * l.net_value), 2),
|
||||
'ANo': self.event.slug,
|
||||
'AQ': -1 if invoice.is_cancellation else 1,
|
||||
'AVatP': round(float(l.tax_rate), 2),
|
||||
'DIDt': (l.subevent or invoice.order.event).date_from.isoformat().replace('Z', '+00:00'),
|
||||
'PosGrossA': round(float((-1 if invoice.is_cancellation else 1) * l.gross_value), 2),
|
||||
'PosNetA': round(float((-1 if invoice.is_cancellation else 1) * l.net_value), 2),
|
||||
'PosGrossA': round(float(l.gross_value), 2),
|
||||
'PosNetA': round(float(l.net_value), 2),
|
||||
})
|
||||
gross_total += l.gross_value
|
||||
net_total += l.net_value
|
||||
@@ -50,7 +50,7 @@ class DekodiNREIExporter(BaseExporter):
|
||||
if p.provider == 'paypal':
|
||||
paypal_email = p.info_data.get('payer', {}).get('payer_info', {}).get('email')
|
||||
try:
|
||||
ppid = p.info_data['transactions'][0]['related_resources']['sale']['id']
|
||||
ppid = p.info_data['transactions'][0]['related_resources'][0]['sale']['id']
|
||||
except:
|
||||
ppid = p.info_data.get('id')
|
||||
payments.append({
|
||||
@@ -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,7 +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.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,
|
||||
@@ -213,6 +215,14 @@ class BaseQuestionsForm(forms.Form):
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField().formfield(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label='',
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
@@ -342,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']
|
||||
@@ -393,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'):
|
||||
|
||||
51
src/pretix/base/management/commands/makemigrations.py
Normal file
51
src/pretix/base/management/commands/makemigrations.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Django, for theoretically very valid reasons, creates migrations for *every single thing*
|
||||
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
|
||||
database backend unknown to us might actually use this information for its database schema.
|
||||
|
||||
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
|
||||
certain that some changes to models will never require a change to the database. In this case,
|
||||
not creating a migration for certain changes will save us some performance while applying them
|
||||
*and* allow for a cleaner git history. Win-win!
|
||||
|
||||
Only caveat is that we need to do some dirty monkeypatching to achieve it...
|
||||
"""
|
||||
from django.core.management.commands.makemigrations import Command as Parent
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import models as modelops
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
|
||||
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
|
||||
IGNORED_ATTRS = [
|
||||
# (field type, attribute name, blacklist of field sub-types)
|
||||
(models.Field, 'verbose_name', []),
|
||||
(models.Field, 'help_text', []),
|
||||
(models.Field, 'validators', []),
|
||||
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
|
||||
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
|
||||
models.TimeField]),
|
||||
(models.CharField, 'choices', [CountryField])
|
||||
]
|
||||
|
||||
original_deconstruct = models.Field.deconstruct
|
||||
|
||||
|
||||
def new_deconstruct(self):
|
||||
name, path, args, kwargs = original_deconstruct(self)
|
||||
for ftype, attr, blacklist in IGNORED_ATTRS:
|
||||
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in blacklist):
|
||||
kwargs.pop(attr, None)
|
||||
return name, path, args, kwargs
|
||||
|
||||
|
||||
models.Field.deconstruct = new_deconstruct
|
||||
|
||||
|
||||
class Command(Parent):
|
||||
pass
|
||||
28
src/pretix/base/management/commands/migrate.py
Normal file
28
src/pretix/base/management/commands/migrate.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
|
||||
run when there are things we have no migrations for. Usually, this is intended, and running
|
||||
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
|
||||
users from doing that by going really dirty and fitlering it from the output.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from django.core.management.base import OutputWrapper
|
||||
from django.core.management.commands.migrate import Command as Parent
|
||||
|
||||
|
||||
class OutputFilter(OutputWrapper):
|
||||
blacklist = (
|
||||
"Your models have changes that are not yet reflected",
|
||||
"Run 'manage.py makemigrations' to make new "
|
||||
)
|
||||
|
||||
def write(self, msg, style_func=None, ending=None):
|
||||
if any(b in msg for b in self.blacklist):
|
||||
return
|
||||
super().write(msg, style_func, ending)
|
||||
|
||||
|
||||
class Command(Parent):
|
||||
def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False):
|
||||
super().__init__(stdout, stderr, no_color, force_color)
|
||||
self.stdout = OutputFilter(stdout or sys.stdout)
|
||||
22
src/pretix/base/migrations/0119_auto_20190509_0654.py
Normal file
22
src/pretix/base/migrations/0119_auto_20190509_0654.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.2 on 2019-05-09 06:54
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0118_auto_20190423_0839'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='hidden',
|
||||
field=models.BooleanField(default=False, help_text='This question will only show up in the backend.', verbose_name='Hidden question'),
|
||||
),
|
||||
]
|
||||
77
src/pretix/base/migrations/0120_auto_20190509_0736.py
Normal file
77
src/pretix/base/migrations/0120_auto_20190509_0736.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# Generated by Django 2.2 on 2019-05-09 07:36
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0119_auto_20190509_0654'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='attendee_name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='voucher',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Voucher'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoiceaddress',
|
||||
name='name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='sales_channels',
|
||||
field=pretix.base.models.fields.MultiStringField(default=['web']),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='sales_channel',
|
||||
field=models.CharField(default='web', max_length=190),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='attendee_name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='voucher',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Voucher'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='staffsessionauditlog',
|
||||
name='method',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='email',
|
||||
field=models.EmailField(db_index=True, max_length=190, null=True, unique=True),
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0121_order_email_known_to_work.py
Normal file
18
src/pretix/base/migrations/0121_order_email_known_to_work.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
20
src/pretix/base/migrations/0122_orderposition_web_secret.py
Normal file
20
src/pretix/base/migrations/0122_orderposition_web_secret.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ class EventMixin:
|
||||
def annotated(cls, qs, channel='web'):
|
||||
from pretix.base.models import Item, ItemVariation, Quota
|
||||
|
||||
sq_active_item = Item.objects.filter_available(channel=channel).filter(
|
||||
sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel).filter(
|
||||
Q(variations__isnull=True)
|
||||
& Q(quotas__pk=OuterRef('pk'))
|
||||
).order_by().values_list('quotas__pk').annotate(
|
||||
@@ -186,7 +186,7 @@ class EventMixin:
|
||||
Prefetch(
|
||||
'quotas',
|
||||
to_attr='active_quotas',
|
||||
queryset=Quota.objects.annotate(
|
||||
queryset=Quota.objects.using(settings.DATABASE_REPLICA).annotate(
|
||||
active_items=Subquery(sq_active_item, output_field=models.TextField()),
|
||||
active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
|
||||
).exclude(
|
||||
@@ -445,6 +445,7 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
self.plugins = other.plugins
|
||||
self.is_public = other.is_public
|
||||
self.testmode = other.testmode
|
||||
self.save()
|
||||
|
||||
tax_map = {}
|
||||
|
||||
@@ -16,6 +16,7 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import is_naive, make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_countries.fields import Country
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.models import fields
|
||||
@@ -332,7 +333,9 @@ class Item(LoggedModel):
|
||||
require_bundling = models.BooleanField(
|
||||
verbose_name=_('Only sell this product as part of a bundle'),
|
||||
default=False,
|
||||
help_text=_('If this option is set, the product will only be sold as part of bundle products.')
|
||||
help_text=_('If this option is set, the product will only be sold as part of bundle products. Do '
|
||||
'<strong>not</strong> check this option if you want to use this product as an add-on product, '
|
||||
'but only for fixed bundles!')
|
||||
)
|
||||
allow_cancel = models.BooleanField(
|
||||
verbose_name=_('Allow product to be canceled'),
|
||||
@@ -893,6 +896,8 @@ class Question(LoggedModel):
|
||||
:param items: A set of ``Items`` objects that this question should be applied to
|
||||
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
|
||||
:type ask_during_checkin: bool
|
||||
:param hidden: Whether to only show the question in the backend
|
||||
:type hidden: bool
|
||||
:param identifier: An arbitrary, internal identifier
|
||||
:type identifier: str
|
||||
:param dependency_question: This question will only show up if the referenced question is set to `dependency_value`.
|
||||
@@ -910,6 +915,7 @@ class Question(LoggedModel):
|
||||
TYPE_DATE = "D"
|
||||
TYPE_TIME = "H"
|
||||
TYPE_DATETIME = "W"
|
||||
TYPE_COUNTRYCODE = "CC"
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_NUMBER, _("Number")),
|
||||
(TYPE_STRING, _("Text (one line)")),
|
||||
@@ -921,6 +927,7 @@ class Question(LoggedModel):
|
||||
(TYPE_DATE, _("Date")),
|
||||
(TYPE_TIME, _("Time")),
|
||||
(TYPE_DATETIME, _("Date and time")),
|
||||
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
|
||||
)
|
||||
|
||||
event = models.ForeignKey(
|
||||
@@ -968,6 +975,11 @@ class Question(LoggedModel):
|
||||
'pretixdesk 0.2 or newer.'),
|
||||
default=False
|
||||
)
|
||||
hidden = models.BooleanField(
|
||||
verbose_name=_('Hidden question'),
|
||||
help_text=_('This question will only show up in the backend.'),
|
||||
default=False
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
@@ -1071,6 +1083,12 @@ class Question(LoggedModel):
|
||||
return dt
|
||||
except:
|
||||
raise ValidationError(_('Invalid datetime input.'))
|
||||
elif self.type == Question.TYPE_COUNTRYCODE and answer:
|
||||
c = Country(answer.upper())
|
||||
if c.name:
|
||||
return answer
|
||||
else:
|
||||
raise ValidationError(_('Unknown country code.'))
|
||||
|
||||
return answer
|
||||
|
||||
@@ -1290,7 +1308,8 @@ class Quota(LoggedModel):
|
||||
'cached_availability_state', 'cached_availability_number', 'cached_availability_time',
|
||||
'cached_availability_paid_orders'
|
||||
],
|
||||
clear_cache=False
|
||||
clear_cache=False,
|
||||
using='default'
|
||||
)
|
||||
|
||||
if _cache is not None:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -24,7 +25,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 pgettext_lazy, ugettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from django_countries.fields import Country, CountryField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
@@ -180,6 +181,10 @@ 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')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Order")
|
||||
@@ -206,6 +211,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):
|
||||
"""
|
||||
@@ -665,7 +673,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 +690,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 +704,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 +725,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 +745,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,
|
||||
@@ -860,6 +877,8 @@ class QuestionAnswer(models.Model):
|
||||
return date_format(d, "TIME_FORMAT")
|
||||
except ValueError:
|
||||
return self.answer
|
||||
elif self.question.type == Question.TYPE_COUNTRYCODE and self.answer:
|
||||
return Country(self.answer).name or self.answer
|
||||
else:
|
||||
return self.answer
|
||||
|
||||
@@ -959,6 +978,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.
|
||||
@@ -976,7 +999,8 @@ class AbstractPosition(models.Model):
|
||||
if hasattr(self.item, 'questions_to_ask'):
|
||||
questions = list(copy.copy(q) for q in self.item.questions_to_ask)
|
||||
else:
|
||||
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False))
|
||||
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
|
||||
hidden=False))
|
||||
else:
|
||||
questions = list(copy.copy(q) for q in self.item.questions.all())
|
||||
|
||||
@@ -1160,7 +1184,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
|
||||
@@ -1181,8 +1206,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)
|
||||
@@ -1191,7 +1214,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'])
|
||||
|
||||
@@ -1246,35 +1269,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):
|
||||
@@ -1663,6 +1728,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,
|
||||
@@ -1786,6 +1852,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):
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import sys
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
|
||||
from django.apps import apps
|
||||
from django.apps import AppConfig, apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
|
||||
class PluginType(Enum):
|
||||
@@ -39,3 +41,22 @@ def get_all_plugins(event=None) -> List[type]:
|
||||
plugins,
|
||||
key=lambda m: (0 if m.module.startswith('pretix.') else 1, str(m.name).lower().replace('pretix ', ''))
|
||||
)
|
||||
|
||||
|
||||
class PluginConfig(AppConfig):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not hasattr(self, 'PretixPluginMeta'):
|
||||
raise ImproperlyConfigured("A pretix plugin config should have a PretixPluginMeta inner class.")
|
||||
|
||||
if hasattr(self.PretixPluginMeta, 'compatibility'):
|
||||
import pkg_resources
|
||||
try:
|
||||
pkg_resources.require(self.PretixPluginMeta.compatibility)
|
||||
except pkg_resources.VersionConflict as e:
|
||||
print("Incompatible plugins found!")
|
||||
print("Plugin {} requires you to have {}, but you installed {}.".format(
|
||||
self.name, e.req, e.dist
|
||||
))
|
||||
sys.exit(1)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -634,7 +634,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
|
||||
|
||||
@@ -2,15 +2,20 @@ 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.signals import register_data_exporters
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
class ExportError(LazyLocaleException):
|
||||
pass
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, throws=(ExportError,))
|
||||
def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
|
||||
event = Event.objects.get(id=event)
|
||||
file = CachedFile.objects.get(id=fileid)
|
||||
@@ -19,7 +24,12 @@ def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) ->
|
||||
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
|
||||
|
||||
@@ -257,7 +257,8 @@ def invoice_pdf_task(invoice: int):
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -13,7 +14,9 @@ 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 +41,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 +63,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 +106,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 +118,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 +138,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 +166,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 +197,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,8 +214,8 @@ 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")
|
||||
@@ -212,10 +246,15 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -104,7 +104,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,
|
||||
|
||||
@@ -43,10 +43,11 @@ 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.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
|
||||
@@ -222,9 +223,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 +284,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 +378,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):
|
||||
@@ -531,7 +535,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 +558,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,6 +646,75 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
return order, p
|
||||
|
||||
|
||||
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 = ""
|
||||
|
||||
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: str, payment_provider: str, position_ids: List[str],
|
||||
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'):
|
||||
|
||||
@@ -667,6 +738,9 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
|
||||
positions = CartPosition.objects.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():
|
||||
@@ -705,49 +779,26 @@ 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
|
||||
|
||||
@@ -800,9 +851,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,
|
||||
@@ -851,9 +903,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 +919,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 = {
|
||||
@@ -1350,9 +1428,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,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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 pretix.base.models import LogEntry, Quota
|
||||
from pretix.base.models import Event, LogEntry
|
||||
from pretix.celery_app import app
|
||||
|
||||
from ..signals import periodic_task
|
||||
@@ -18,19 +18,25 @@ def build_all_quota_caches(sender, **kwargs):
|
||||
|
||||
@app.task
|
||||
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()
|
||||
|
||||
@@ -96,7 +96,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 +111,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 +143,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 +152,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,
|
||||
|
||||
@@ -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,12 @@ 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``.
|
||||
"""
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,7 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
|
||||
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
|
||||
if not arg:
|
||||
raise ValueError("No currency passed.")
|
||||
arg = arg.upper()
|
||||
|
||||
places = settings.CURRENCY_PLACES.get(arg, 2)
|
||||
rounded = value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
||||
|
||||
@@ -47,7 +47,7 @@ ALLOWED_TAGS = [
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
'a': ['href', 'title'],
|
||||
'a': ['href', 'title', 'class'],
|
||||
'abbr': ['title'],
|
||||
'acronym': ['title'],
|
||||
'table': ['width'],
|
||||
|
||||
200
src/pretix/base/timeline.py
Normal file
200
src/pretix/base/timeline.py
Normal file
@@ -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)
|
||||
@@ -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),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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):
|
||||
@@ -279,7 +306,7 @@ class EventSettingsForm(SettingsForm):
|
||||
)
|
||||
display_net_prices = forms.BooleanField(
|
||||
label=_("Show net prices instead of gross prices in the product list (not recommended!)"),
|
||||
help_text=_("Independent of your choice, the cart will show gross prices as this the price that needs to be "
|
||||
help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be "
|
||||
"paid"),
|
||||
required=False
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -82,6 +82,7 @@ class QuestionForm(I18nModelForm):
|
||||
'type',
|
||||
'required',
|
||||
'ask_during_checkin',
|
||||
'hidden',
|
||||
'identifier',
|
||||
'items',
|
||||
'dependency_question',
|
||||
@@ -205,6 +206,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 = [
|
||||
@@ -363,6 +366,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,6 +9,7 @@ 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.forms.widgets import DatePickerWidget
|
||||
from pretix.base.models import InvoiceAddress, ItemAddOn, Order, OrderPosition
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.services.pricing import get_price
|
||||
@@ -116,6 +117,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)
|
||||
@@ -285,10 +292,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)
|
||||
@@ -340,7 +344,7 @@ class OrderContactForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['email']
|
||||
fields = ['email', 'email_known_to_work']
|
||||
|
||||
|
||||
class OrderLocaleForm(forms.ModelForm):
|
||||
|
||||
@@ -12,7 +12,9 @@ from pretix.api.models import WebHook
|
||||
from pretix.api.webhooks import get_all_webhook_events
|
||||
from pretix.base.forms import I18nModelForm, SettingsForm
|
||||
from pretix.base.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
|
||||
|
||||
@@ -260,6 +262,7 @@ class OrganizerDisplaySettingsForm(SettingsForm):
|
||||
choices=[
|
||||
('Open Sans', 'Open Sans')
|
||||
],
|
||||
widget=FontSelect,
|
||||
help_text=_('Only respected by modern browsers.')
|
||||
)
|
||||
favicon = ExtFileField(
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -146,6 +146,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
|
||||
)
|
||||
|
||||
@@ -251,6 +251,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.'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,16 +2,6 @@ from django.dispatch import Signal
|
||||
|
||||
from pretix.base.signals import DeprecatedSignal, EventPluginSignal
|
||||
|
||||
restriction_formset = EventPluginSignal(
|
||||
providing_args=["item"]
|
||||
)
|
||||
"""
|
||||
This signal is sent out to build configuration forms for all restriction formsets
|
||||
(see plugin API documentation for details).
|
||||
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
html_page_start = Signal(
|
||||
providing_args=[]
|
||||
)
|
||||
@@ -46,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.
|
||||
|
||||
@@ -83,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.
|
||||
|
||||
@@ -183,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``.
|
||||
|
||||
@@ -242,7 +242,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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{% load i18n %}
|
||||
<div class="panel panel-default items">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Your timeline" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body timeline">
|
||||
{% regroup timeline by date as tl_list %}
|
||||
{% for day in tl_list %}
|
||||
<div class="row {% if day.grouper < today %}text-muted{% endif %}">
|
||||
<div class="col-date">
|
||||
<strong>{{ day.grouper|date:"SHORT_DATE_FORMAT" }}</strong>
|
||||
</div>
|
||||
<div class="col-event">
|
||||
{% for e in day.list %}
|
||||
<strong class="">{{ e.time|date:"TIME_FORMAT" }}</strong>
|
||||
|
||||
<span class="{% if e.time < nearly_now %}text-muted{% endif %}">
|
||||
{{ e.entry.description }}
|
||||
</span>
|
||||
{% if e.entry.edit_url %}
|
||||
|
||||
<a href="{{ e.entry.edit_url }}" class="text-muted">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if forloop.revcounter > 1 %}
|
||||
<br/>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,6 +90,9 @@
|
||||
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if not request.event.has_subevents or subevent %}
|
||||
{% include "pretixcontrol/event/fragment_timeline.html" %}
|
||||
{% endif %}
|
||||
<div class="dashboard">
|
||||
{% for w in widgets %}
|
||||
<div class="widget-container widget-{{ w.display_size|default:"small" }}">
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "General settings" %}</legend>
|
||||
{% bootstrap_field form.invoice_generate layout="control" %}
|
||||
{% bootstrap_field form.invoice_generate_sales_channels layout="control" %}
|
||||
{% bootstrap_field form.invoice_email_attachment layout="control" %}
|
||||
{% bootstrap_field form.invoice_numbers_prefix layout="control" %}
|
||||
{% bootstrap_field form.invoice_numbers_consecutive layout="control" %}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
{% block inside %}
|
||||
<h1>{% trans "Event logs" %}</h1>
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<input type="hidden" name="content_type" value="{{ request.GET.content_type }}">
|
||||
<input type="hidden" name="object" value="{{ request.GET.object }}">
|
||||
<p>
|
||||
<select name="user" class="form-control">
|
||||
<option value="">{% trans "All actions" %}</option>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<legend>{% trans "General settings" %}</legend>
|
||||
{% bootstrap_field form.mail_prefix layout="control" %}
|
||||
{% bootstrap_field form.mail_from layout="control" %}
|
||||
{% bootstrap_field form.mail_from_name layout="control" %}
|
||||
{% bootstrap_field form.mail_text_signature layout="control" %}
|
||||
{% bootstrap_field form.mail_bcc layout="control" %}
|
||||
</fieldset>
|
||||
@@ -40,13 +41,13 @@
|
||||
<legend>{% trans "E-mail content" %}</legend>
|
||||
<div class="panel-group" id="questions_group">
|
||||
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed,mail_send_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %}
|
||||
|
||||
{% blocktrans asvar title_paid_order %}Paid order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_paid" title=title_paid_order items="mail_text_order_paid" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_paid" title=title_paid_order items="mail_text_order_paid,mail_send_order_paid_attendee,mail_text_order_paid_attendee" exclude="mail_send_order_paid_attendee" %}
|
||||
|
||||
{% blocktrans asvar title_free_order %}Free order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_free" title=title_free_order items="mail_text_order_free" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_free" title=title_free_order items="mail_text_order_free,mail_send_order_free_attendee,mail_text_order_free_attendee" exclude="mail_send_order_free_attendee" %}
|
||||
|
||||
{% blocktrans asvar title_resend_link %}Resend link{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="resend_link" title=title_resend_link items="mail_text_resend_link,mail_text_resend_all_links" %}
|
||||
@@ -67,7 +68,7 @@
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="custom_mail" title=title_order_custom_mail items="mail_text_order_custom_mail" %}
|
||||
|
||||
{% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_text_download_reminder" exclude="mail_days_download_reminder" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_text_download_reminder_attendee" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee" %}
|
||||
|
||||
{% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_denied" %}
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
{% with exclude|split as exclusion %}
|
||||
{% with items|split as item_list %}
|
||||
{% for item in item_list %}
|
||||
{% if item in exclusion %}
|
||||
{% if item in exclusion and form|hasattr:item %}
|
||||
{% with form|getattr:item as field %}
|
||||
{% bootstrap_field field layout="horizontal" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{% elif form|hasattr:item %}
|
||||
<div id="{{ item }}_panel" class="preview-panel form-group" for="{{ item }}">
|
||||
{% with form|getattr:item as field %}
|
||||
<label class="col-md-3 control-label">{{ field.label }}</label>
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
{% trans "There are no payment providers available. Please go to the plugin settings and activate one or more payment plugins." %}
|
||||
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
|
||||
{% blocktrans trimmed with plugin_settings_href='href="'|add:plugin_settings_url|add:'"'|safe %}
|
||||
There are no payment providers available. Please go to the <a {{ plugin_settings_href }}>plugin settings</a> and activate one or more payment plugins.
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "There are no ticket outputs available. Please go to the plugin settings and activate one or more ticket output plugins." %}</em>
|
||||
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
|
||||
{% blocktrans trimmed with plugin_settings_href='href="'|add:plugin_settings_url|add:'"'|safe %}
|
||||
There are no ticket outputs available. Please go to the <a {{ plugin_settings_href }}>plugin settings</a> and activate one or more ticket output plugins.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{% load i18n %}{% if widget.wrap_label %}<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %} class="preload-font"
|
||||
data-family="{{ widget.label }}" data-style="regular">{% endif %}{% include "django/forms/widgets/input.html" %}{% if widget.wrap_label %} <strong>{{ widget.label }}</strong><br>{% trans "The quick brown fox jumps over the lazy dog." context "typography" %}</label>{% endif %}
|
||||
@@ -1,7 +1,7 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
<ul class="list-group">
|
||||
{% for log in obj.all_logentries %}
|
||||
{% for log in obj.top_logentries %}
|
||||
<li class="list-group-item logentry">
|
||||
<p class="meta">
|
||||
<span class="fa fa-clock-o"></span> {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
@@ -45,4 +45,11 @@
|
||||
</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if obj.all_logentries_link and obj.top_logentries_has_more %}
|
||||
<li class="list-group-item logentry">
|
||||
<a href="{{ obj.all_logentries_link }}">
|
||||
{% trans "View full log" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
{% bootstrap_field form.help_text layout="control" %}
|
||||
{% bootstrap_field form.identifier layout="control" %}
|
||||
{% bootstrap_field form.ask_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.hidden layout="control" %}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="id_dependency_question">
|
||||
|
||||
@@ -60,7 +60,13 @@
|
||||
<td>
|
||||
<ul>
|
||||
{% for item in q.items.all %}
|
||||
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a></li>
|
||||
{% if not item.has_variations %}
|
||||
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for v in q.variations.all %}
|
||||
<li><a href="{% url "control:event.item.variations" organizer=request.event.organizer.slug event=request.event.slug item=v.item.id %}">
|
||||
{{ v.item }} – {{ v }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
|
||||
@@ -121,11 +121,13 @@
|
||||
<strong>{% trans "Price" %}</strong>
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
{{ position.price|money:request.event.currency }}<br>
|
||||
{{ position.price|money:request.event.currency }}
|
||||
{% if position.tax_rate %}
|
||||
<small>{% blocktrans trimmed with rate=position.tax_rate name=position.tax_rule.name %}
|
||||
<strong>incl.</strong> {{ rate }}% {{ name }}
|
||||
{% endblocktrans %}</small>
|
||||
<br>
|
||||
<small>
|
||||
({{ position.net_price|money:request.event.currency }}
|
||||
+ {{ position.tax_rate }}%)
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-4 field-container">
|
||||
|
||||
@@ -136,7 +136,10 @@
|
||||
{% endif %}
|
||||
<dt>{% trans "User" %}</dt>
|
||||
<dd>
|
||||
{{ order.email|default_if_none:"" }}
|
||||
{{ order.email|default_if_none:"" }}
|
||||
{% if order.email and order.email_known_to_work %}
|
||||
<span class="fa fa-check-circle text-success" data-toggle="tooltip" title="{% trans "We know that this email address works because the user clicked a link we sent them." %}"></span>
|
||||
{% endif %}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<input type="hidden" name="status" value="p" />
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.amount layout='horizontal' %}
|
||||
{% bootstrap_field form.payment_date layout='horizontal' %}
|
||||
{% if form.force %}
|
||||
{% bootstrap_field form.force layout='horizontal' horizontal_label_class='sr-only' horizontal_field_class='col-md-12' %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block custom_header %}
|
||||
{{ block.super }}
|
||||
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
|
||||
{% endblock %}
|
||||
{% block inner %}
|
||||
<h1>{% trans "Display settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% compress css %}
|
||||
<link type="text/css" rel="stylesheet" href="{% static "pretixcontrol/scss/pdfeditor.css" %}">
|
||||
{% endcompress %}
|
||||
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" organizer=request.organizer.slug event=request.event.slug %}">
|
||||
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
{% bootstrap_field form.email layout='control' %}
|
||||
{% bootstrap_field form.new_pw layout='control' %}
|
||||
{% bootstrap_field form.new_pw_repeat layout='control' %}
|
||||
{% bootstrap_field form.last_login layout='control' %}
|
||||
{% bootstrap_field form.require_2fa layout='control' %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -11,3 +11,12 @@ def split(value, delimiter=","):
|
||||
@register.filter(name="getattr")
|
||||
def get_attribute(value, key):
|
||||
return value[key]
|
||||
|
||||
|
||||
@register.filter(name="hasattr")
|
||||
def has_attribute(value, key):
|
||||
try:
|
||||
value[key]
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
@@ -34,6 +34,7 @@ urlpatterns = [
|
||||
url(r'^users/(?P<id>\d+)/reset$', users.UserResetView.as_view(), name='users.reset'),
|
||||
url(r'^users/(?P<id>\d+)/impersonate', users.UserImpersonateView.as_view(), name='users.impersonate'),
|
||||
url(r'^users/(?P<id>\d+)/anonymize', users.UserAnonymizeView.as_view(), name='users.anonymize'),
|
||||
url(r'^pdf/editor/webfonts.css', pdf.FontsCSSView.as_view(), name='pdf.css'),
|
||||
url(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
|
||||
url(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
|
||||
url(r'^settings/notifications/$', user.UserNotificationsEditView.as_view(), name='user.settings.notifications'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytz
|
||||
@@ -22,6 +23,7 @@ from pretix.base.models import (
|
||||
WaitingListEntry,
|
||||
)
|
||||
from pretix.base.models.checkin import CheckinList
|
||||
from pretix.base.timeline import timeline_for_event
|
||||
from pretix.control.forms.event import CommentForm
|
||||
from pretix.control.signals import (
|
||||
event_dashboard_widgets, user_dashboard_widgets,
|
||||
@@ -279,6 +281,7 @@ def event_index(request, organizer, event):
|
||||
ctx = {
|
||||
'widgets': rearrange(widgets),
|
||||
'logs': qs[:5],
|
||||
'subevent': subevent,
|
||||
'actions': a_qs[:5] if can_change_orders else [],
|
||||
'comment_form': CommentForm(initial={'comment': request.event.comment})
|
||||
}
|
||||
@@ -302,7 +305,19 @@ def event_index(request, organizer, event):
|
||||
for a in ctx['actions']:
|
||||
a.display = a.display(request)
|
||||
|
||||
return render(request, 'pretixcontrol/event/index.html', ctx)
|
||||
ctx['timeline'] = [
|
||||
{
|
||||
'date': t.datetime.astimezone(request.event.timezone).date(),
|
||||
'entry': t,
|
||||
'time': t.datetime.astimezone(request.event.timezone)
|
||||
}
|
||||
for t in timeline_for_event(request.event, subevent)
|
||||
]
|
||||
ctx['today'] = now().astimezone(request.event.timezone).date()
|
||||
ctx['nearly_now'] = now().astimezone(request.event.timezone) - timedelta(seconds=20)
|
||||
resp = render(request, 'pretixcontrol/event/index.html', ctx)
|
||||
# resp['Content-Security-Policy'] = "style-src 'unsafe-inline'"
|
||||
return resp
|
||||
|
||||
|
||||
def annotated_event_query(request):
|
||||
|
||||
@@ -689,13 +689,14 @@ class MailSettingsRendererPreview(MailSettingsPreview):
|
||||
expires=now(), code="PREVIEW", total=119)
|
||||
item = request.event.items.create(name=ugettext("Sample product"), default_price=42.23,
|
||||
description=ugettext("Sample product description"))
|
||||
order.positions.create(item=item, attendee_name_parts={'_legacy': ugettext("John Doe")},
|
||||
price=item.default_price)
|
||||
p = order.positions.create(item=item, attendee_name_parts={'_legacy': ugettext("John Doe")},
|
||||
price=item.default_price)
|
||||
v = renderers[request.GET.get('renderer')].render(
|
||||
v,
|
||||
str(request.event.settings.mail_text_signature),
|
||||
ugettext('Your order: %(code)s') % {'code': order.code},
|
||||
order
|
||||
order,
|
||||
position=p
|
||||
)
|
||||
r = HttpResponse(v, content_type='text/html')
|
||||
r._csp_ignore = True
|
||||
@@ -974,6 +975,12 @@ class EventLog(EventPermissionRequiredMixin, ListView):
|
||||
elif self.request.GET.get('user'):
|
||||
qs = qs.filter(user_id=self.request.GET.get('user'))
|
||||
|
||||
if self.request.GET.get('content_type'):
|
||||
qs = qs.filter(content_type=get_object_or_404(ContentType, pk=self.request.GET.get('content_type')))
|
||||
|
||||
if self.request.GET.get('object'):
|
||||
qs = qs.filter(object_id=self.request.GET.get('object'))
|
||||
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
from django.contrib import messages
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, F, Q
|
||||
from django.db.models import Count, F, Prefetch, Q
|
||||
from django.forms.models import inlineformset_factory
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
@@ -15,6 +15,7 @@ from django.views.generic import ListView
|
||||
from django.views.generic.base import TemplateView
|
||||
from django.views.generic.detail import DetailView, SingleObjectMixin
|
||||
from django.views.generic.edit import DeleteView
|
||||
from django_countries.fields import Country
|
||||
|
||||
from pretix.base.forms import I18nFormSet
|
||||
from pretix.base.models import (
|
||||
@@ -444,6 +445,10 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
|
||||
a['alink'] = a['answer']
|
||||
a['answer'] = ugettext('Yes') if a['answer'] == 'True' else ugettext('No')
|
||||
a['answer_bool'] = a['answer'] == 'True'
|
||||
elif self.object.type == Question.TYPE_COUNTRYCODE:
|
||||
for a in qs:
|
||||
a['alink'] = a['answer']
|
||||
a['answer'] = Country(a['answer']).name or a['answer']
|
||||
|
||||
return list(qs)
|
||||
|
||||
@@ -560,7 +565,14 @@ class QuotaList(PaginationMixin, ListView):
|
||||
def get_queryset(self):
|
||||
qs = Quota.objects.filter(
|
||||
event=self.request.event
|
||||
).prefetch_related("items")
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
"items",
|
||||
queryset=Item.objects.annotate(has_variations=Count('variations'))
|
||||
),
|
||||
"variations",
|
||||
"variations__item"
|
||||
)
|
||||
if self.request.GET.get("subevent", "") != "":
|
||||
s = self.request.GET.get("subevent", "")
|
||||
qs = qs.filter(subevent_id=s)
|
||||
@@ -888,6 +900,12 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['plugin_forms'] = self.plugin_forms
|
||||
|
||||
if not ctx['item'].active and ctx['item'].bundled_with.count() > 0:
|
||||
messages.info(self.request, _("You disabled this item, but it is still part of a product bundle. "
|
||||
"Your participants won't be able to buy the bundle unless you remove this "
|
||||
"item from it."))
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ class EventList(PaginationMixin, ListView):
|
||||
def condition_copy(wizard):
|
||||
return (
|
||||
not wizard.clone_from and
|
||||
EventWizardCopyForm.copy_from_queryset(wizard.request.user).exists()
|
||||
EventWizardCopyForm.copy_from_queryset(wizard.request.user, wizard.request.session).exists()
|
||||
)
|
||||
|
||||
|
||||
@@ -176,7 +176,8 @@ class EventWizard(SafeSessionWizardView):
|
||||
|
||||
def get_form_kwargs(self, step=None):
|
||||
kwargs = {
|
||||
'user': self.request.user
|
||||
'user': self.request.user,
|
||||
'session': self.request.session,
|
||||
}
|
||||
if step != 'foundation':
|
||||
fdata = self.get_cleaned_data_for_step('foundation')
|
||||
@@ -203,6 +204,7 @@ class EventWizard(SafeSessionWizardView):
|
||||
event.organizer = foundation_data['organizer']
|
||||
event.plugins = settings.PRETIX_PLUGINS_DEFAULT
|
||||
event.has_subevents = foundation_data['has_subevents']
|
||||
event.testmode = True
|
||||
form_dict['basics'].save()
|
||||
|
||||
has_control_rights = self.request.user.teams.filter(
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal, DecimalException
|
||||
|
||||
import pytz
|
||||
@@ -24,7 +24,7 @@ from django.utils import formats
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.http import is_safe_url
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import (
|
||||
DetailView, FormView, ListView, TemplateView, View,
|
||||
@@ -862,8 +862,15 @@ class OrderTransition(OrderView):
|
||||
fee=None
|
||||
)
|
||||
|
||||
payment_date = None
|
||||
if self.mark_paid_form.cleaned_data['payment_date'] != now().date():
|
||||
payment_date = make_aware(datetime.combine(
|
||||
self.mark_paid_form.cleaned_data['payment_date'],
|
||||
time(hour=0, minute=0, second=0)
|
||||
), self.order.event.timezone)
|
||||
|
||||
try:
|
||||
p.confirm(user=self.request.user, count_waitinglist=False,
|
||||
p.confirm(user=self.request.user, count_waitinglist=False, payment_date=payment_date,
|
||||
force=self.mark_paid_form.cleaned_data.get('force', False))
|
||||
except Quota.QuotaExceededException as e:
|
||||
p.state = OrderPayment.PAYMENT_STATE_FAILED
|
||||
@@ -1365,6 +1372,7 @@ class OrderContactChange(OrderView):
|
||||
},
|
||||
user=self.request.user,
|
||||
)
|
||||
|
||||
if self.form.cleaned_data['regenerate_secrets']:
|
||||
changed = True
|
||||
self.order.secret = generate_secret()
|
||||
@@ -1474,9 +1482,10 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
|
||||
'code': order.code,
|
||||
'date': date_format(order.datetime.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
'expire_date': date_format(order.expires, 'SHORT_DATE_FORMAT'),
|
||||
'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,
|
||||
@@ -1646,6 +1655,7 @@ class ExportMixin:
|
||||
|
||||
class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, View):
|
||||
permission = 'can_view_orders'
|
||||
known_errortypes = ['ExportError']
|
||||
task = export
|
||||
|
||||
def get_success_message(self, value):
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.utils.timezone import make_aware
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
|
||||
from pretix.base.models import Organizer, User
|
||||
from pretix.control.forms.event import EventWizardCopyForm
|
||||
from pretix.control.permissions import event_permission_required
|
||||
from pretix.helpers.daterange import daterange
|
||||
from pretix.helpers.i18n import i18ncomp
|
||||
@@ -72,7 +73,13 @@ def event_list(request):
|
||||
page = int(request.GET.get('page', '1'))
|
||||
except ValueError:
|
||||
page = 1
|
||||
qs = request.user.get_events_with_any_permission(request).filter(
|
||||
|
||||
if 'can_copy' in request.GET:
|
||||
qs = EventWizardCopyForm.copy_from_queryset(request.user, request.session)
|
||||
else:
|
||||
qs = request.user.get_events_with_any_permission(request)
|
||||
|
||||
qs = qs.filter(
|
||||
Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) |
|
||||
Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query)
|
||||
).annotate(
|
||||
@@ -100,6 +107,8 @@ def event_list(request):
|
||||
|
||||
def nav_context_list(request):
|
||||
query = request.GET.get('query', '')
|
||||
organizer = request.GET.get('organizer', None)
|
||||
|
||||
try:
|
||||
page = int(request.GET.get('page', '1'))
|
||||
except ValueError:
|
||||
@@ -138,6 +147,13 @@ def nav_context_list(request):
|
||||
] + [
|
||||
serialize_event(e) for e in qs_events.select_related('organizer')[offset:offset + (pagesize if query else 5)]
|
||||
]
|
||||
|
||||
if show_user and organizer:
|
||||
organizer = serialize_orga(Organizer.objects.get(pk=organizer))
|
||||
if organizer in results:
|
||||
results.remove(organizer)
|
||||
results.insert(1, organizer)
|
||||
|
||||
doc = {
|
||||
'results': results,
|
||||
'pagination': {
|
||||
@@ -332,7 +348,10 @@ def organizer_select2(request):
|
||||
if term:
|
||||
qs = qs.filter(Q(name__icontains=term) | Q(slug__icontains=term))
|
||||
if not request.user.has_active_staff_session(request.session.session_key):
|
||||
qs = qs.filter(pk__in=request.user.teams.values_list('organizer', flat=True))
|
||||
if 'can_create' in request.GET:
|
||||
qs = qs.filter(pk__in=request.user.teams.filter(can_create_events=True).values_list('organizer', flat=True))
|
||||
else:
|
||||
qs = qs.filter(pk__in=request.user.teams.values_list('organizer', flat=True))
|
||||
|
||||
total = qs.count()
|
||||
pagesize = 20
|
||||
|
||||
@@ -143,7 +143,7 @@ class VoucherDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
messages.error(request, _('A voucher can not be deleted if it already has been redeemed.'))
|
||||
else:
|
||||
self.object.log_action('pretix.voucher.deleted', user=self.request.user)
|
||||
CartPosition.objects.filter(addon_to__voucher=False).delete()
|
||||
CartPosition.objects.filter(addon_to__voucher=self.object).delete()
|
||||
self.object.cartposition_set.all().delete()
|
||||
self.object.delete()
|
||||
messages.success(request, _('The selected voucher has been deleted.'))
|
||||
|
||||
@@ -76,3 +76,21 @@ class GroupConcat(Aggregate):
|
||||
function='string_agg',
|
||||
template="%(function)s(%(field)s::text, '%(separator)s')",
|
||||
)
|
||||
|
||||
|
||||
class ReplicaRouter:
|
||||
|
||||
def db_for_read(self, model, **hints):
|
||||
return 'default'
|
||||
|
||||
def db_for_write(self, model, **hints):
|
||||
return 'default'
|
||||
|
||||
def allow_relation(self, obj1, obj2, **hints):
|
||||
db_list = ('default', 'replica')
|
||||
if obj1._state.db in db_list and obj2._state.db in db_list:
|
||||
return True
|
||||
return None
|
||||
|
||||
def allow_migrate(self, db, app_label, model_name=None, **hintrs):
|
||||
return True
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-05-01 12:02+0000\n"
|
||||
"POT-Creation-Date: 2019-06-05 10:21+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-05-01 12:02+0000\n"
|
||||
"POT-Creation-Date: 2019-06-05 10:21+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-05-01 12:02+0000\n"
|
||||
"POT-Creation-Date: 2019-06-05 10:21+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2019-05-01 12:02+0000\n"
|
||||
"POT-Creation-Date: 2019-06-05 10:21+0000\n"
|
||||
"PO-Revision-Date: 2018-04-24 14:22+0000\n"
|
||||
"Last-Translator: Pernille Thorsen <perth@aarhus.dk>\n"
|
||||
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user