mirror of
https://github.com/pretix/pretix.git
synced 2026-01-11 22:32:27 +00:00
Compare commits
289 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e9d09e1f | ||
|
|
93947cace0 | ||
|
|
d4eac76a8d | ||
|
|
8889607d1c | ||
|
|
5e9e00acec | ||
|
|
0e89d4c0f7 | ||
|
|
8b3ce69425 | ||
|
|
b20d1e8373 | ||
|
|
c278687487 | ||
|
|
0c45e73456 | ||
|
|
104f84b7a8 | ||
|
|
ac4ecfbe69 | ||
|
|
61c6cd2937 | ||
|
|
38066ca5ab | ||
|
|
373ab29701 | ||
|
|
7302bba602 | ||
|
|
5096121ac7 | ||
|
|
ca4c21a843 | ||
|
|
407ecdf6c5 | ||
|
|
2faeee8e9c | ||
|
|
e1bbf7139f | ||
|
|
64fc38a06e | ||
|
|
6bcf884b7a | ||
|
|
d319293da8 | ||
|
|
832c58d288 | ||
|
|
c251e0e7d3 | ||
|
|
27437e065a | ||
|
|
86534aa7cc | ||
|
|
379a2140c8 | ||
|
|
67059fe323 | ||
|
|
8ffc96bf31 | ||
|
|
58b688628e | ||
|
|
3f7348717b | ||
|
|
90c8e0c172 | ||
|
|
d35ad345d7 | ||
|
|
21634369a8 | ||
|
|
a2b075c0d7 | ||
|
|
0617abe6e3 | ||
|
|
040466353c | ||
|
|
46b7e9467b | ||
|
|
283ff3b5e5 | ||
|
|
b0bb22ea38 | ||
|
|
334ee98318 | ||
|
|
c4d342029b | ||
|
|
bc86f9c059 | ||
|
|
51107fe4fd | ||
|
|
3d65c2fd51 | ||
|
|
9b394b3833 | ||
|
|
d5747084ec | ||
|
|
777772b89e | ||
|
|
c202286470 | ||
|
|
0c1738b9bb | ||
|
|
af607083cb | ||
|
|
def7918b29 | ||
|
|
0933fc848d | ||
|
|
166f8b8a2a | ||
|
|
70fcba96a5 | ||
|
|
2d2d62045a | ||
|
|
3988f1e2f6 | ||
|
|
d3ecb92108 | ||
|
|
b3debdfb55 | ||
|
|
abb770a8e7 | ||
|
|
72a2d0da35 | ||
|
|
937cec53f7 | ||
|
|
6e4af5da64 | ||
|
|
7ed35e06ba | ||
|
|
55841ea660 | ||
|
|
78544cdb30 | ||
|
|
37183aced7 | ||
|
|
a7d3cb134c | ||
|
|
da8f7f163f | ||
|
|
89d612beed | ||
|
|
f23de7e2c0 | ||
|
|
d073007fd7 | ||
|
|
d9d1c83218 | ||
|
|
ae9b8bafb8 | ||
|
|
cbf5c2ec1d | ||
|
|
17392f3ef4 | ||
|
|
bf36ad009f | ||
|
|
ca9e4823e2 | ||
|
|
d505422e0f | ||
|
|
33c43ce482 | ||
|
|
f273cf4960 | ||
|
|
afdf09eeb4 | ||
|
|
01e5872f61 | ||
|
|
14cc31c810 | ||
|
|
2972129547 | ||
|
|
ec4227651a | ||
|
|
77950de588 | ||
|
|
187576eee5 | ||
|
|
0e513a0985 | ||
|
|
1cde728ffe | ||
|
|
76893caffc | ||
|
|
a539999c04 | ||
|
|
b9c570b3d8 | ||
|
|
48b399424a | ||
|
|
1c73f000a9 | ||
|
|
d0721165c1 | ||
|
|
bed0a0ceeb | ||
|
|
b53ee1dc1d | ||
|
|
41b56c00e5 | ||
|
|
cb17febf7c | ||
|
|
07d42a4d77 | ||
|
|
e3ebf887a4 | ||
|
|
0440187e59 | ||
|
|
dfcda0fa2c | ||
|
|
560c0a8729 | ||
|
|
bc80b60b04 | ||
|
|
08bf3648ea | ||
|
|
f8ee7acad6 | ||
|
|
10c86869ea | ||
|
|
9034a98df9 | ||
|
|
a7142fdf55 | ||
|
|
ee97c46aec | ||
|
|
7063f32f24 | ||
|
|
2ec926b7c7 | ||
|
|
834b5a26a5 | ||
|
|
90f08d0aca | ||
|
|
d5c2637198 | ||
|
|
f517ba51bd | ||
|
|
d738198ec5 | ||
|
|
b1ce58d06c | ||
|
|
b26ef74128 | ||
|
|
4f8c8ea917 | ||
|
|
0803b049af | ||
|
|
97f3fbdb80 | ||
|
|
434b6e4729 | ||
|
|
f56bceb55f | ||
|
|
2aa246b3d5 | ||
|
|
f77b551aa6 | ||
|
|
c9415cba2b | ||
|
|
4dae224d73 | ||
|
|
13cc57e98b | ||
|
|
6f980b82ac | ||
|
|
f32c581a9e | ||
|
|
fcadfffb92 | ||
|
|
9e43459879 | ||
|
|
87424c25de | ||
|
|
acdf7d62b5 | ||
|
|
944138f7a9 | ||
|
|
5da2eab1fb | ||
|
|
d680937a6c | ||
|
|
f35c2544b6 | ||
|
|
0285cd12f7 | ||
|
|
03cacace57 | ||
|
|
6ed016e49e | ||
|
|
da8da01614 | ||
|
|
9a2ea6699a | ||
|
|
51a8bac9e6 | ||
|
|
303ed07504 | ||
|
|
c7627f631f | ||
|
|
604c31c6e2 | ||
|
|
c3da6731a1 | ||
|
|
6e556ab09b | ||
|
|
16622883f6 | ||
|
|
cce4379d3e | ||
|
|
5af99f4f1a | ||
|
|
9ed49888b8 | ||
|
|
5bfb00db73 | ||
|
|
a031d72ca9 | ||
|
|
15a190cdf3 | ||
|
|
d181375479 | ||
|
|
d8a57b0baa | ||
|
|
d482bc9de0 | ||
|
|
5c030796d7 | ||
|
|
f6eb3bfb80 | ||
|
|
3703fbcacf | ||
|
|
cdea6eb55e | ||
|
|
bf1e9d47d0 | ||
|
|
350df2a3cc | ||
|
|
bc6915b251 | ||
|
|
f9c7eeff9a | ||
|
|
247bcf0a20 | ||
|
|
455c961fc7 | ||
|
|
9052d4a7a9 | ||
|
|
589401e8d2 | ||
|
|
0c366a8473 | ||
|
|
c9ddbd0e88 | ||
|
|
31bf0c24f1 | ||
|
|
c74386346b | ||
|
|
725e1f019e | ||
|
|
06eddb2c6d | ||
|
|
80b5750756 | ||
|
|
f37d265534 | ||
|
|
7c4a1e5fb8 | ||
|
|
9a045c76ec | ||
|
|
447b36fdd3 | ||
|
|
5dbd984178 | ||
|
|
95f96f8321 | ||
|
|
3933032778 | ||
|
|
d0b18d9f64 | ||
|
|
71de71ed37 | ||
|
|
3438d079d5 | ||
|
|
e7730333c2 | ||
|
|
e8b9f0a3ae | ||
|
|
77ebd18404 | ||
|
|
2d48198c83 | ||
|
|
d103b0bb84 | ||
|
|
01411b84e4 | ||
|
|
b7e154d8c9 | ||
|
|
f39ac96322 | ||
|
|
74db808978 | ||
|
|
ab72b93706 | ||
|
|
af5aece639 | ||
|
|
228ab15900 | ||
|
|
66164d8202 | ||
|
|
d5ac155914 | ||
|
|
75a966529e | ||
|
|
28a6a6185d | ||
|
|
07cdaa9ca9 | ||
|
|
1c6935ebd9 | ||
|
|
60c1ea8aad | ||
|
|
0b8798a65c | ||
|
|
a8836cbeec | ||
|
|
336a34b10b | ||
|
|
c5862cc0a0 | ||
|
|
89cdcd3781 | ||
|
|
2837cac554 | ||
|
|
3b54556739 | ||
|
|
4d6d6ff737 | ||
|
|
ffee31e415 | ||
|
|
8abfbba9d0 | ||
|
|
588955901c | ||
|
|
4b7bf2f27f | ||
|
|
664957e886 | ||
|
|
f15a6d39c3 | ||
|
|
3fd80a9a46 | ||
|
|
2fd2716303 | ||
|
|
37315fc380 | ||
|
|
f96fc0744e | ||
|
|
5bb7883020 | ||
|
|
3f95434922 | ||
|
|
08da5a8b91 | ||
|
|
97dc4421ea | ||
|
|
26ca2ff006 | ||
|
|
980c359f57 | ||
|
|
ff1198dec6 | ||
|
|
7275de94af | ||
|
|
ed46f41f8c | ||
|
|
1078e38890 | ||
|
|
2e9bbff308 | ||
|
|
13a48701fa | ||
|
|
ddc9c850c0 | ||
|
|
fa0dae6ed6 | ||
|
|
da6176a51e | ||
|
|
4ef6659551 | ||
|
|
82624a1dc0 | ||
|
|
b50add260a | ||
|
|
f72f97d366 | ||
|
|
ad46e9e541 | ||
|
|
343dbc00be | ||
|
|
3cb94f702d | ||
|
|
ddeae224fb | ||
|
|
3c57895101 | ||
|
|
687c85eb58 | ||
|
|
90ffdbdfa3 | ||
|
|
654be0db34 | ||
|
|
82e3359b40 | ||
|
|
01a6861453 | ||
|
|
7f6cdd6241 | ||
|
|
aad1fda31f | ||
|
|
ad462921f0 | ||
|
|
dc433f6420 | ||
|
|
2d8b3d1c79 | ||
|
|
eb85fa956e | ||
|
|
215514fca7 | ||
|
|
3fe2dfe810 | ||
|
|
041d05eb66 | ||
|
|
d05530ddfc | ||
|
|
734e77d1a3 | ||
|
|
633061e203 | ||
|
|
e11ee4a427 | ||
|
|
1edcd47703 | ||
|
|
cf4b2544f2 | ||
|
|
04c3cffd43 | ||
|
|
483d41c7a6 | ||
|
|
b0c4c88d01 | ||
|
|
518298f71c | ||
|
|
62c2e7765b | ||
|
|
2bb2a40509 | ||
|
|
49828186b0 | ||
|
|
c07a6cb4aa | ||
|
|
67ad9a0dcb | ||
|
|
d267dfc682 | ||
|
|
eed220f14a | ||
|
|
85289fe0d1 | ||
|
|
6293ad34d4 | ||
|
|
0dc4f61cf0 | ||
|
|
6849e682d7 |
@@ -38,7 +38,7 @@ if [ "$1" == "translation-spelling" ]; then
|
||||
potypo
|
||||
fi
|
||||
if [ "$1" == "tests" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt pytest-xdist
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
|
||||
@@ -125,6 +125,23 @@ Example::
|
||||
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
||||
turns on some optimizations/special case handlers. Default: ``False``
|
||||
|
||||
Database replica settings
|
||||
-------------------------
|
||||
|
||||
If you use a replicated database setup, pretix expects that the default database connection always points to the primary database node.
|
||||
Routing read queries to a replica on database layer is **strongly** discouraged since this can lead to inaccurate such as more tickets
|
||||
being sold than are actually available.
|
||||
|
||||
However, pretix can still make use of a database replica to keep some expensive queries with that can tolerate some latency from your
|
||||
primary database, such as backend search queries. The ``replica`` configuration section can have the same settings as the ``database``
|
||||
section (except for the ``backend`` setting) and will default back to the ``database`` settings for all values that are not given. This
|
||||
way, you just need to specify the settings that are different for the replica.
|
||||
|
||||
Example::
|
||||
|
||||
[replica]
|
||||
host=192.168.0.2
|
||||
|
||||
URLs
|
||||
----
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ To build and run pretix, you will need the following debian packages::
|
||||
|
||||
# apt-get install git build-essential python-dev python-virtualenv python3 python3-pip \
|
||||
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
||||
gettext libpq-dev libmysqlclient-dev libjpeg-dev
|
||||
gettext libpq-dev libmysqlclient-dev libjpeg-dev libopenjp2-7-dev
|
||||
|
||||
Config file
|
||||
-----------
|
||||
|
||||
@@ -445,6 +445,8 @@ Order position endpoints
|
||||
The result format is the same as the :ref:`order-position-resource`, with one important difference: the
|
||||
``checkins`` value will only include check-ins for the selected list.
|
||||
|
||||
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -516,6 +518,8 @@ Order position endpoints
|
||||
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
||||
accepts a number of optional requests in the body.
|
||||
|
||||
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
|
||||
|
||||
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||
you do not implement question handling in your user interface, you **must**
|
||||
set this to ``false``. In that case, questions will just be ignored. Defaults
|
||||
@@ -561,7 +565,10 @@ Order position endpoints
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "ok"
|
||||
"status": "ok",
|
||||
"position": {
|
||||
…
|
||||
}
|
||||
}
|
||||
|
||||
**Example response with required questions**:
|
||||
@@ -572,7 +579,10 @@ Order position endpoints
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "incomplete"
|
||||
"status": "incomplete",
|
||||
"position": {
|
||||
…
|
||||
},
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -617,6 +627,9 @@ Order position endpoints
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "unpaid",
|
||||
"position": {
|
||||
…
|
||||
}
|
||||
}
|
||||
|
||||
Possible error reasons:
|
||||
|
||||
@@ -15,6 +15,7 @@ name multi-lingual string The event's ful
|
||||
slug string A short form of the name, used e.g. in URLs.
|
||||
live boolean If ``true``, the event ticket shop is publicly
|
||||
available.
|
||||
testmode boolean If ``true``, the ticket shop is in test mode.
|
||||
currency string The currency this event is handled in.
|
||||
date_from datetime The event's start date
|
||||
date_to datetime The event's end date (or ``null``)
|
||||
@@ -45,6 +46,10 @@ plugins list A list of packa
|
||||
|
||||
Filters have been added to the list of events.
|
||||
|
||||
.. versionchanged:: 2.5
|
||||
|
||||
The ``testmode`` attribute has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -79,6 +84,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -137,6 +143,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -183,6 +190,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -211,6 +219,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -259,6 +268,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -287,6 +297,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -347,6 +358,7 @@ Endpoints
|
||||
"name": {"en": "Sample Conference"},
|
||||
"slug": "sampleconf",
|
||||
"live": false,
|
||||
"testmode": false,
|
||||
"currency": "EUR",
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
|
||||
@@ -64,6 +64,12 @@ original_price money (string) An original pri
|
||||
require_approval boolean If ``True``, orders with this product will need to be
|
||||
approved by the event organizer before they can be
|
||||
paid.
|
||||
generate_tickets boolean If ``False``, tickets are never generated for this
|
||||
product, regardless of other settings. If ``True``,
|
||||
tickets are generated even if this is a
|
||||
non-admission or add-on product, regardless of event
|
||||
settings. If this is ``null``, regular ticketing
|
||||
rules apply.
|
||||
has_variations boolean Shows whether or not this item has variations.
|
||||
variations list of objects A list with one object for each variation of this item.
|
||||
Can be empty. Only writable during creation,
|
||||
@@ -111,6 +117,10 @@ addons list of objects Definition of a
|
||||
|
||||
The ``sales_channels`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.4
|
||||
|
||||
The ``generate_tickets`` attribute has been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Please note that an item either always has variations or never has. Once created with variations the item can never
|
||||
@@ -174,6 +184,7 @@ Endpoints
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": false,
|
||||
"generate_tickets": null,
|
||||
"require_approval": false,
|
||||
"variations": [
|
||||
{
|
||||
@@ -256,6 +267,7 @@ Endpoints
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"generate_tickets": null,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
@@ -323,6 +335,7 @@ Endpoints
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"generate_tickets": null,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
@@ -379,6 +392,7 @@ Endpoints
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"generate_tickets": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
"require_approval": false,
|
||||
@@ -462,6 +476,7 @@ Endpoints
|
||||
"available_until": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"generate_tickets": null,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
|
||||
@@ -26,7 +26,8 @@ status string Order status, o
|
||||
* ``p`` – paid
|
||||
* ``e`` – expired
|
||||
* ``c`` – canceled
|
||||
* ``r`` – refunded
|
||||
testmode boolean If ``True``, this order was created when the event was in
|
||||
test mode. Only orders in test mode can be deleted.
|
||||
secret string The secret contained in the link sent to the customer
|
||||
email string The customer email address
|
||||
locale string The locale used for communication with this customer
|
||||
@@ -58,9 +59,9 @@ invoice_address object Invoice address
|
||||
└ vat_id_validated string ``True``, if the VAT ID has been validated against the
|
||||
EU VAT service and validation was successful. This only
|
||||
happens in rare cases.
|
||||
positions list of objects List of order positions (see below)
|
||||
fees list of objects List of fees included in the order total (i.e.
|
||||
payment fees)
|
||||
positions list of objects List of non-canceled order positions (see below)
|
||||
fees list of objects List of non-canceled fees included in the order total
|
||||
(i.e. payment fees)
|
||||
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
|
||||
``other``)
|
||||
├ value money (string) Fee amount
|
||||
@@ -127,6 +128,15 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``sales_channel`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.4:
|
||||
|
||||
``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and
|
||||
``…/mark_refunded/`` has been deprecated.
|
||||
|
||||
.. versionchanged:: 2.5:
|
||||
|
||||
The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders.
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
Order position resource
|
||||
@@ -268,6 +278,7 @@ List of all orders
|
||||
{
|
||||
"code": "ABC12",
|
||||
"status": "p",
|
||||
"testmode": false,
|
||||
"secret": "k24fiuwvu8kxz3y1",
|
||||
"email": "tester@example.org",
|
||||
"locale": "en",
|
||||
@@ -366,11 +377,14 @@ List of all orders
|
||||
``status``. Default: ``datetime``
|
||||
:query string code: Only return orders that match the given order code
|
||||
:query string status: Only return orders in the given order status (see above)
|
||||
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
|
||||
:query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field
|
||||
``require_approval`` will be returned.
|
||||
:query string email: Only return orders created with the given email address
|
||||
:query string locale: Only return orders with the given customer locale
|
||||
:query datetime modified_since: Only return orders that have changed since the given date
|
||||
:query datetime modified_since: Only return orders that have changed since the given date. Be careful: We only
|
||||
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
|
||||
you will not notice it using this method.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
|
||||
@@ -405,6 +419,7 @@ Fetching individual orders
|
||||
{
|
||||
"code": "ABC12",
|
||||
"status": "p",
|
||||
"testmode": false,
|
||||
"secret": "k24fiuwvu8kxz3y1",
|
||||
"email": "tester@example.org",
|
||||
"locale": "en",
|
||||
@@ -548,6 +563,37 @@ Order ticket download
|
||||
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
|
||||
seconds.
|
||||
|
||||
Deleting orders
|
||||
---------------
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
|
||||
|
||||
Deletes an order. Works only if the order has ``testmode`` set to ``true``.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param code: The ``code`` field of the order to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource **or** the order may not be deleted.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
Creating orders
|
||||
---------------
|
||||
|
||||
@@ -602,6 +648,7 @@ Creating orders
|
||||
or in state ``confirmed``, depending on this value. If you create a paid order, the ``order_paid`` signal will
|
||||
**not** be sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and
|
||||
then call the ``mark_paid`` API method.
|
||||
* ``testmode`` (optional) – Defaults to ``false``
|
||||
* ``consume_carts`` (optional) – A list of cart IDs. All cart positions with these IDs will be deleted if the
|
||||
order creation is successful. Any quotas that become free by this operation will be credited to your order
|
||||
creation.
|
||||
@@ -776,7 +823,10 @@ Order state operations
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_canceled/
|
||||
|
||||
Marks a pending order as canceled.
|
||||
Cancels an order. For a pending order, this will set the order to status ``c``. For a paid order, this will set
|
||||
the order to status ``c`` if no ``cancellation_fee`` is passed. If you do pass a ``cancellation_fee``, the order
|
||||
will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation
|
||||
fee as the only component of the order.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -788,7 +838,8 @@ Order state operations
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"send_email": true
|
||||
"send_email": true,
|
||||
"cancellation_fee": null
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -849,44 +900,6 @@ Order state operations
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_refunded/
|
||||
|
||||
Marks a paid order as refunded.
|
||||
|
||||
.. warning:: In the current implementation, this will **bypass** the payment provider, i.e. the money will **not** be
|
||||
transferred back to the user automatically, the order will only be *marked* as refunded within pretix.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_expired/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"code": "ABC12",
|
||||
"status": "r",
|
||||
...
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param code: The ``code`` field of the order to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The order cannot be marked as expired since the current order status does not allow it.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/
|
||||
|
||||
Marks a unpaid order as expired.
|
||||
@@ -1066,6 +1079,8 @@ List of all order positions
|
||||
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
|
||||
``pseudonymization_id``.
|
||||
|
||||
.. note:: Individually canceled order positions are currently not visible via the API at all.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
||||
|
||||
Returns a list of all order positions within a given event.
|
||||
@@ -1501,7 +1516,7 @@ Order payment endpoints
|
||||
|
||||
{
|
||||
"amount": "23.00",
|
||||
"mark_refunded": false
|
||||
"mark_canceled": false
|
||||
}
|
||||
|
||||
|
||||
@@ -1648,7 +1663,7 @@ Order refund endpoints
|
||||
"payment": 1,
|
||||
"execution_date": null,
|
||||
"provider": "manual",
|
||||
"mark_refunded": false
|
||||
"mark_canceled": false
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -1718,7 +1733,7 @@ Order refund endpoints
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/process/
|
||||
|
||||
Acts on an external refund, either marks the order as refunded or pending. Only allowed in state ``external``.
|
||||
Acts on an external refund, either marks the order as canceled or pending. Only allowed in state ``external``.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -1729,7 +1744,7 @@ Order refund endpoints
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{"mark_refunded": false}
|
||||
{"mark_canceled": false}
|
||||
|
||||
**Example response**:
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ The following values for ``action_types`` are valid with pretix core:
|
||||
* ``pretix.event.order.contact.changed``
|
||||
* ``pretix.event.order.changed.*``
|
||||
* ``pretix.event.order.refund.created.externally``
|
||||
* ``pretix.event.order.refunded``
|
||||
* ``pretix.event.order.approved``
|
||||
* ``pretix.event.order.denied``
|
||||
* ``pretix.event.checkin``
|
||||
|
||||
@@ -26,7 +26,7 @@ Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content
|
||||
: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
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
@@ -49,7 +49,7 @@ Backend
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
|
||||
order_info, event_settings_widget, oauth_application_registered
|
||||
order_info, event_settings_widget, oauth_application_registered, order_position_buttons
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
|
||||
@@ -23,7 +23,7 @@ that we'll provide in this plugin::
|
||||
|
||||
|
||||
@receiver(register_invoice_renderers, dispatch_uid="output_custom")
|
||||
def register_infoice_renderers(sender, **kwargs):
|
||||
def register_invoice_renderers(sender, **kwargs):
|
||||
from .invoice import MyInvoiceRenderer
|
||||
return MyInvoiceRenderer
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ The provider class
|
||||
|
||||
.. autoattribute:: is_meta
|
||||
|
||||
.. autoattribute:: test_mode_message
|
||||
|
||||
|
||||
Additional views
|
||||
----------------
|
||||
|
||||
@@ -79,6 +79,9 @@ human-readable error messages. We recommend using the ``django.utils.functional.
|
||||
decorator, as it might get called a lot. You can also implement ``compatibility_warnings``,
|
||||
those will be displayed but not block the plugin execution.
|
||||
|
||||
The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin
|
||||
is available for a specific event. If not, it will not be shown in the plugin list of that event.
|
||||
|
||||
Plugin registration
|
||||
-------------------
|
||||
|
||||
|
||||
@@ -82,6 +82,12 @@ Orders
|
||||
^^^^^^
|
||||
|
||||
If a customer completes the checkout process, an **Order** will be created containing all the entered information.
|
||||
An order can be in one of currently five states that are listed in the diagram below:
|
||||
An order can be in one of currently four states that are listed in the diagram below:
|
||||
|
||||
.. image:: /images/order_states.png
|
||||
|
||||
There are additional "fake" states that are displayed like states but not represented as states in the system:
|
||||
|
||||
* An order is considered **canceled (with paid fee)** if it is in **paid** status but does not include any non-cancelled positions.
|
||||
|
||||
* An order is considered **requiring approval** if it is in **pending** status with the ``require_approval`` attribute set to ``True``.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 53 KiB |
@@ -4,7 +4,6 @@ Pending: Order is expecting payment\nOrder reduces quotas
|
||||
Expired: Payment period is over\nOrder does not affect quotas
|
||||
Paid: Order was successful\nOrder reduces quotas
|
||||
Canceled: Order has been canceled\nOrder does not affect quotas
|
||||
Refunded: Order has been refunded\nOrder does not affect quotas
|
||||
|
||||
[*] --> Pending: customer\nplaces order
|
||||
Pending --> Paid: successful payment
|
||||
@@ -12,8 +11,9 @@ Pending --> Expired: automatically\nor manually\non admin action
|
||||
Expired --> Paid: if payment is received\nonly if quota left
|
||||
Expired --> Canceled
|
||||
Expired --> Pending: manually\non admin action
|
||||
Paid --> Refunded: manually on\nadmin action\nor if an external\npayment provider\nnotifies about a\npayment refund
|
||||
Paid --> Canceled: manually on\nadmin action\nor if an external\npayment provider\nnotifies about a\npayment refund
|
||||
Pending --> Canceled: on admin or\ncustomer action
|
||||
Paid -> Pending: manually on admin action
|
||||
[*] --> Paid: customer\nplaces free order
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -6,6 +6,7 @@ api
|
||||
auditability
|
||||
auth
|
||||
autobuild
|
||||
availabilities
|
||||
backend
|
||||
backends
|
||||
banktransfer
|
||||
@@ -110,6 +111,7 @@ submodule
|
||||
subpath
|
||||
Symfony
|
||||
systemd
|
||||
testmode
|
||||
testutils
|
||||
timestamp
|
||||
tuples
|
||||
|
||||
@@ -4,22 +4,10 @@ FAQ and Troubleshooting
|
||||
How can I test my shop before taking it live?
|
||||
---------------------------------------------
|
||||
|
||||
There are multiple ways to do this.
|
||||
|
||||
First, you could just create some orders in your real shop and cancel/refund them later. If you don't want to process
|
||||
real payments for the tests, you can either use a "manual" payment method like bank transfer and just mark the orders
|
||||
as paid with the button in the backend, or if you want to use e.g. Stripe, you can configure pretix to use your keys
|
||||
for the Stripe test system and use their test credit cars. Read our :ref:`Stripe documentation <stripe>` for more
|
||||
information.
|
||||
|
||||
Second, you could create a separate event, just for testing. In the last step of the :ref:`event creation process <event_create>`,
|
||||
you can specify that you want to copy all settings from your real event, so you don't have to do all of it twice.
|
||||
|
||||
We are planning to add a dedicated test mode in a later version of pretix.
|
||||
|
||||
If you are using the hosted service at pretix.eu and want to get rid of the test orders completely, contact us at
|
||||
support@pretix.eu and we can remove them for you. Please note that we only are able to do that *before* you have
|
||||
received any real orders (i.e. taken the shop public). We won't charge any fees for test orders or test events.
|
||||
On your event dashboard, click on the first tile that shows your shop status. On the lower part of this page, you can
|
||||
place your event into "test mode". In "test mode", everything behaves the same, but orders created during test mode can
|
||||
later be fully deleted. Be sure to actually delete them when or after you turn off test mode, since test mode orders
|
||||
still count toward your quotas and are included in your reports.
|
||||
|
||||
How do I delete an event?
|
||||
-------------------------
|
||||
|
||||
@@ -34,4 +34,5 @@ git push
|
||||
# Unlock Weblate
|
||||
for c in $COMPONENTS; do
|
||||
wlc unlock $c;
|
||||
wlc pull $c;
|
||||
done
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.3.0"
|
||||
__version__ = "2.5.0.dev0"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.functional import cached_property
|
||||
@@ -47,7 +48,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ('name', 'slug', 'live', 'currency', 'date_from',
|
||||
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
|
||||
'date_to', 'date_admission', 'is_public', 'presale_start',
|
||||
'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins')
|
||||
|
||||
@@ -95,7 +96,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
plugins_available = {
|
||||
p.module for p in get_all_plugins()
|
||||
p.module for p in get_all_plugins(self.instance)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
|
||||
@@ -108,7 +109,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
|
||||
event = super().create(validated_data)
|
||||
|
||||
# Meta data
|
||||
@@ -122,6 +123,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
# Plugins
|
||||
if plugins is not None:
|
||||
event.set_active_plugins(plugins)
|
||||
event.save(update_fields=['plugins'])
|
||||
|
||||
return event
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'position', 'picture', 'available_from', 'available_until',
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
|
||||
'variations', 'addons', 'original_price', 'require_approval')
|
||||
'variations', 'addons', 'original_price', 'require_approval', 'generate_tickets')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
@@ -12,6 +12,7 @@ from rest_framework.reverse import reverse
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||
Question, QuestionAnswer,
|
||||
@@ -114,9 +115,7 @@ class PositionDownloadsField(serializers.Field):
|
||||
if instance.order.status != Order.STATUS_PAID:
|
||||
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
|
||||
return []
|
||||
if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons:
|
||||
return []
|
||||
if not instance.item.admission and not instance.order.event.settings.ticket_download_nonadm:
|
||||
if not instance.generate_ticket:
|
||||
return []
|
||||
|
||||
request = self.context['request']
|
||||
@@ -142,20 +141,21 @@ class PdfDataSerializer(serializers.Field):
|
||||
res = {}
|
||||
|
||||
ev = instance.subevent or instance.order.event
|
||||
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
|
||||
# we serialize a list.
|
||||
with language(instance.order.locale):
|
||||
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
|
||||
# we serialize a list.
|
||||
|
||||
if 'vars' not in self.context:
|
||||
self.context['vars'] = get_variables(self.context['request'].event)
|
||||
if 'vars' not in self.context:
|
||||
self.context['vars'] = get_variables(self.context['request'].event)
|
||||
|
||||
for k, f in self.context['vars'].items():
|
||||
res[k] = f['evaluate'](instance, instance.order, ev)
|
||||
for k, f in self.context['vars'].items():
|
||||
res[k] = f['evaluate'](instance, instance.order, ev)
|
||||
|
||||
if not hasattr(ev, '_cached_meta_data'):
|
||||
ev._cached_meta_data = ev.meta_data
|
||||
if not hasattr(ev, '_cached_meta_data'):
|
||||
ev._cached_meta_data = ev.meta_data
|
||||
|
||||
for k, v in ev._cached_meta_data.items():
|
||||
res['meta:' + k] = v
|
||||
for k, v in ev._cached_meta_data.items():
|
||||
res['meta:' + k] = v
|
||||
|
||||
return res
|
||||
|
||||
@@ -231,7 +231,7 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
fields = ('code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
|
||||
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
|
||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel')
|
||||
|
||||
@@ -324,7 +324,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
'secret', 'addon_to', 'subevent', 'answers')
|
||||
|
||||
def validate_secret(self, secret):
|
||||
if secret and OrderPosition.objects.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
raise ValidationError(
|
||||
'You cannot assign a position secret that already exists.'
|
||||
)
|
||||
@@ -413,7 +413,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
@@ -557,7 +557,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
order.payments.create(
|
||||
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
payment_date=now()
|
||||
)
|
||||
elif payment_provider == "free" and order.total != Decimal('0.00'):
|
||||
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count, F, Max, OuterRef, Prefetch, Subquery
|
||||
from django.db.models import Count, F, Max, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -16,7 +16,9 @@ from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import OrderPositionSerializer
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.models import Checkin, CheckinList, Order, OrderPosition
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Event, Order, OrderPosition,
|
||||
)
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, perform_checkin,
|
||||
)
|
||||
@@ -201,12 +203,39 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
subevent=self.checkinlist.subevent
|
||||
).annotate(
|
||||
last_checked_in=Subquery(cqs)
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
Event.objects.select_related('organizer')
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to'
|
||||
)
|
||||
).select_related('item', 'variation', 'order', 'addon_to')
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
|
||||
|
||||
if not self.checkinlist.all_products:
|
||||
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
|
||||
@@ -251,6 +280,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
'status': 'incomplete',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data,
|
||||
'questions': [
|
||||
QuestionSerializer(q).data for q in e.questions
|
||||
]
|
||||
@@ -258,9 +289,21 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
except CheckInError as e:
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': e.code
|
||||
'reason': e.code,
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=400)
|
||||
else:
|
||||
return Response({
|
||||
'status': 'ok',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=201)
|
||||
|
||||
def get_object(self):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
if self.kwargs['pk'].isnumeric():
|
||||
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
||||
else:
|
||||
obj = get_object_or_404(queryset, secret=self.kwargs['pk'])
|
||||
return obj
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import django_filters
|
||||
import pytz
|
||||
@@ -15,7 +16,7 @@ from rest_framework.exceptions import (
|
||||
APIException, NotFound, PermissionDenied, ValidationError,
|
||||
)
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.mixins import CreateModelMixin
|
||||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
@@ -25,8 +26,8 @@ from pretix.api.serializers.order import (
|
||||
OrderRefundSerializer, OrderSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Device, Invoice, Order, OrderPayment,
|
||||
OrderPosition, OrderRefund, Quota, TeamAPIToken,
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, Order,
|
||||
OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.services.invoices import (
|
||||
@@ -50,10 +51,10 @@ class OrderFilter(FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['code', 'status', 'email', 'locale', 'require_approval']
|
||||
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
|
||||
|
||||
|
||||
class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
class OrderViewSet(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderSerializer
|
||||
queryset = Order.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
@@ -82,6 +83,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
'positions',
|
||||
OrderPosition.objects.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item__category', 'addon_to',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
)
|
||||
)
|
||||
@@ -186,6 +188,12 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
@detail_route(methods=['POST'])
|
||||
def mark_canceled(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
cancellation_fee = request.data.get('cancellation_fee', None)
|
||||
if cancellation_fee:
|
||||
try:
|
||||
cancellation_fee = float(Decimal(cancellation_fee))
|
||||
except:
|
||||
cancellation_fee = None
|
||||
|
||||
order = self.get_object()
|
||||
if not order.cancel_allowed():
|
||||
@@ -194,14 +202,21 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
cancel_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
||||
device=request.auth if isinstance(request.auth, Device) else None,
|
||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
||||
send_mail=send_mail
|
||||
)
|
||||
try:
|
||||
cancel_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
||||
device=request.auth if isinstance(request.auth, Device) else None,
|
||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
||||
send_mail=send_mail,
|
||||
cancellation_fee=cancellation_fee
|
||||
)
|
||||
except OrderError as e:
|
||||
return Response(
|
||||
{'detail': str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@@ -363,6 +378,13 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.testmode:
|
||||
raise PermissionDenied('Only test mode orders can be deleted.')
|
||||
|
||||
with transaction.atomic():
|
||||
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
|
||||
|
||||
class OrderPositionFilter(FilterSet):
|
||||
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
|
||||
@@ -377,6 +399,7 @@ class OrderPositionFilter(FilterSet):
|
||||
| Q(addon_to__attendee_name_cached__icontains=value)
|
||||
| Q(order__code__istartswith=value)
|
||||
| Q(order__invoice_address__name_cached__icontains=value)
|
||||
| Q(order__email__icontains=value)
|
||||
)
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
@@ -421,11 +444,33 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question'
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer'
|
||||
)
|
||||
qs = OrderPosition.objects.filter(order__event=self.request.event)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
Event.objects.select_related('organizer')
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to'
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question'
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer'
|
||||
)
|
||||
return qs
|
||||
|
||||
def _get_output_provider(self, identifier):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
@@ -442,10 +487,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
|
||||
if pos.order.status != Order.STATUS_PAID:
|
||||
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
||||
if pos.addon_to_id and not request.event.settings.ticket_download_addons:
|
||||
raise PermissionDenied("Downloads are not enabled for add-on products.")
|
||||
if not pos.item.admission and not request.event.settings.ticket_download_nonadm:
|
||||
raise PermissionDenied("Downloads are not enabled for non-admission products.")
|
||||
if not pos.generate_ticket:
|
||||
raise PermissionDenied("Downloads are not enabled for this product.")
|
||||
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=pos, provider=provider.identifier, file__isnull=False
|
||||
@@ -515,7 +558,10 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||
request.data.get('amount', str(payment.amount))
|
||||
)
|
||||
mark_refunded = request.data.get('mark_refunded', False)
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.get('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.get('mark_canceled', False)
|
||||
|
||||
if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED:
|
||||
return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -624,10 +670,14 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
if request.data.get('mark_refunded', False):
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.get('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.get('mark_canceled', False)
|
||||
if mark_refunded:
|
||||
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth)
|
||||
else:
|
||||
elif not (refund.order.status == Order.STATUS_PAID and refund.order.pending_sum <= 0):
|
||||
refund.order.status = Order.STATUS_PENDING
|
||||
refund.order.set_expires(
|
||||
now(),
|
||||
@@ -653,7 +703,10 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
return ctx
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
mark_refunded = request.data.pop('mark_refunded', False)
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.pop('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.pop('mark_canceled', False)
|
||||
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
@@ -770,7 +823,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
else:
|
||||
c = generate_cancellation(inv)
|
||||
if inv.order.status not in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED):
|
||||
if inv.order.status != Order.STATUS_CANCELED:
|
||||
inv = generate_invoice(inv.order)
|
||||
else:
|
||||
inv = c
|
||||
|
||||
@@ -124,12 +124,12 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
serializer.save(event=self.request.event)
|
||||
for i in serializer.instance:
|
||||
i.log_action(
|
||||
for i, v in enumerate(serializer.instance):
|
||||
v.log_action(
|
||||
'pretix.voucher.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
data=self.request.data[i]
|
||||
)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@@ -113,7 +113,7 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
_('New order placed'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.placed.required_approval',
|
||||
'pretix.event.order.placed.require_approval',
|
||||
_('New order requires approval'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
@@ -144,10 +144,6 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.event.order.refund.created.externally',
|
||||
_('External refund of payment'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.refunded',
|
||||
_('Order refunded'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.approved',
|
||||
_('Order approved'),
|
||||
|
||||
@@ -27,7 +27,7 @@ class CustomSMTPBackend(EmailBackend):
|
||||
if code != 250:
|
||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||
raise SMTPResponseException(code, resp)
|
||||
(code, resp) = self.connection.rcpt('test@example.com')
|
||||
(code, resp) = self.connection.rcpt('testdummy@pretix.eu')
|
||||
if (code != 250) and (code != 251):
|
||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||
raise SMTPResponseException(code, resp)
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Tuple
|
||||
|
||||
from defusedcsv import csv
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import ugettext, ugettext_lazy as _
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import KNOWN_TYPES
|
||||
|
||||
@@ -143,3 +143,73 @@ class ListExporter(BaseExporter):
|
||||
return self._render_csv(form_data, dialect='excel')
|
||||
elif form_data.get('_format') == 'semicolon':
|
||||
return self._render_csv(form_data, dialect='excel', delimiter=';')
|
||||
|
||||
|
||||
class MultiSheetListExporter(ListExporter):
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def export_form_fields(self) -> dict:
|
||||
choices = [
|
||||
('xlsx', _('Combined Excel (.xlsx)')),
|
||||
]
|
||||
for s, l in self.sheets:
|
||||
choices += [
|
||||
(s + ':default', str(l) + ' – ' + ugettext('CSV (with commas)')),
|
||||
(s + ':excel', str(l) + ' – ' + ugettext('CSV (Excel-style)')),
|
||||
(s + ':semicolon', str(l) + ' – ' + ugettext('CSV (with semicolons)')),
|
||||
]
|
||||
ff = OrderedDict(
|
||||
[
|
||||
('_format',
|
||||
forms.ChoiceField(
|
||||
label=_('Export format'),
|
||||
choices=choices,
|
||||
)),
|
||||
]
|
||||
)
|
||||
ff.update(self.additional_form_fields)
|
||||
return ff
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
pass
|
||||
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
def _render_sheet_csv(self, form_data, sheet, **kwargs):
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, **kwargs)
|
||||
for line in self.iterate_sheet(form_data, sheet):
|
||||
writer.writerow(line)
|
||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
def _render_xlsx(self, form_data):
|
||||
wb = Workbook()
|
||||
ws = wb.get_active_sheet()
|
||||
wb.remove(ws)
|
||||
for s, l in self.sheets:
|
||||
ws = wb.create_sheet(str(l))
|
||||
for i, line in enumerate(self.iterate_sheet(form_data, sheet=s)):
|
||||
for j, val in enumerate(line):
|
||||
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
|
||||
wb.save(f.name)
|
||||
f.seek(0)
|
||||
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
|
||||
|
||||
def render(self, form_data: dict) -> Tuple[str, str, bytes]:
|
||||
if form_data.get('_format') == 'xlsx':
|
||||
return self._render_xlsx(form_data)
|
||||
elif ':' in form_data.get('_format'):
|
||||
sheet, f = form_data.get('_format').split(':')
|
||||
if f == 'default':
|
||||
return self._render_sheet_csv(form_data, sheet, quoting=csv.QUOTE_NONNUMERIC, delimiter=',')
|
||||
elif f == 'excel':
|
||||
return self._render_sheet_csv(form_data, sheet, dialect='excel')
|
||||
elif f == 'semicolon':
|
||||
return self._render_sheet_csv(form_data, sheet, dialect='excel', delimiter=';')
|
||||
|
||||
@@ -10,7 +10,7 @@ from ..signals import register_data_exporters
|
||||
|
||||
class JSONExporter(BaseExporter):
|
||||
identifier = 'json'
|
||||
verbose_name = 'JSON'
|
||||
verbose_name = 'Order data (JSON)'
|
||||
|
||||
def render(self, form_data):
|
||||
jo = {
|
||||
|
||||
@@ -3,22 +3,32 @@ from decimal import Decimal
|
||||
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.db.models import DateTimeField, Max, OuterRef, Subquery, Sum
|
||||
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import localize
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
|
||||
from pretix.base.models import InvoiceAddress, Order, OrderPosition
|
||||
from pretix.base.models import (
|
||||
InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||
)
|
||||
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from ..exporter import ListExporter
|
||||
from ..exporter import ListExporter, MultiSheetListExporter
|
||||
from ..signals import register_data_exporters
|
||||
|
||||
|
||||
class OrderListExporter(ListExporter):
|
||||
class OrderListExporter(MultiSheetListExporter):
|
||||
identifier = 'orderlist'
|
||||
verbose_name = ugettext_lazy('List of orders')
|
||||
verbose_name = ugettext_lazy('Order data')
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
return (
|
||||
('orders', _('Orders')),
|
||||
('positions', _('Order positions')),
|
||||
('fees', _('Order fees')),
|
||||
)
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
@@ -49,7 +59,15 @@ class OrderListExporter(ListExporter):
|
||||
tax_rates = sorted(tax_rates)
|
||||
return tax_rates
|
||||
|
||||
def iterate_list(self, form_data: dict):
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
if sheet == 'orders':
|
||||
return self.iterate_orders(form_data)
|
||||
elif sheet == 'positions':
|
||||
return self.iterate_positions(form_data)
|
||||
elif sheet == 'fees':
|
||||
return self.iterate_fees(form_data)
|
||||
|
||||
def iterate_orders(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
p_date = OrderPayment.objects.filter(
|
||||
@@ -160,13 +178,188 @@ class OrderListExporter(ListExporter):
|
||||
row.append(', '.join([i.number for i in order.invoices.all()]))
|
||||
yield row
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
qs = OrderFee.objects.filter(
|
||||
order__event=self.event,
|
||||
).select_related('order', 'order__invoice_address', 'tax_rule')
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
|
||||
headers = [
|
||||
_('Order code'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Order date'),
|
||||
_('Fee type'),
|
||||
_('Description'),
|
||||
_('Price'),
|
||||
_('Tax rate'),
|
||||
_('Tax rule'),
|
||||
_('Tax value'),
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
]
|
||||
|
||||
yield headers
|
||||
|
||||
for op in qs.order_by('order__datetime'):
|
||||
order = op.order
|
||||
row = [
|
||||
order.code,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
op.get_fee_type_display(),
|
||||
op.description,
|
||||
op.value,
|
||||
op.tax_rate,
|
||||
str(op.tax_rule) if op.tax_rule else '',
|
||||
op.tax_value,
|
||||
]
|
||||
try:
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
)
|
||||
row += [
|
||||
order.invoice_address.street,
|
||||
order.invoice_address.zipcode,
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
yield row
|
||||
|
||||
def iterate_positions(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.event,
|
||||
).select_related(
|
||||
'order', 'order__invoice_address', 'item', 'variation',
|
||||
'voucher', 'tax_rule'
|
||||
).prefetch_related(
|
||||
'answers', 'answers__question'
|
||||
)
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
|
||||
headers = [
|
||||
_('Order code'),
|
||||
_('Position ID'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Order date'),
|
||||
_('Product'),
|
||||
_('Variation'),
|
||||
_('Price'),
|
||||
_('Tax rate'),
|
||||
_('Tax rule'),
|
||||
_('Tax value'),
|
||||
_('Attendee name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Attendee name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Attendee email'),
|
||||
_('Voucher'),
|
||||
_('Pseudonymization ID'),
|
||||
]
|
||||
questions = list(self.event.questions.all())
|
||||
for q in questions:
|
||||
headers.append(str(q.question))
|
||||
headers += [
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
]
|
||||
|
||||
yield headers
|
||||
|
||||
for op in qs.order_by('order__datetime', 'positionid'):
|
||||
order = op.order
|
||||
row = [
|
||||
order.code,
|
||||
op.positionid,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
str(op.item),
|
||||
str(op.variation) if op.variation else '',
|
||||
op.price,
|
||||
op.tax_rate,
|
||||
str(op.tax_rule) if op.tax_rule else '',
|
||||
op.tax_value,
|
||||
op.attendee_name,
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
op.attendee_name_parts.get(k, '')
|
||||
)
|
||||
row += [
|
||||
op.attendee_email,
|
||||
op.voucher.code if op.voucher else '',
|
||||
op.pseudonymization_id,
|
||||
]
|
||||
acache = {}
|
||||
for a in op.answers.all():
|
||||
acache[a.question_id] = str(a)
|
||||
for q in questions:
|
||||
row.append(acache.get(q.pk, ''))
|
||||
try:
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
)
|
||||
row += [
|
||||
order.invoice_address.street,
|
||||
order.invoice_address.zipcode,
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_orders'.format(self.event.slug)
|
||||
|
||||
|
||||
class PaymentListExporter(ListExporter):
|
||||
identifier = 'paymentlist'
|
||||
verbose_name = ugettext_lazy('List of payments and refunds')
|
||||
verbose_name = ugettext_lazy('Order payments and refunds')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
@@ -208,7 +401,7 @@ class PaymentListExporter(ListExporter):
|
||||
|
||||
headers = [
|
||||
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Amount'), _('Payment method')
|
||||
_('Status code'), _('Amount'), _('Payment method')
|
||||
]
|
||||
yield headers
|
||||
|
||||
@@ -225,6 +418,7 @@ class PaymentListExporter(ListExporter):
|
||||
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
d2,
|
||||
obj.get_state_display(),
|
||||
obj.state,
|
||||
localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)),
|
||||
provider_names.get(obj.provider, obj.provider)
|
||||
]
|
||||
@@ -263,6 +457,176 @@ class QuotaListExporter(ListExporter):
|
||||
return '{}_quotas'.format(self.event.slug)
|
||||
|
||||
|
||||
class InvoiceDataExporter(MultiSheetListExporter):
|
||||
identifier = 'invoicedata'
|
||||
verbose_name = ugettext_lazy('Invoice data')
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
return (
|
||||
('invoices', _('Invoices')),
|
||||
('lines', _('Invoice lines')),
|
||||
)
|
||||
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
if sheet == 'invoices':
|
||||
yield [
|
||||
_('Invoice number'),
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
_('E-mail address'),
|
||||
_('Invoice type'),
|
||||
_('Cancellation of'),
|
||||
_('Language'),
|
||||
_('Invoice sender:') + ' ' + _('Name'),
|
||||
_('Invoice sender:') + ' ' + _('Address'),
|
||||
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||
_('Invoice sender:') + ' ' + _('City'),
|
||||
_('Invoice sender:') + ' ' + _('Country'),
|
||||
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Company'),
|
||||
_('Invoice recipient:') + ' ' + _('Name'),
|
||||
_('Invoice recipient:') + ' ' + _('Street address'),
|
||||
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||
_('Invoice recipient:') + ' ' + _('City'),
|
||||
_('Invoice recipient:') + ' ' + _('Country'),
|
||||
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
_('Reverse charge'),
|
||||
_('Shown foreign currency'),
|
||||
_('Foreign currency rate'),
|
||||
_('Total value (with taxes)'),
|
||||
_('Total value (without taxes)'),
|
||||
]
|
||||
qs = self.event.invoices.order_by('full_invoice_no').select_related(
|
||||
'order', 'refers'
|
||||
).annotate(
|
||||
total_gross=Subquery(
|
||||
InvoiceLine.objects.filter(
|
||||
invoice=OuterRef('pk')
|
||||
).order_by().values('invoice').annotate(
|
||||
s=Sum('gross_value')
|
||||
).values('s')
|
||||
),
|
||||
total_net=Subquery(
|
||||
InvoiceLine.objects.filter(
|
||||
invoice=OuterRef('pk')
|
||||
).order_by().values('invoice').annotate(
|
||||
s=Sum(F('gross_value') - F('tax_value'))
|
||||
).values('s')
|
||||
)
|
||||
)
|
||||
for i in qs:
|
||||
yield [
|
||||
i.full_invoice_no,
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
i.order.code,
|
||||
i.order.email,
|
||||
_('Cancellation') if i.is_cancellation else _('Invoice'),
|
||||
i.refers.full_invoice_no if i.refers else '',
|
||||
i.locale,
|
||||
i.invoice_from_name,
|
||||
i.invoice_from,
|
||||
i.invoice_from_zipcode,
|
||||
i.invoice_from_city,
|
||||
i.invoice_from_country,
|
||||
i.invoice_from_tax_id,
|
||||
i.invoice_from_vat_id,
|
||||
i.invoice_to_company,
|
||||
i.invoice_to_name,
|
||||
i.invoice_to_street or i.invoice_to,
|
||||
i.invoice_to_zipcode,
|
||||
i.invoice_to_city,
|
||||
i.invoice_to_country,
|
||||
i.invoice_to_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
_('Yes') if i.reverse_charge else _('No'),
|
||||
i.foreign_currency_display,
|
||||
i.foreign_currency_rate,
|
||||
i.total_gross if i.total_gross else Decimal('0.00'),
|
||||
Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')),
|
||||
]
|
||||
elif sheet == 'lines':
|
||||
yield [
|
||||
_('Invoice number'),
|
||||
_('Line number'),
|
||||
_('Description'),
|
||||
_('Gross price'),
|
||||
_('Net price'),
|
||||
_('Tax value'),
|
||||
_('Tax rate'),
|
||||
_('Tax name'),
|
||||
_('Event start date'),
|
||||
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
_('E-mail address'),
|
||||
_('Invoice type'),
|
||||
_('Cancellation of'),
|
||||
_('Invoice sender:') + ' ' + _('Name'),
|
||||
_('Invoice sender:') + ' ' + _('Address'),
|
||||
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||
_('Invoice sender:') + ' ' + _('City'),
|
||||
_('Invoice sender:') + ' ' + _('Country'),
|
||||
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Company'),
|
||||
_('Invoice recipient:') + ' ' + _('Name'),
|
||||
_('Invoice recipient:') + ' ' + _('Street address'),
|
||||
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||
_('Invoice recipient:') + ' ' + _('City'),
|
||||
_('Invoice recipient:') + ' ' + _('Country'),
|
||||
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
]
|
||||
qs = InvoiceLine.objects.filter(
|
||||
invoice__event=self.event
|
||||
).order_by('invoice__full_invoice_no', 'position').select_related(
|
||||
'invoice', 'invoice__order', 'invoice__refers'
|
||||
)
|
||||
for l in qs:
|
||||
i = l.invoice
|
||||
yield [
|
||||
i.full_invoice_no,
|
||||
l.position + 1,
|
||||
l.description.replace("<br />", " - "),
|
||||
l.gross_value,
|
||||
l.net_value,
|
||||
l.tax_value,
|
||||
l.tax_rate,
|
||||
l.tax_name,
|
||||
date_format(l.event_date_from, "SHORT_DATE_FORMAT") if l.event_date_from else "",
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
i.order.code,
|
||||
i.order.email,
|
||||
_('Cancellation') if i.is_cancellation else _('Invoice'),
|
||||
i.refers.full_invoice_no if i.refers else '',
|
||||
i.invoice_from_name,
|
||||
i.invoice_from,
|
||||
i.invoice_from_zipcode,
|
||||
i.invoice_from_city,
|
||||
i.invoice_from_country,
|
||||
i.invoice_from_tax_id,
|
||||
i.invoice_from_vat_id,
|
||||
i.invoice_to_company,
|
||||
i.invoice_to_name,
|
||||
i.invoice_to_street or i.invoice_to,
|
||||
i.invoice_to_zipcode,
|
||||
i.invoice_to_city,
|
||||
i.invoice_to_country,
|
||||
i.invoice_to_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
]
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_invoices'.format(self.event.slug)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
|
||||
def register_orderlist_exporter(sender, **kwargs):
|
||||
return OrderListExporter
|
||||
@@ -276,3 +640,8 @@ def register_paymentlist_exporter(sender, **kwargs):
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
|
||||
def register_quotalist_exporter(sender, **kwargs):
|
||||
return QuotaListExporter
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_invoicedata")
|
||||
def register_invoicedata_exporter(sender, **kwargs):
|
||||
return InvoiceDataExporter
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
|
||||
import i18nfield.forms
|
||||
from django import forms
|
||||
from django.forms.models import ModelFormMetaclass
|
||||
from django.utils import six
|
||||
from django.utils.crypto import get_random_string
|
||||
from formtools.wizard.views import SessionWizardView
|
||||
from hierarkey.forms import HierarkeyForm
|
||||
|
||||
from pretix.base.models import Event
|
||||
@@ -71,3 +73,29 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
fname = '%s/%s.%s.%s' % (self.obj.slug, name, nonce, name.split('.')[-1])
|
||||
# TODO: make sure pub is always correct
|
||||
return 'pub/' + fname
|
||||
|
||||
|
||||
class PrefixForm(forms.Form):
|
||||
prefix = forms.CharField(widget=forms.HiddenInput)
|
||||
|
||||
|
||||
class SafeSessionWizardView(SessionWizardView):
|
||||
def get_prefix(self, request, *args, **kwargs):
|
||||
if hasattr(request, '_session_wizard_prefix'):
|
||||
return request._session_wizard_prefix
|
||||
prefix_form = PrefixForm(self.request.POST, prefix=super().get_prefix(request, *args, **kwargs))
|
||||
if not prefix_form.is_valid():
|
||||
request._session_wizard_prefix = get_random_string(length=24)
|
||||
else:
|
||||
request._session_wizard_prefix = prefix_form.cleaned_data['prefix']
|
||||
return request._session_wizard_prefix
|
||||
|
||||
def get_context_data(self, form, **kwargs):
|
||||
context = super().get_context_data(form=form, **kwargs)
|
||||
context['wizard']['prefix_form'] = PrefixForm(
|
||||
prefix=super().get_prefix(self.request),
|
||||
initial={
|
||||
'prefix': self.get_prefix(self.request)
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
@@ -120,7 +120,7 @@ class RegistrationForm(forms.Form):
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
if User.objects.filter(email=email).exists():
|
||||
if User.objects.filter(email__iexact=email).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_email'],
|
||||
code='duplicate_email'
|
||||
|
||||
@@ -275,10 +275,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
'internal_reference')
|
||||
'internal_reference', 'beneficiary')
|
||||
widgets = {
|
||||
'is_business': BusinessBooleanRadio,
|
||||
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
|
||||
'beneficiary': forms.Textarea(attrs={'rows': 3}),
|
||||
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'internal_reference': forms.TextInput,
|
||||
@@ -291,17 +292,18 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.event = event = kwargs.pop('event')
|
||||
self.request = kwargs.pop('request', None)
|
||||
self.validate_vat_id = kwargs.pop('validate_vat_id')
|
||||
self.all_optional = kwargs.pop('all_optional', False)
|
||||
super().__init__(*args, **kwargs)
|
||||
if not event.settings.invoice_address_vatid:
|
||||
del self.fields['vat_id']
|
||||
|
||||
if not event.settings.invoice_address_required:
|
||||
if not event.settings.invoice_address_required or self.all_optional:
|
||||
for k, f in self.fields.items():
|
||||
f.required = False
|
||||
f.widget.is_required = False
|
||||
if 'required' in f.widget.attrs:
|
||||
del f.widget.attrs['required']
|
||||
elif event.settings.invoice_address_company_required:
|
||||
elif event.settings.invoice_address_company_required and not self.all_optional:
|
||||
self.initial['is_business'] = True
|
||||
|
||||
self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True)
|
||||
@@ -314,16 +316,19 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
self.fields['name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.invoice_name_required,
|
||||
required=event.settings.invoice_name_required and not self.all_optional,
|
||||
scheme=event.settings.name_scheme,
|
||||
label=_('Name'),
|
||||
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
|
||||
)
|
||||
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required:
|
||||
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
|
||||
self.fields['name_parts'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
||||
self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1'
|
||||
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
|
||||
|
||||
if not event.settings.invoice_address_beneficiary:
|
||||
del self.fields['beneficiary']
|
||||
|
||||
def clean(self):
|
||||
data = self.cleaned_data
|
||||
if not data.get('is_business'):
|
||||
|
||||
@@ -69,7 +69,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
if User.objects.filter(Q(email=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_identifier'],
|
||||
code='duplicate_identifier',
|
||||
|
||||
@@ -90,6 +90,8 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
time_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
time_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
date_attrs.setdefault('autocomplete', 'off')
|
||||
time_attrs.setdefault('autocomplete', 'off')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from typing import Tuple
|
||||
|
||||
import bleach
|
||||
import vat_moss.exchange_rates
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import pgettext
|
||||
from django.utils.translation import pgettext, ugettext
|
||||
from PIL.Image import BICUBIC
|
||||
from reportlab.lib import pagesizes
|
||||
from reportlab.lib.enums import TA_LEFT
|
||||
@@ -31,6 +32,31 @@ from pretix.base.templatetags.money import money_filter
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NumberedCanvas(Canvas):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.font_regular = kwargs.pop('font_regular')
|
||||
super().__init__(*args, **kwargs)
|
||||
self._saved_page_states = []
|
||||
|
||||
def showPage(self):
|
||||
self._saved_page_states.append(dict(self.__dict__))
|
||||
self._startPage()
|
||||
|
||||
def save(self):
|
||||
num_pages = len(self._saved_page_states)
|
||||
for state in self._saved_page_states:
|
||||
self.__dict__.update(state)
|
||||
self.draw_page_number(num_pages)
|
||||
Canvas.showPage(self)
|
||||
Canvas.save(self)
|
||||
|
||||
def draw_page_number(self, page_count):
|
||||
self.saveState()
|
||||
self.setFont(self.font_regular, 8)
|
||||
self.drawRightString(self._pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d of %d") % (self._pageNumber, page_count,))
|
||||
self.restoreState()
|
||||
|
||||
|
||||
class BaseInvoiceRenderer:
|
||||
"""
|
||||
This is the base class for all invoice renderers.
|
||||
@@ -79,6 +105,9 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
top_margin = 20 * mm
|
||||
bottom_margin = 15 * mm
|
||||
doc_template_class = BaseDocTemplate
|
||||
canvas_class = Canvas
|
||||
font_regular = 'OpenSans'
|
||||
font_bold = 'OpenSansBd'
|
||||
|
||||
def _init(self):
|
||||
"""
|
||||
@@ -92,10 +121,10 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles.
|
||||
"""
|
||||
stylesheet = StyleSheet1()
|
||||
stylesheet.add(ParagraphStyle(name='Normal', fontName='OpenSans', fontSize=10, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName='OpenSansBd', fontSize=15, leading=15 * 1.2))
|
||||
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName='OpenSansBd', fontSize=8, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Fineprint', fontName='OpenSans', fontSize=8, leading=10))
|
||||
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2))
|
||||
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10))
|
||||
return stylesheet
|
||||
|
||||
def _register_fonts(self):
|
||||
@@ -171,7 +200,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
)
|
||||
])
|
||||
story = self._get_story(doc)
|
||||
doc.build(story)
|
||||
doc.build(story, canvasmaker=self.canvas_class)
|
||||
return doc
|
||||
|
||||
def generate(self, invoice: Invoice):
|
||||
@@ -206,10 +235,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
identifier = 'classic'
|
||||
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
|
||||
|
||||
def canvas_class(self, *args, **kwargs):
|
||||
kwargs['font_regular'] = self.font_regular
|
||||
return NumberedCanvas(*args, **kwargs)
|
||||
|
||||
def _on_other_page(self, canvas: Canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
|
||||
canvas.setFont(self.font_regular, 8)
|
||||
|
||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
@@ -233,72 +265,78 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
|
||||
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
|
||||
canvas.setFont(self.font_regular, 8)
|
||||
|
||||
if self.invoice.order.testmode:
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSansBd', 30)
|
||||
canvas.setFillColorRGB(32, 0, 0)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, ugettext('TEST MODE'))
|
||||
canvas.restoreState()
|
||||
|
||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
self._draw_invoice_from(canvas)
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
self._draw_invoice_to(canvas)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.order.full_code)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
if self.invoice.is_cancellation:
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.refers.number)
|
||||
else:
|
||||
textobject.textLine(pgettext('invoice', 'Invoice number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
if self.invoice.is_cancellation:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.refers.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
else:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
@@ -349,7 +387,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Event').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
@@ -390,6 +428,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.invoice_to_beneficiary:
|
||||
story.append(Paragraph(
|
||||
pgettext('invoice', 'Beneficiary') + ':<br />' +
|
||||
bleach.clean(self.invoice.invoice_to_beneficiary, tags=[]).replace("\n", "<br />\n"),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.introductory_text:
|
||||
story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal']))
|
||||
story.append(Spacer(1, 10 * mm))
|
||||
@@ -400,8 +445,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
tstyledata = [
|
||||
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'),
|
||||
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
|
||||
('FONTNAME', (0, 0), (-1, 0), self.font_bold),
|
||||
('FONTNAME', (0, -1), (-1, -1), self.font_bold),
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
]
|
||||
@@ -469,7 +514,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||
('FONTNAME', (0, 0), (-1, -1), 'OpenSans'),
|
||||
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
|
||||
]
|
||||
thead = [
|
||||
pgettext('invoice', 'Tax rate'),
|
||||
|
||||
@@ -129,13 +129,22 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
if _supported is None:
|
||||
_supported = OrderedDict(settings.LANGUAGES)
|
||||
|
||||
return (
|
||||
get_language_from_user_settings(request)
|
||||
or get_language_from_session_or_cookie(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
or get_default_language()
|
||||
)
|
||||
if request.path.startswith(get_script_prefix() + 'control'):
|
||||
return (
|
||||
get_language_from_user_settings(request)
|
||||
or get_language_from_session_or_cookie(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
or get_default_language()
|
||||
)
|
||||
else:
|
||||
return (
|
||||
get_language_from_session_or_cookie(request)
|
||||
or get_language_from_user_settings(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
or get_default_language()
|
||||
)
|
||||
|
||||
|
||||
def _parse_csp(header):
|
||||
@@ -189,6 +198,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"],
|
||||
'font-src': ["{static}"],
|
||||
'media-src': ["{static}", "data:"],
|
||||
# form-action is not only used to match on form actions, but also on URLs
|
||||
# form-actions redirect to. In the context of e.g. payment providers or
|
||||
# single-sign-on this can be nearly anything so we cannot really restrict
|
||||
|
||||
62
src/pretix/base/migrations/0104_auto_20181114_1526.py
Normal file
62
src/pretix/base/migrations/0104_auto_20181114_1526.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 2.1.1 on 2018-11-14 15:26
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.manager
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def change_refunded_to_canceled(apps, schema_editor):
|
||||
Order = apps.get_model('pretixbase', 'Order')
|
||||
Order.objects.filter(status='r').update(status='c')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0103_auto_20181121_1224'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='orderposition',
|
||||
managers=[
|
||||
('all', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='canceled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderfee',
|
||||
name='fee_type',
|
||||
field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('cancellation', 'Cancellation fee'), ('other', 'Other fees'), ('giftcard', 'Gift card')], max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_positions',
|
||||
to='pretixbase.Order', verbose_name='Order'),
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='orderfee',
|
||||
managers=[
|
||||
('all', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderfee',
|
||||
name='canceled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderfee',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_fees', to='pretixbase.Order', verbose_name='Order'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
change_refunded_to_canceled, migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
27
src/pretix/base/migrations/0105_auto_20190112_1512.py
Normal file
27
src/pretix/base/migrations/0105_auto_20190112_1512.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.1 on 2019-01-12 15:12
|
||||
|
||||
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', '0104_auto_20181114_1526'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='beneficiary',
|
||||
field=models.TextField(blank=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_beneficiary',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 2.1.5 on 2019-02-04 13:02
|
||||
|
||||
import django.db.migrations.operations.special
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def enable_notifications_for_everyone(apps, schema_editor):
|
||||
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
|
||||
User = apps.get_model('pretixbase', 'User')
|
||||
create = []
|
||||
for u in User.objects.iterator():
|
||||
create.append(NotificationSetting(
|
||||
user=u,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
event=None,
|
||||
method='mail',
|
||||
enabled=True
|
||||
))
|
||||
if len(create) > 200:
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
replaces = [('pretixbase', '0105_auto_20190112_1512'), ('pretixbase', '0106_auto_20190118_1527'),
|
||||
('pretixbase', '0107_auto_20190129_1337')]
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0104_auto_20181114_1526'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='beneficiary',
|
||||
field=models.TextField(blank=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_beneficiary',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=enable_notifications_for_everyone,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Date'),
|
||||
),
|
||||
]
|
||||
31
src/pretix/base/migrations/0106_auto_20190118_1527.py
Normal file
31
src/pretix/base/migrations/0106_auto_20190118_1527.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 2.1.5 on 2019-01-18 15:27
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def enable_notifications_for_everyone(apps, schema_editor):
|
||||
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
|
||||
User = apps.get_model('pretixbase', 'User')
|
||||
create = []
|
||||
for u in User.objects.iterator():
|
||||
create.append(NotificationSetting(
|
||||
user=u,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
event=None,
|
||||
method='mail',
|
||||
enabled=True
|
||||
))
|
||||
if len(create) > 200:
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0105_auto_20190112_1512'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(enable_notifications_for_everyone, migrations.RunPython.noop)
|
||||
]
|
||||
22
src/pretix/base/migrations/0107_auto_20190129_1337.py
Normal file
22
src/pretix/base/migrations/0107_auto_20190129_1337.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.1.5 on 2019-01-29 13:37
|
||||
|
||||
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', '0106_auto_20190118_1527'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Date'),
|
||||
),
|
||||
]
|
||||
22
src/pretix/base/migrations/0108_auto_20190201_1527.py
Normal file
22
src/pretix/base/migrations/0108_auto_20190201_1527.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.1.5 on 2019-02-01 15:27
|
||||
|
||||
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', '0107_auto_20190129_1337'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='generate_tickets',
|
||||
field=models.NullBooleanField(verbose_name='Allow ticket download'),
|
||||
),
|
||||
]
|
||||
27
src/pretix/base/migrations/0109_auto_20190208_1432.py
Normal file
27
src/pretix/base/migrations/0109_auto_20190208_1432.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.1 on 2019-02-08 14:32
|
||||
|
||||
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', '0108_auto_20190201_1527'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceline',
|
||||
name='event_date_from',
|
||||
field=models.DateTimeField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoiceline',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
|
||||
),
|
||||
]
|
||||
23
src/pretix/base/migrations/0110_auto_20190219_1245.py
Normal file
23
src/pretix/base/migrations/0110_auto_20190219_1245.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.1.5 on 2019-02-19 12:45
|
||||
|
||||
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', '0109_auto_20190208_1432'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='plugins',
|
||||
field=models.TextField(blank=True, default='', verbose_name='Plugins'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
27
src/pretix/base/migrations/0111_auto_20190219_0949.py
Normal file
27
src/pretix/base/migrations/0111_auto_20190219_0949.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.1.5 on 2019-02-19 09:49
|
||||
|
||||
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', '0110_auto_20190219_1245'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='testmode',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='testmode',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -114,7 +114,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email = self.email.lower()
|
||||
is_new = not self.pk
|
||||
super().save(*args, **kwargs)
|
||||
if is_new:
|
||||
self.notification_settings.create(
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
event=None,
|
||||
method='mail',
|
||||
enabled=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
@@ -76,8 +76,17 @@ class LoggingMixin:
|
||||
kwargs['api_token'] = api_token
|
||||
|
||||
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, **kwargs)
|
||||
if data:
|
||||
if isinstance(data, dict):
|
||||
sensitivekeys = ['password', 'secret', 'api_key']
|
||||
|
||||
for sensitivekey in sensitivekeys:
|
||||
for k, v in data.items():
|
||||
if (sensitivekey in k) and v:
|
||||
data[k] = "********"
|
||||
|
||||
logentry.data = json.dumps(data, cls=CustomJSONEncoder)
|
||||
elif data:
|
||||
raise TypeError("You should only supply dictionaries as log data.")
|
||||
if save:
|
||||
logentry.save()
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ class CheckinList(LoggedModel):
|
||||
'order have not been paid. This only works with pretixdesk '
|
||||
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
|
||||
|
||||
class Meta:
|
||||
ordering = ('subevent__date_from', 'name')
|
||||
|
||||
@staticmethod
|
||||
def annotate_with_numbers(qs, event):
|
||||
"""
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
@@ -22,6 +22,7 @@ from i18nfield.fields import I18nCharField, I18nTextField
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.validators import EventSlugBlacklistValidator
|
||||
from pretix.helpers.database import GroupConcat
|
||||
from pretix.helpers.daterange import daterange
|
||||
from pretix.helpers.json import safe_string
|
||||
|
||||
@@ -159,6 +160,79 @@ class EventMixin:
|
||||
|
||||
return safe_string(json.dumps(eventdict))
|
||||
|
||||
@classmethod
|
||||
def annotated(cls, qs, channel='web'):
|
||||
from pretix.base.models import Item, ItemVariation, Quota
|
||||
|
||||
sq_active_item = Item.objects.filter_available(channel=channel).filter(
|
||||
Q(variations__isnull=True)
|
||||
& Q(quotas__pk=OuterRef('pk'))
|
||||
).order_by().values_list('quotas__pk').annotate(
|
||||
items=GroupConcat('pk', delimiter=',')
|
||||
).values('items')
|
||||
sq_active_variation = ItemVariation.objects.filter(
|
||||
Q(active=True)
|
||||
& Q(item__active=True)
|
||||
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
||||
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
|
||||
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
|
||||
& Q(item__sales_channels__contains=channel)
|
||||
& Q(item__hide_without_voucher=False) # TODO: does this make sense?
|
||||
& Q(quotas__pk=OuterRef('pk'))
|
||||
).order_by().values_list('quotas__pk').annotate(
|
||||
items=GroupConcat('pk', delimiter=',')
|
||||
).values('items')
|
||||
return qs.prefetch_related(
|
||||
Prefetch(
|
||||
'quotas',
|
||||
to_attr='active_quotas',
|
||||
queryset=Quota.objects.annotate(
|
||||
active_items=Subquery(sq_active_item, output_field=models.TextField()),
|
||||
active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
|
||||
).exclude(
|
||||
Q(active_items="") & Q(active_variations="")
|
||||
).select_related('event', 'subevent')
|
||||
)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def best_availability_state(self):
|
||||
from .items import Quota
|
||||
|
||||
if not hasattr(self, 'active_quotas'):
|
||||
raise TypeError("Call this only if you fetched the subevents via Event/SubEvent.annotated()")
|
||||
items_available = set()
|
||||
vars_available = set()
|
||||
items_reserved = set()
|
||||
vars_reserved = set()
|
||||
items_gone = set()
|
||||
vars_gone = set()
|
||||
for q in self.active_quotas:
|
||||
res = q.availability(allow_cache=True)
|
||||
|
||||
if res[0] == Quota.AVAILABILITY_OK:
|
||||
if q.active_items:
|
||||
items_available.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_available.update(q.active_variations.split(","))
|
||||
elif res[0] == Quota.AVAILABILITY_RESERVED:
|
||||
if q.active_items:
|
||||
items_reserved.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_available.update(q.active_variations.split(","))
|
||||
elif res[0] < Quota.AVAILABILITY_RESERVED:
|
||||
if q.active_items:
|
||||
items_gone.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_gone.update(q.active_variations.split(","))
|
||||
if not self.active_quotas:
|
||||
return None
|
||||
if items_available - items_reserved - items_gone or vars_available - vars_reserved - vars_gone:
|
||||
return Quota.AVAILABILITY_OK
|
||||
if items_reserved - items_gone or vars_reserved - vars_gone:
|
||||
return Quota.AVAILABILITY_RESERVED
|
||||
return Quota.AVAILABILITY_GONE
|
||||
|
||||
|
||||
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
||||
class Event(EventMixin, LoggedModel):
|
||||
@@ -168,6 +242,8 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
:param organizer: The organizer this event belongs to
|
||||
:type organizer: Organizer
|
||||
:param testmode: This event is in test mode
|
||||
:type testmode: bool
|
||||
:param name: This event's full title
|
||||
:type name: str
|
||||
:param slug: A short, alphanumeric, all-lowercase name for use in URLs. The slug has to
|
||||
@@ -197,6 +273,7 @@ class Event(EventMixin, LoggedModel):
|
||||
settings_namespace = 'event'
|
||||
CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES]
|
||||
organizer = models.ForeignKey(Organizer, related_name="events", on_delete=models.PROTECT)
|
||||
testmode = models.BooleanField(default=False)
|
||||
name = I18nCharField(
|
||||
max_length=200,
|
||||
verbose_name=_("Event name"),
|
||||
@@ -247,7 +324,7 @@ class Event(EventMixin, LoggedModel):
|
||||
verbose_name=_("Location"),
|
||||
)
|
||||
plugins = models.TextField(
|
||||
null=True, blank=True,
|
||||
null=False, blank=True,
|
||||
verbose_name=_("Plugins"),
|
||||
)
|
||||
comment = models.TextField(
|
||||
@@ -281,10 +358,11 @@ class Event(EventMixin, LoggedModel):
|
||||
if not really:
|
||||
raise TypeError("Pass really=True as a parameter.")
|
||||
|
||||
OrderPosition.objects.filter(order__event=self).delete()
|
||||
OrderPosition.all.filter(order__event=self, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order__event=self).delete()
|
||||
OrderFee.objects.filter(order__event=self).delete()
|
||||
OrderPayment.objects.filter(order__event=self).delete()
|
||||
OrderRefund.objects.filter(order__event=self).delete()
|
||||
OrderPayment.objects.filter(order__event=self).delete()
|
||||
self.orders.all().delete()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -468,6 +546,7 @@ class Event(EventMixin, LoggedModel):
|
||||
else:
|
||||
s.save()
|
||||
|
||||
self.settings.flush()
|
||||
event_copy_data.send(
|
||||
sender=self, other=other,
|
||||
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
|
||||
@@ -572,8 +651,10 @@ class Event(EventMixin, LoggedModel):
|
||||
)
|
||||
).order_by('date_from', 'name')
|
||||
|
||||
@property
|
||||
def subevent_list_subevents(self):
|
||||
def subevents_annotated(self, channel):
|
||||
return SubEvent.annotated(self.subevents, channel)
|
||||
|
||||
def subevents_sorted(self, queryset):
|
||||
ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
|
||||
orderfields = {
|
||||
'date_ascending': ('date_from', 'name'),
|
||||
@@ -581,7 +662,7 @@ class Event(EventMixin, LoggedModel):
|
||||
'name_ascending': ('name', 'date_from'),
|
||||
'name_descending': ('-name', 'date_from'),
|
||||
}[ordering]
|
||||
subevs = self.subevents.filter(
|
||||
subevs = queryset.filter(
|
||||
Q(active=True) & (
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(date_to__gte=now())
|
||||
@@ -674,6 +755,7 @@ class Event(EventMixin, LoggedModel):
|
||||
return not self.orders.exists() and not self.invoices.exists()
|
||||
|
||||
def delete_sub_objects(self):
|
||||
self.cartposition_set.all().delete()
|
||||
self.items.all().delete()
|
||||
self.subevents.all().delete()
|
||||
|
||||
@@ -682,7 +764,7 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
plugins_active = self.get_plugins()
|
||||
plugins_available = {
|
||||
p.module: p for p in get_all_plugins()
|
||||
p.module: p for p in get_all_plugins(self)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ class Invoice(models.Model):
|
||||
invoice_to_city = models.TextField(null=True)
|
||||
invoice_to_country = CountryField(null=True)
|
||||
invoice_to_vat_id = models.TextField(null=True)
|
||||
invoice_to_beneficiary = models.TextField(null=True)
|
||||
date = models.DateField(default=today)
|
||||
locale = models.CharField(max_length=50, default='en')
|
||||
introductory_text = models.TextField(blank=True)
|
||||
@@ -117,8 +118,8 @@ class Invoice(models.Model):
|
||||
self.invoice_from,
|
||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
||||
str(self.invoice_from_country),
|
||||
pgettext("invoice", "VAT-ID: %s" % self.invoice_from_vat_id) if self.invoice_from_vat_id else "",
|
||||
pgettext("invoice", "Tax ID: %s" % self.invoice_from_tax_id) if self.invoice_from_tax_id else "",
|
||||
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
|
||||
pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id if self.invoice_from_tax_id else "",
|
||||
]
|
||||
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||
|
||||
@@ -149,6 +150,8 @@ class Invoice(models.Model):
|
||||
if not self.prefix:
|
||||
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
|
||||
if not self.invoice_no:
|
||||
if self.order.testmode:
|
||||
self.prefix += 'TEST-'
|
||||
for i in range(10):
|
||||
if self.event.settings.get('invoice_numbers_consecutive'):
|
||||
self.invoice_no = self._get_numeric_invoice_number()
|
||||
@@ -191,6 +194,9 @@ class Invoice(models.Model):
|
||||
unique_together = ('organizer', 'prefix', 'invoice_no')
|
||||
ordering = ('date', 'invoice_no',)
|
||||
|
||||
def __repr__(self):
|
||||
return '<Invoice {} / {}>'.format(self.full_invoice_no, self.pk)
|
||||
|
||||
|
||||
class InvoiceLine(models.Model):
|
||||
"""
|
||||
@@ -208,6 +214,10 @@ class InvoiceLine(models.Model):
|
||||
:type tax_rate: decimal.Decimal
|
||||
:param tax_name: The name of the applied tax rate
|
||||
:type tax_name: str
|
||||
:param subevent: The subevent this line refers to
|
||||
:type subevent: SubEvent
|
||||
:param event_date_from: Event date of the (sub)event at the time the invoice was created
|
||||
:type event_date_from: datetime
|
||||
"""
|
||||
invoice = models.ForeignKey('Invoice', related_name='lines', on_delete=models.CASCADE)
|
||||
position = models.PositiveIntegerField(default=0)
|
||||
@@ -216,6 +226,8 @@ class InvoiceLine(models.Model):
|
||||
tax_value = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
||||
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
|
||||
tax_name = models.CharField(max_length=190)
|
||||
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
|
||||
event_date_from = models.DateTimeField(null=True)
|
||||
|
||||
@property
|
||||
def net_value(self):
|
||||
@@ -223,3 +235,6 @@ class InvoiceLine(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ('position', 'pk')
|
||||
|
||||
def __str__(self):
|
||||
return 'Line {} of invoice {}'.format(self.position, self.invoice)
|
||||
|
||||
@@ -153,6 +153,30 @@ class SubEventItemVariation(models.Model):
|
||||
self.subevent.event.cache.clear()
|
||||
|
||||
|
||||
class ItemQuerySet(models.QuerySet):
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False):
|
||||
q = (
|
||||
# IMPORTANT: If this is updated, also update the ItemVariation query
|
||||
# in models/event.py: EventMixin.annotated()
|
||||
Q(active=True)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(sales_channels__contains=channel)
|
||||
)
|
||||
if not allow_addons:
|
||||
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
|
||||
qs = self.filter(q)
|
||||
|
||||
vouchq = Q(hide_without_voucher=False)
|
||||
if voucher:
|
||||
if voucher.item_id:
|
||||
vouchq |= Q(pk=voucher.item_id)
|
||||
qs = qs.filter(pk=voucher.item_id)
|
||||
elif voucher.quota_id:
|
||||
qs = qs.filter(quotas__in=[voucher.quota_id])
|
||||
return qs.filter(vouchq)
|
||||
|
||||
|
||||
class Item(LoggedModel):
|
||||
"""
|
||||
An item is a thing which can be sold. It belongs to an event and may or may not belong to a category.
|
||||
@@ -200,6 +224,8 @@ class Item(LoggedModel):
|
||||
:type sales_channels: bool
|
||||
"""
|
||||
|
||||
objects = ItemQuerySet.as_manager()
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.PROTECT,
|
||||
@@ -261,6 +287,10 @@ class Item(LoggedModel):
|
||||
),
|
||||
default=False
|
||||
)
|
||||
generate_tickets = models.NullBooleanField(
|
||||
verbose_name=_("Generate tickets"),
|
||||
blank=True, null=True,
|
||||
)
|
||||
position = models.IntegerField(
|
||||
default=0
|
||||
)
|
||||
@@ -301,9 +331,8 @@ class Item(LoggedModel):
|
||||
allow_cancel = models.BooleanField(
|
||||
verbose_name=_('Allow product to be canceled'),
|
||||
default=True,
|
||||
help_text=_('If this is active and the general event settings allow it, orders containing this product can be '
|
||||
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own '
|
||||
'and you can cancel orders at all times, regardless of this setting')
|
||||
help_text=_('If this is checked, the usual cancellation settings of this event apply. If this is unchecked, '
|
||||
'orders containing this product can not be canceled by users but only by you.')
|
||||
)
|
||||
min_per_order = models.IntegerField(
|
||||
verbose_name=_('Minimum amount per order'),
|
||||
@@ -416,7 +445,7 @@ class Item(LoggedModel):
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import OrderPosition
|
||||
|
||||
return not OrderPosition.objects.filter(item=self).exists()
|
||||
return not OrderPosition.all.filter(item=self).exists()
|
||||
|
||||
@cached_property
|
||||
def has_variations(self):
|
||||
@@ -930,6 +959,16 @@ class Quota(LoggedModel):
|
||||
:type size: int
|
||||
:param items: The set of :py:class:`Item` objects this quota applies to
|
||||
:param variations: The set of :py:class:`ItemVariation` objects this quota applies to
|
||||
|
||||
This model keeps a cache of the quota availability that is used in places where up-to-date
|
||||
data is not important. This cache might be out of date even though a more recent quota was
|
||||
calculated. This is intentional to keep database writes low. Currently, the cached values
|
||||
are written whenever the quota is being calculated throughout the system and the cache is
|
||||
at least 120 seconds old or if the new value is qualitatively "better" than the cached one
|
||||
(i.e. more free quota).
|
||||
|
||||
There's also a cronjob that refreshes the cache of every quota if there is any log entry in
|
||||
the event that is newer than the quota's cached time.
|
||||
"""
|
||||
|
||||
AVAILABILITY_GONE = 0
|
||||
@@ -1012,6 +1051,15 @@ class Quota(LoggedModel):
|
||||
This method is used to determine whether Items or ItemVariations belonging
|
||||
to this quota should currently be available for sale.
|
||||
|
||||
:param count_waitinglist: Whether or not take waiting list reservations into account. Defaults
|
||||
to ``True``.
|
||||
:param _cache: A dictionary mapping quota IDs to availabilities. If this quota is already
|
||||
contained in that dictionary, this value will be used. Otherwise, the dict
|
||||
will be populated accordingly.
|
||||
:param allow_cache: Allow for values to be returned from the longer-term cache, see also
|
||||
the documentation of this model class. Only works if ``count_waitinglist`` is
|
||||
set to ``True``.
|
||||
|
||||
:returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants
|
||||
and the second is the number of available tickets.
|
||||
"""
|
||||
@@ -1027,7 +1075,10 @@ class Quota(LoggedModel):
|
||||
res = self._availability(now_dt, count_waitinglist)
|
||||
|
||||
self.event.cache.delete('item_quota_cache')
|
||||
if count_waitinglist and not self.cache_is_hot(now_dt):
|
||||
rewrite_cache = count_waitinglist and (
|
||||
not self.cache_is_hot(now_dt) or res[0] > self.cached_availability_state
|
||||
)
|
||||
if rewrite_cache:
|
||||
self.cached_availability_state = res[0]
|
||||
self.cached_availability_number = res[1]
|
||||
self.cached_availability_time = now_dt
|
||||
@@ -1064,16 +1115,16 @@ class Quota(LoggedModel):
|
||||
|
||||
size_left -= self.count_blocking_vouchers(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
size_left -= self.count_in_cart(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
|
||||
if count_waitinglist:
|
||||
size_left -= self.count_waiting_list_pending()
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
|
||||
size_left -= self.count_in_cart(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
return Quota.AVAILABILITY_OK, size_left
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from django_countries.fields import CountryField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
@@ -70,12 +71,13 @@ class Order(LockModel, LoggedModel):
|
||||
* ``STATUS_PAID``
|
||||
* ``STATUS_EXPIRED``
|
||||
* ``STATUS_CANCELED``
|
||||
* ``STATUS_REFUNDED``
|
||||
|
||||
:param event: The event this order belongs to
|
||||
:type event: Event
|
||||
:param email: The email of the person who ordered this
|
||||
:type email: str
|
||||
:param testmode: Whether this is a test mode order
|
||||
:type testmode: bool
|
||||
:param locale: The locale of this order
|
||||
:type locale: str
|
||||
:param secret: A secret string that is required to modify the order
|
||||
@@ -102,13 +104,12 @@ class Order(LockModel, LoggedModel):
|
||||
STATUS_PAID = "p"
|
||||
STATUS_EXPIRED = "e"
|
||||
STATUS_CANCELED = "c"
|
||||
STATUS_REFUNDED = "r"
|
||||
STATUS_REFUNDED = "c" # deprecated
|
||||
STATUS_CHOICE = (
|
||||
(STATUS_PENDING, _("pending")),
|
||||
(STATUS_PAID, _("paid")),
|
||||
(STATUS_EXPIRED, _("expired")),
|
||||
(STATUS_CANCELED, _("canceled")),
|
||||
(STATUS_REFUNDED, _("refunded"))
|
||||
)
|
||||
|
||||
code = models.CharField(
|
||||
@@ -122,6 +123,7 @@ class Order(LockModel, LoggedModel):
|
||||
verbose_name=_("Status"),
|
||||
db_index=True
|
||||
)
|
||||
testmode = models.BooleanField(default=False)
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
verbose_name=_("Event"),
|
||||
@@ -138,7 +140,7 @@ class Order(LockModel, LoggedModel):
|
||||
)
|
||||
secret = models.CharField(max_length=32, default=generate_secret)
|
||||
datetime = models.DateTimeField(
|
||||
verbose_name=_("Date")
|
||||
verbose_name=_("Date"), db_index=True
|
||||
)
|
||||
expires = models.DateTimeField(
|
||||
verbose_name=_("Expiration date")
|
||||
@@ -186,6 +188,45 @@ class Order(LockModel, LoggedModel):
|
||||
def __str__(self):
|
||||
return self.full_code
|
||||
|
||||
def gracefully_delete(self, user=None, auth=None):
|
||||
if not self.testmode:
|
||||
raise TypeError("Only test mode orders can be deleted.")
|
||||
self.event.log_action(
|
||||
'pretix.event.order.deleted', user=user, auth=auth,
|
||||
data={
|
||||
'code': self.code,
|
||||
}
|
||||
)
|
||||
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order=self).delete()
|
||||
OrderFee.all.filter(order=self).delete()
|
||||
self.refunds.all().delete()
|
||||
self.payments.all().delete()
|
||||
self.event.cache.delete('complain_testmode_orders')
|
||||
self.delete()
|
||||
|
||||
@property
|
||||
def fees(self):
|
||||
"""
|
||||
Related manager for all non-canceled fees. Use ``all_fees`` instead if you want
|
||||
canceled positions as well.
|
||||
"""
|
||||
return self.all_fees(manager='objects')
|
||||
|
||||
@cached_property
|
||||
def count_positions(self):
|
||||
if hasattr(self, 'pcnt'):
|
||||
return self.pcnt or 0
|
||||
return self.positions.count()
|
||||
|
||||
@property
|
||||
def positions(self):
|
||||
"""
|
||||
Related manager for all non-canceled positions. Use ``all_positions`` instead if you want
|
||||
canceled positions as well.
|
||||
"""
|
||||
return self.all_positions(manager='objects')
|
||||
|
||||
@cached_property
|
||||
def meta_info_data(self):
|
||||
try:
|
||||
@@ -207,8 +248,8 @@ class Order(LockModel, LoggedModel):
|
||||
@property
|
||||
def pending_sum(self):
|
||||
total = self.total
|
||||
if self.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
|
||||
total = 0
|
||||
if self.status == Order.STATUS_CANCELED:
|
||||
total = Decimal('0.00')
|
||||
payment_sum = self.payments.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
|
||||
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||
@@ -219,7 +260,7 @@ class Order(LockModel, LoggedModel):
|
||||
return total - payment_sum + refund_sum
|
||||
|
||||
@classmethod
|
||||
def annotate_overpayments(cls, qs):
|
||||
def annotate_overpayments(cls, qs, results=True, refunds=True, sums=False):
|
||||
payment_sum = OrderPayment.objects.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
order=OuterRef('pk')
|
||||
@@ -237,38 +278,47 @@ class Order(LockModel, LoggedModel):
|
||||
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
|
||||
order=OuterRef('pk')
|
||||
)
|
||||
payment_sum_sq = Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
|
||||
refund_sum_sq = Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
|
||||
if sums:
|
||||
qs = qs.annotate(
|
||||
payment_sum=payment_sum_sq,
|
||||
refund_sum=refund_sum_sq,
|
||||
)
|
||||
|
||||
qs = qs.annotate(
|
||||
payment_sum=Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
refund_sum=Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
has_external_refund=Exists(external_refund),
|
||||
has_pending_refund=Exists(pending_refund),
|
||||
).annotate(
|
||||
pending_sum_t=F('total') - Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
|
||||
pending_sum_rc=-1 * Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
|
||||
).annotate(
|
||||
is_overpaid=Case(
|
||||
When(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0),
|
||||
then=Value('1')),
|
||||
When(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_pending_with_full_payment=Case(
|
||||
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
||||
& Q(require_approval=False),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_underpaid=Case(
|
||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=0),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
)
|
||||
pending_sum_t=F('total') - Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
|
||||
pending_sum_rc=-1 * Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
|
||||
)
|
||||
if refunds:
|
||||
qs = qs.annotate(
|
||||
has_external_refund=Exists(external_refund),
|
||||
has_pending_refund=Exists(pending_refund),
|
||||
)
|
||||
if results:
|
||||
qs = qs.annotate(
|
||||
is_overpaid=Case(
|
||||
When(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=-1e-8),
|
||||
then=Value('1')),
|
||||
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=-1e-8),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_pending_with_full_payment=Case(
|
||||
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=1e-8)
|
||||
& Q(require_approval=False),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_underpaid=Case(
|
||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
)
|
||||
)
|
||||
return qs
|
||||
|
||||
@property
|
||||
@@ -336,10 +386,112 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
def cancel_allowed(self):
|
||||
return (
|
||||
self.status == Order.STATUS_PENDING
|
||||
or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00'))
|
||||
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.count_positions
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def user_cancel_deadline(self):
|
||||
if self.status == Order.STATUS_PAID and self.total != Decimal('0.00'):
|
||||
until = self.event.settings.get('cancel_allow_user_paid_until', as_type=RelativeDateWrapper)
|
||||
else:
|
||||
until = self.event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper)
|
||||
if until:
|
||||
if self.event.has_subevents:
|
||||
return min([
|
||||
until.datetime(se)
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
])
|
||||
else:
|
||||
return until.datetime(self.event)
|
||||
|
||||
@cached_property
|
||||
def user_cancel_fee(self):
|
||||
fee = Decimal('0.00')
|
||||
if self.event.settings.cancel_allow_user_paid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
if self.event.settings.cancel_allow_user_paid_keep_percentage:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * self.total
|
||||
if self.event.settings.cancel_allow_user_paid_keep_fees:
|
||||
fee += self.fees.filter(
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE)
|
||||
).aggregate(
|
||||
s=Sum('value')
|
||||
)['s'] or 0
|
||||
return round_decimal(fee, self.event.currency)
|
||||
|
||||
@property
|
||||
def user_cancel_allowed(self) -> bool:
|
||||
"""
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
"""
|
||||
from .checkin import Checkin
|
||||
|
||||
positions = list(
|
||||
self.positions.all().annotate(
|
||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
|
||||
).select_related('item')
|
||||
)
|
||||
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
|
||||
if not cancelable or not positions:
|
||||
return False
|
||||
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||
return False
|
||||
if self.status == Order.STATUS_PENDING:
|
||||
return self.event.settings.cancel_allow_user
|
||||
elif self.status == Order.STATUS_PAID:
|
||||
if self.total == Decimal('0.00'):
|
||||
return self.event.settings.cancel_allow_user
|
||||
return self.event.settings.cancel_allow_user_paid
|
||||
return False
|
||||
|
||||
def propose_auto_refunds(self, amount: Decimal, payments: list=None):
|
||||
# Algorithm to choose which payments are to be refunded to create the least hassle
|
||||
payments = payments or self.payments.filter(state=OrderPayment.PAYMENT_STATE_CONFIRMED)
|
||||
for p in payments:
|
||||
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
|
||||
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
|
||||
p.propose_refund = Decimal('0.00')
|
||||
p.available_amount = p.amount - p.refunded_amount
|
||||
|
||||
unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible)
|
||||
to_refund = amount
|
||||
proposals = {}
|
||||
|
||||
while to_refund and unused_payments:
|
||||
bigger = sorted([
|
||||
p for p in unused_payments
|
||||
if p.available_amount > to_refund
|
||||
and p.partial_refund_possible
|
||||
], key=lambda p: p.available_amount)
|
||||
same = [
|
||||
p for p in unused_payments
|
||||
if p.available_amount == to_refund
|
||||
and (p.full_refund_possible or p.partial_refund_possible)
|
||||
]
|
||||
smaller = sorted([
|
||||
p for p in unused_payments
|
||||
if p.available_amount < to_refund
|
||||
and (p.full_refund_possible or p.partial_refund_possible)
|
||||
], key=lambda p: p.available_amount, reverse=True)
|
||||
if same:
|
||||
payment = same[0]
|
||||
proposals[payment] = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
elif bigger:
|
||||
payment = bigger[0]
|
||||
proposals[payment] = to_refund
|
||||
to_refund -= to_refund
|
||||
unused_payments.remove(payment)
|
||||
elif smaller:
|
||||
payment = smaller[0]
|
||||
proposals[payment] = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
else:
|
||||
break
|
||||
return proposals
|
||||
|
||||
@staticmethod
|
||||
def normalize_code(code):
|
||||
tr = str.maketrans({
|
||||
@@ -358,6 +510,10 @@ class Order(LockModel, LoggedModel):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
while True:
|
||||
code = get_random_string(length=settings.ENTROPY['order_code'], allowed_chars=charset)
|
||||
if self.testmode:
|
||||
# Subtle way to recognize test orders while debugging: They all contain a 0 at the second place,
|
||||
# even though zeros are not used outside test mode.
|
||||
code = code[0] + "0" + code[2:]
|
||||
if not Order.objects.filter(event__organizer=self.event.organizer, code=code).exists():
|
||||
self.code = code
|
||||
return
|
||||
@@ -392,15 +548,6 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
return False # nothing there to modify
|
||||
|
||||
@property
|
||||
def can_user_cancel(self) -> bool:
|
||||
"""
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
"""
|
||||
positions = self.positions.all().select_related('item')
|
||||
cancelable = all([op.item.allow_cancel for op in positions])
|
||||
return self.cancel_allowed() and self.event.settings.cancel_allow_user and cancelable
|
||||
|
||||
@property
|
||||
def is_expired_by_time(self):
|
||||
return (
|
||||
@@ -538,6 +685,9 @@ class Order(LockModel, LoggedModel):
|
||||
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:
|
||||
@@ -563,6 +713,13 @@ class Order(LockModel, LoggedModel):
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def positions_with_tickets(self):
|
||||
for op in self.positions.all():
|
||||
if not op.generate_ticket:
|
||||
continue
|
||||
yield op
|
||||
|
||||
|
||||
def answerfile_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
|
||||
@@ -907,6 +1064,9 @@ class OrderPayment(models.Model):
|
||||
class Meta:
|
||||
ordering = ('local_id',)
|
||||
|
||||
def __str__(self):
|
||||
return self.full_id
|
||||
|
||||
@property
|
||||
def info_data(self):
|
||||
"""
|
||||
@@ -968,9 +1128,23 @@ class OrderPayment(models.Model):
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
self.state = self.PAYMENT_STATE_CONFIRMED
|
||||
self.payment_date = now()
|
||||
self.save()
|
||||
with transaction.atomic():
|
||||
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||
if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
|
||||
# Race condition detected, this payment is already confirmed
|
||||
return
|
||||
|
||||
locked_instance.state = self.PAYMENT_STATE_CONFIRMED
|
||||
locked_instance.payment_date = now()
|
||||
locked_instance.info = self.info # required for backwards compatibility
|
||||
locked_instance.save(update_fields=['state', 'payment_date', 'info'])
|
||||
|
||||
# Do a cheap manual "refresh from db" on non-complex fields
|
||||
for field in self._meta.concrete_fields:
|
||||
if not field.is_relation:
|
||||
setattr(self, field.attname, getattr(locked_instance, field.attname))
|
||||
|
||||
self.refresh_from_db()
|
||||
|
||||
self.order.log_action('pretix.event.order.payment.confirmed', {
|
||||
'local_id': self.local_id,
|
||||
@@ -1199,6 +1373,9 @@ class OrderRefund(models.Model):
|
||||
class Meta:
|
||||
ordering = ('local_id',)
|
||||
|
||||
def __str__(self):
|
||||
return self.full_id
|
||||
|
||||
@property
|
||||
def info_data(self):
|
||||
"""
|
||||
@@ -1254,11 +1431,19 @@ class OrderRefund(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ActivePositionManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(canceled=False)
|
||||
|
||||
|
||||
class OrderFee(models.Model):
|
||||
"""
|
||||
An OrderFee object represents a fee that is added to the order total independently of
|
||||
the actual positions. This might for example be a payment or a shipping fee.
|
||||
|
||||
The default ``OrderFee.objects`` manager only contains fees that are not ``canceled``. If
|
||||
you ant all objects, you need to use ``OrderFee.all`` instead.
|
||||
|
||||
:param value: Gross price of this fee
|
||||
:type value: Decimal
|
||||
:param order: Order this fee is charged with
|
||||
@@ -1275,16 +1460,20 @@ class OrderFee(models.Model):
|
||||
:type tax_rule: TaxRule
|
||||
:param tax_value: The tax amount included in the price
|
||||
:type tax_value: Decimal
|
||||
:param canceled: True, if this position is canceled and should no longer be regarded
|
||||
:type canceled: bool
|
||||
"""
|
||||
FEE_TYPE_PAYMENT = "payment"
|
||||
FEE_TYPE_SHIPPING = "shipping"
|
||||
FEE_TYPE_SERVICE = "service"
|
||||
FEE_TYPE_CANCELLATION = "cancellation"
|
||||
FEE_TYPE_OTHER = "other"
|
||||
FEE_TYPE_GIFTCARD = "giftcard"
|
||||
FEE_TYPES = (
|
||||
(FEE_TYPE_PAYMENT, _("Payment fee")),
|
||||
(FEE_TYPE_SHIPPING, _("Shipping fee")),
|
||||
(FEE_TYPE_SERVICE, _("Service fee")),
|
||||
(FEE_TYPE_CANCELLATION, _("Cancellation fee")),
|
||||
(FEE_TYPE_OTHER, _("Other fees")),
|
||||
(FEE_TYPE_GIFTCARD, _("Gift card")),
|
||||
)
|
||||
@@ -1296,7 +1485,7 @@ class OrderFee(models.Model):
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
related_name='fees',
|
||||
related_name='all_fees',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
fee_type = models.CharField(
|
||||
@@ -1317,6 +1506,10 @@ class OrderFee(models.Model):
|
||||
max_digits=10, decimal_places=2,
|
||||
verbose_name=_('Tax value')
|
||||
)
|
||||
canceled = models.BooleanField(default=False)
|
||||
|
||||
all = models.Manager()
|
||||
objects = ActivePositionManager()
|
||||
|
||||
@property
|
||||
def net_value(self):
|
||||
@@ -1371,6 +1564,9 @@ class OrderPosition(AbstractPosition):
|
||||
of a specified type (or variation). This has all properties of
|
||||
AbstractPosition.
|
||||
|
||||
The default ``OrderPosition.objects`` manager only contains fees that are not ``canceled``. If
|
||||
you ant all objects, you need to use ``OrderPosition.all`` instead.
|
||||
|
||||
:param order: The order this position is a part of
|
||||
:type order: Order
|
||||
:param positionid: A local ID of this position, counted for each order individually
|
||||
@@ -1383,6 +1579,8 @@ class OrderPosition(AbstractPosition):
|
||||
:type tax_value: Decimal
|
||||
:param secret: The secret used for ticket QR codes
|
||||
:type secret: str
|
||||
:param canceled: True, if this position is canceled and should no longer be regarded
|
||||
:type canceled: bool
|
||||
:param pseudonymization_id: The QR code content for lead scanning
|
||||
:type pseudonymization_id: str
|
||||
"""
|
||||
@@ -1390,7 +1588,7 @@ class OrderPosition(AbstractPosition):
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
related_name='positions',
|
||||
related_name='all_positions',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
@@ -1412,6 +1610,10 @@ class OrderPosition(AbstractPosition):
|
||||
unique=True,
|
||||
db_index=True
|
||||
)
|
||||
canceled = models.BooleanField(default=False)
|
||||
|
||||
all = models.Manager()
|
||||
objects = ActivePositionManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Order position")
|
||||
@@ -1422,6 +1624,15 @@ class OrderPosition(AbstractPosition):
|
||||
def sort_key(self):
|
||||
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0
|
||||
|
||||
@property
|
||||
def generate_ticket(self):
|
||||
if self.item.generate_tickets is not None:
|
||||
return self.item.generate_tickets
|
||||
return (
|
||||
(self.order.event.settings.ticket_download_addons or not self.addon_to_id) and
|
||||
(self.event.settings.ticket_download_nonadm or self.item.admission)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def transform_cart_positions(cls, cp: List, order) -> list:
|
||||
from . import Voucher
|
||||
@@ -1492,7 +1703,7 @@ class OrderPosition(AbstractPosition):
|
||||
self._calculate_tax()
|
||||
self.order.touch()
|
||||
if self.pk is None:
|
||||
while OrderPosition.objects.filter(secret=self.secret).exists():
|
||||
while OrderPosition.all.filter(secret=self.secret).exists():
|
||||
self.secret = generate_position_secret()
|
||||
|
||||
if not self.pseudonymization_id:
|
||||
@@ -1508,7 +1719,7 @@ class OrderPosition(AbstractPosition):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
while True:
|
||||
code = get_random_string(length=10, allowed_chars=charset)
|
||||
if not OrderPosition.objects.filter(pseudonymization_id=code).exists():
|
||||
if not OrderPosition.all.filter(pseudonymization_id=code).exists():
|
||||
self.pseudonymization_id = code
|
||||
return
|
||||
|
||||
@@ -1596,6 +1807,10 @@ class InvoiceAddress(models.Model):
|
||||
help_text=_('This reference will be printed on your invoice for your convenience.'),
|
||||
blank=True
|
||||
)
|
||||
beneficiary = models.TextField(
|
||||
verbose_name=_('Beneficiary'),
|
||||
blank=True
|
||||
)
|
||||
|
||||
def save(self, **kwargs):
|
||||
if self.order:
|
||||
|
||||
@@ -97,7 +97,7 @@ class TaxRule(LoggedModel):
|
||||
|
||||
return (
|
||||
not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not OrderPosition.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not self.event.items.filter(tax_rule=self).exists()
|
||||
and self.event.settings.tax_rate_default != self
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from ..decimal import round_decimal
|
||||
from .base import LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Quota
|
||||
from .orders import Order
|
||||
|
||||
|
||||
def _generate_random_code(prefix=None):
|
||||
@@ -182,7 +183,7 @@ class Voucher(LoggedModel):
|
||||
return self.code
|
||||
|
||||
def allow_delete(self):
|
||||
return self.redeemed == 0
|
||||
return self.redeemed == 0 and not self.orderposition_set.exists()
|
||||
|
||||
def clean(self):
|
||||
Voucher.clean_item_properties(
|
||||
@@ -380,3 +381,11 @@ class Voucher(LoggedModel):
|
||||
return p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
||||
return p
|
||||
return original_price
|
||||
|
||||
def distinct_orders(self):
|
||||
"""
|
||||
Return the list of orders where this voucher has been used.
|
||||
Each order will appear at most once.
|
||||
"""
|
||||
|
||||
return Order.objects.filter(all_positions__voucher__in=[self]).distinct()
|
||||
|
||||
@@ -159,7 +159,7 @@ class WaitingListEntry(LoggedModel):
|
||||
@staticmethod
|
||||
def clean_duplicate(email, item, variation, subevent, pk):
|
||||
if WaitingListEntry.objects.filter(
|
||||
item=item, variation=variation, email=email, voucher__isnull=True, subevent=subevent
|
||||
item=item, variation=variation, email__iexact=email, voucher__isnull=True, subevent=subevent
|
||||
).exclude(pk=pk).exists():
|
||||
raise ValidationError(_('You are already on this waiting list! We will notify '
|
||||
'you as soon as we have a ticket available for you.'))
|
||||
|
||||
@@ -177,6 +177,7 @@ class ParametrizedOrderNotificationType(NotificationType):
|
||||
n.add_attribute(_('Event'), order.event.name)
|
||||
n.add_attribute(_('Order code'), order.code)
|
||||
n.add_attribute(_('Order total'), money_filter(order.total, logentry.event.currency))
|
||||
n.add_attribute(_('Pending amount'), money_filter(order.pending_sum, logentry.event.currency))
|
||||
n.add_attribute(_('Order date'), date_format(order.datetime, 'SHORT_DATETIME_FORMAT'))
|
||||
n.add_attribute(_('Order status'), order.get_status_display())
|
||||
n.add_attribute(_('Order positions'), str(order.positions.count()))
|
||||
@@ -243,9 +244,9 @@ def register_default_notification_types(sender, **kwargs):
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.refunded',
|
||||
_('Order refunded'),
|
||||
_('Order {order.code} has been refunded.')
|
||||
'pretix.event.order.refund.requested',
|
||||
_('Refund requested'),
|
||||
_('You have been requested to issue a refund for {order.code}.')
|
||||
),
|
||||
ActionRequiredNotificationType(
|
||||
sender,
|
||||
|
||||
@@ -88,6 +88,18 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return self.settings.get('_enabled', as_type=bool)
|
||||
|
||||
@property
|
||||
def test_mode_message(self) -> str:
|
||||
"""
|
||||
If this property is set to a string, this will be displayed when this payment provider is selected
|
||||
while the event is in test mode. You should use it to explain to your user how your plugin behaves,
|
||||
e.g. if it falls back to a test mode automatically as well or if actual payments will be performed.
|
||||
|
||||
If you do not set this (or, return ``None``), pretix will show a default message warning the user
|
||||
that this plugin does not support test mode payments.
|
||||
"""
|
||||
return None
|
||||
|
||||
def calculate_fee(self, price: Decimal) -> Decimal:
|
||||
"""
|
||||
Calculate the fee for this payment provider which will be added to
|
||||
@@ -713,6 +725,11 @@ class ManualPayment(BasePaymentProvider):
|
||||
identifier = 'manual'
|
||||
verbose_name = _('Manual payment')
|
||||
|
||||
@property
|
||||
def test_mode_message(self):
|
||||
return _('In test mode, you can just manually mark this order as paid in the backend after it has been '
|
||||
'created.')
|
||||
|
||||
@property
|
||||
def is_implicit(self):
|
||||
return 'pretix.plugins.manualpayment' not in self.event.plugins
|
||||
@@ -788,9 +805,9 @@ class ManualPayment(BasePaymentProvider):
|
||||
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order))
|
||||
return msg
|
||||
|
||||
def order_pending_render(self, request, order) -> str:
|
||||
def payment_pending_render(self, request, payment) -> str:
|
||||
return rich_text(
|
||||
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(order))
|
||||
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(payment.order))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from PyPDF2 import PdfFileReader
|
||||
from pytz import timezone
|
||||
@@ -192,6 +193,30 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("Event organizer info text"),
|
||||
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
|
||||
}),
|
||||
("now_date", {
|
||||
"label": _("Printing date"),
|
||||
"editor_sample": _("2017-05-31"),
|
||||
"evaluate": lambda op, order, ev: date_format(
|
||||
now().astimezone(timezone(ev.settings.timezone)),
|
||||
"SHORT_DATE_FORMAT"
|
||||
)
|
||||
}),
|
||||
("now_datetime", {
|
||||
"label": _("Printing date and time"),
|
||||
"editor_sample": _("2017-05-31 19:00"),
|
||||
"evaluate": lambda op, order, ev: date_format(
|
||||
now().astimezone(timezone(ev.settings.timezone)),
|
||||
"SHORT_DATETIME_FORMAT"
|
||||
)
|
||||
}),
|
||||
("now_time", {
|
||||
"label": _("Printing time"),
|
||||
"editor_sample": _("19:00"),
|
||||
"evaluate": lambda op, order, ev: date_format(
|
||||
now().astimezone(timezone(ev.settings.timezone)),
|
||||
"TIME_FORMAT"
|
||||
) if ev.date_admission else ""
|
||||
}),
|
||||
))
|
||||
|
||||
|
||||
@@ -219,6 +244,14 @@ def variables_from_questions(sender, *args, **kwargs):
|
||||
return d
|
||||
|
||||
|
||||
def _get_attendee_name_part(key, op, order, ev):
|
||||
return op.attendee_name_parts.get(key, '')
|
||||
|
||||
|
||||
def _get_ia_name_part(key, op, order, ev):
|
||||
return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
||||
|
||||
|
||||
def get_variables(event):
|
||||
v = copy.copy(DEFAULT_VARIABLES)
|
||||
|
||||
@@ -227,7 +260,7 @@ def get_variables(event):
|
||||
v['attendee_name_%s' % key] = {
|
||||
'label': _("Attendee name: {part}").format(part=label),
|
||||
'editor_sample': scheme['sample'][key],
|
||||
'evaluate': lambda op, order, ev: op.attendee_name_parts.get(key, '')
|
||||
'evaluate': partial(_get_attendee_name_part, key)
|
||||
}
|
||||
|
||||
v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
|
||||
@@ -237,7 +270,7 @@ def get_variables(event):
|
||||
v['invoice_name_%s' % key] = {
|
||||
'label': _("Invoice address name: {part}").format(part=label),
|
||||
'editor_sample': scheme['sample'][key],
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
||||
"evaluate": partial(_get_ia_name_part, key)
|
||||
}
|
||||
|
||||
for recv, res in layout_text_variables.send(sender=event):
|
||||
|
||||
@@ -17,7 +17,7 @@ class PluginType(Enum):
|
||||
EXPORT = 4
|
||||
|
||||
|
||||
def get_all_plugins() -> List[type]:
|
||||
def get_all_plugins(event=None) -> List[type]:
|
||||
"""
|
||||
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
|
||||
"""
|
||||
@@ -29,5 +29,13 @@ def get_all_plugins() -> List[type]:
|
||||
meta.app = app
|
||||
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
|
||||
continue
|
||||
|
||||
if hasattr(app, 'is_available') and event:
|
||||
if not app.is_available(event):
|
||||
continue
|
||||
|
||||
plugins.append(meta)
|
||||
return plugins
|
||||
return sorted(
|
||||
plugins,
|
||||
key=lambda m: (0 if m.module.startswith('pretix.') else 1, str(m.name).lower().replace('pretix ', ''))
|
||||
)
|
||||
|
||||
@@ -143,10 +143,12 @@ class CartManager:
|
||||
for cp in self.positions:
|
||||
if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start:
|
||||
err = error_messages['some_subevent_not_started']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
|
||||
if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end:
|
||||
err = error_messages['some_subevent_ended']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
return err
|
||||
|
||||
@@ -369,7 +371,7 @@ class CartManager:
|
||||
# could cancel each other out quota-wise). However, we are not taking this performance
|
||||
# penalty for now as there is currently no outside interface that would allow building
|
||||
# such a transaction.
|
||||
for cp in self.positions.all():
|
||||
for cp in self.positions.filter(addon_to__isnull=True):
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
|
||||
def set_addons(self, addons):
|
||||
@@ -651,6 +653,7 @@ class CartManager:
|
||||
op.position.price = op.price.gross
|
||||
op.position.save()
|
||||
elif available_count == 0:
|
||||
op.position.addons.all().delete()
|
||||
op.position.delete()
|
||||
else:
|
||||
raise AssertionError("ExtendOperation cannot affect more than one item")
|
||||
@@ -667,15 +670,15 @@ class CartManager:
|
||||
self._check_max_cart_size()
|
||||
self._calculate_expiry()
|
||||
|
||||
with self.event.lock() as now_dt:
|
||||
with transaction.atomic():
|
||||
self.now_dt = now_dt
|
||||
self._extend_expiry_of_valid_existing_positions()
|
||||
err = self._delete_out_of_timeframe()
|
||||
err = self.extend_expired_positions() or err
|
||||
err = self._perform_operations() or err
|
||||
if err:
|
||||
raise CartError(err)
|
||||
# with self.event.lock() as now_dt:
|
||||
with transaction.atomic():
|
||||
self.now_dt = now()
|
||||
self._extend_expiry_of_valid_existing_positions()
|
||||
err = self._delete_out_of_timeframe()
|
||||
err = self.extend_expired_positions() or err
|
||||
err = self._perform_operations() or err
|
||||
if err:
|
||||
raise CartError(err)
|
||||
|
||||
|
||||
def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress):
|
||||
|
||||
@@ -78,7 +78,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
dt = datetime or now()
|
||||
|
||||
# Fetch order position with related objects
|
||||
op = OrderPosition.objects.select_related(
|
||||
op = OrderPosition.all.select_related(
|
||||
'item', 'variation', 'order', 'addon_to'
|
||||
).prefetch_related(
|
||||
'item__questions',
|
||||
@@ -90,10 +90,16 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
'answers'
|
||||
).get(pk=op.pk)
|
||||
|
||||
if op.canceled:
|
||||
raise CheckInError(
|
||||
_('This order position has been canceled.'),
|
||||
'unpaid'
|
||||
)
|
||||
|
||||
answers = {a.question: a for a in op.answers.all()}
|
||||
require_answers = []
|
||||
for q in op.item.checkin_questions:
|
||||
if q not in given_answers:
|
||||
if q not in given_answers and q not in answers:
|
||||
require_answers.append(q)
|
||||
|
||||
_save_answers(op, answers, given_answers)
|
||||
|
||||
@@ -81,6 +81,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.invoice_to_zipcode = ia.zipcode
|
||||
invoice.invoice_to_city = ia.city
|
||||
invoice.invoice_to_country = ia.country
|
||||
invoice.invoice_to_beneficiary = ia.beneficiary
|
||||
|
||||
if ia.vat_id:
|
||||
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % ia.vat_id
|
||||
@@ -141,6 +142,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
InvoiceLine.objects.create(
|
||||
position=i, invoice=invoice, description=desc,
|
||||
gross_value=p.price, tax_value=p.tax_value,
|
||||
subevent=p.subevent, event_date_from=(p.subevent.date_from if p.subevent else invoice.event.date_from),
|
||||
tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
|
||||
)
|
||||
|
||||
@@ -234,7 +236,7 @@ def generate_invoice(order: Order, trigger_pdf=True):
|
||||
if trigger_pdf:
|
||||
invoice_pdf(invoice.pk)
|
||||
|
||||
if order.status in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED):
|
||||
if order.status == Order.STATUS_CANCELED:
|
||||
generate_cancellation(invoice, trigger_pdf)
|
||||
|
||||
return invoice
|
||||
@@ -310,6 +312,7 @@ def build_preview_invoice_pdf(event):
|
||||
invoice.invoice_to_name, invoice.invoice_to_street,
|
||||
invoice.invoice_to_zipcode, invoice.invoice_to_city
|
||||
)
|
||||
invoice.invoice_to_beneficiary = ''
|
||||
invoice.file = None
|
||||
invoice.save()
|
||||
invoice.lines.all().delete()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import smtplib
|
||||
from email.utils import formataddr
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
@@ -78,6 +79,10 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
headers = headers or {}
|
||||
|
||||
with language(locale):
|
||||
if isinstance(context, dict) and event:
|
||||
for k, v in event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
if isinstance(context, dict) and order:
|
||||
try:
|
||||
context.update({
|
||||
@@ -125,6 +130,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
body_plain += "\r\n\r\n-- \r\n"
|
||||
|
||||
if order:
|
||||
if order.testmode:
|
||||
subject = "[TESTMODE] " + subject
|
||||
body_plain += _(
|
||||
"You are receiving this email because you placed an order for {event}."
|
||||
).format(event=event.name)
|
||||
@@ -170,8 +177,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
chain(*task_chain).apply_async()
|
||||
|
||||
|
||||
@app.task
|
||||
def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
@app.task(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:
|
||||
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||
@@ -219,7 +226,34 @@ def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sen
|
||||
|
||||
try:
|
||||
backend.send_messages([email])
|
||||
except Exception:
|
||||
except smtplib.SMTPResponseException as e:
|
||||
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
|
||||
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
|
||||
logger.exception('Error sending email')
|
||||
|
||||
if order:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.error',
|
||||
data={
|
||||
'subject': 'SMTP code {}'.format(e.smtp_code),
|
||||
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
|
||||
'recipient': '',
|
||||
'invoices': [],
|
||||
}
|
||||
)
|
||||
|
||||
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||
except Exception as e:
|
||||
if order:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.error',
|
||||
data={
|
||||
'subject': 'Internal error',
|
||||
'message': str(e),
|
||||
'recipient': '',
|
||||
'invoices': [],
|
||||
}
|
||||
)
|
||||
logger.exception('Error sending email')
|
||||
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Max, Q, Sum
|
||||
from django.db.models.functions import Greatest
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
@@ -31,7 +32,7 @@ from pretix.base.models.orders import (
|
||||
)
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
from pretix.base.payment import BasePaymentProvider
|
||||
from pretix.base.payment import BasePaymentProvider, PaymentException
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_qualified,
|
||||
)
|
||||
@@ -124,25 +125,12 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
|
||||
|
||||
@transaction.atomic
|
||||
def mark_order_refunded(order, user=None, auth=None, api_token=None):
|
||||
"""
|
||||
Mark this order as refunded. This sets the payment status and returns the order object.
|
||||
:param order: The order to change
|
||||
:param user: The user that performed the change
|
||||
"""
|
||||
if isinstance(order, int):
|
||||
order = Order.objects.get(pk=order)
|
||||
if isinstance(user, int):
|
||||
user = User.objects.get(pk=user)
|
||||
with order.event.lock():
|
||||
order.status = Order.STATUS_REFUNDED
|
||||
order.save(update_fields=['status'])
|
||||
|
||||
order.log_action('pretix.event.order.refunded', user=user, auth=auth or api_token)
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i:
|
||||
generate_cancellation(i)
|
||||
|
||||
return order
|
||||
oautha = auth.pk if isinstance(auth, OAuthApplication) else None
|
||||
device = auth.pk if isinstance(auth, Device) else None
|
||||
api_token = (api_token.pk if api_token else None) or (auth if isinstance(auth, TeamAPIToken) else None)
|
||||
return _cancel_order(
|
||||
order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -268,7 +256,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
|
||||
if send_mail:
|
||||
try:
|
||||
@@ -306,7 +294,8 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None):
|
||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
|
||||
cancellation_fee=None):
|
||||
"""
|
||||
Mark this order as canceled
|
||||
:param order: The order to change
|
||||
@@ -322,20 +311,54 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
device = Device.objects.get(pk=device)
|
||||
if isinstance(oauth_application, int):
|
||||
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
|
||||
with order.event.lock():
|
||||
if not order.cancel_allowed():
|
||||
raise OrderError(_('You cannot cancel this order.'))
|
||||
order.status = Order.STATUS_CANCELED
|
||||
order.save(update_fields=['status'])
|
||||
if isinstance(cancellation_fee, str):
|
||||
cancellation_fee = Decimal(cancellation_fee)
|
||||
|
||||
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device)
|
||||
if not order.cancel_allowed():
|
||||
raise OrderError(_('You cannot cancel this order.'))
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i:
|
||||
generate_cancellation(i)
|
||||
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
if cancellation_fee:
|
||||
with order.event.lock():
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
position.canceled = True
|
||||
position.save(update_fields=['canceled'])
|
||||
for fee in order.fees.all():
|
||||
fee.canceled = True
|
||||
fee.save(update_fields=['canceled'])
|
||||
|
||||
f = OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
|
||||
value=cancellation_fee,
|
||||
tax_rule=order.event.settings.tax_rate_default,
|
||||
order=order,
|
||||
)
|
||||
f._calculate_tax()
|
||||
f.save()
|
||||
|
||||
if order.payment_refund_sum < cancellation_fee:
|
||||
raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
|
||||
order.status = Order.STATUS_PAID
|
||||
order.total = f.value
|
||||
order.save(update_fields=['status', 'total'])
|
||||
|
||||
if i:
|
||||
generate_invoice(order)
|
||||
else:
|
||||
with order.event.lock():
|
||||
order.status = Order.STATUS_CANCELED
|
||||
order.save(update_fields=['status'])
|
||||
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
|
||||
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
|
||||
data={'cancellation_fee': cancellation_fee})
|
||||
|
||||
if send_mail:
|
||||
email_template = order.event.settings.mail_text_order_canceled
|
||||
@@ -510,6 +533,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
datetime=now_dt,
|
||||
locale=locale,
|
||||
total=total,
|
||||
testmode=event.testmode,
|
||||
meta_info=json.dumps(meta_info or {}),
|
||||
require_approval=any(p.item.require_approval for p in positions),
|
||||
sales_channel=sales_channel
|
||||
@@ -532,7 +556,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
fee.tax_rule = None # TODO: deprecate
|
||||
fee.save()
|
||||
|
||||
if payment_provider:
|
||||
if payment_provider and not order.require_approval:
|
||||
order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider=payment_provider.identifier,
|
||||
@@ -573,16 +597,16 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
with event.lock() as now_dt:
|
||||
positions = list(CartPosition.objects.filter(
|
||||
id__in=position_ids).select_related('item', 'variation', 'subevent'))
|
||||
if len(positions) == 0:
|
||||
raise OrderError(error_messages['empty'])
|
||||
if len(position_ids) != len(positions):
|
||||
raise OrderError(error_messages['internal'])
|
||||
_check_positions(event, now_dt, positions, address=addr)
|
||||
order = _create_order(event, email, positions, now_dt, pprov,
|
||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel)
|
||||
# with event.lock() as now_dt:
|
||||
now_dt = now()
|
||||
positions = list(CartPosition.objects.filter(id__in=position_ids).select_related('item', 'variation', 'subevent'))
|
||||
if len(positions) == 0:
|
||||
raise OrderError(error_messages['empty'])
|
||||
if len(position_ids) != len(positions):
|
||||
raise OrderError(error_messages['internal'])
|
||||
_check_positions(event, now_dt, positions, address=addr)
|
||||
order = _create_order(event, email, positions, now_dt, pprov,
|
||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel)
|
||||
|
||||
invoice = order.invoices.last() # Might be generated by plugin already
|
||||
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
|
||||
@@ -792,12 +816,17 @@ class OrderChangeManager:
|
||||
self.notify = notify
|
||||
self._invoice_dirty = False
|
||||
|
||||
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
|
||||
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation], keep_price=False):
|
||||
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
|
||||
raise OrderError(self.error_messages['product_without_variation'])
|
||||
|
||||
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent,
|
||||
invoice_address=self._invoice_address)
|
||||
if keep_price:
|
||||
price = TaxedPrice(gross=position.price, net=position.price - position.tax_value,
|
||||
tax=position.tax_value, rate=position.tax_rate,
|
||||
name=position.tax_rule.name if position.tax_rule else None)
|
||||
else:
|
||||
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent,
|
||||
invoice_address=self._invoice_address)
|
||||
|
||||
if price is None: # NOQA
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
@@ -934,7 +963,7 @@ class OrderChangeManager:
|
||||
)
|
||||
self.order.save()
|
||||
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0:
|
||||
if self.order.pending_sum <= Decimal('0.00'):
|
||||
if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval:
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
elif self.open_payment:
|
||||
@@ -954,7 +983,7 @@ class OrderChangeManager:
|
||||
}, user=self.user, auth=self.auth)
|
||||
|
||||
def _check_paid_to_free(self):
|
||||
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)):
|
||||
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval:
|
||||
# if the order becomes free, mark it paid using the 'free' provider
|
||||
# this could happen if positions have been made cheaper or removed (_totaldiff < 0)
|
||||
# or positions got split off to a new order (split_order with positive total)
|
||||
@@ -969,7 +998,7 @@ class OrderChangeManager:
|
||||
except Quota.QuotaExceededException:
|
||||
raise OrderError(self.error_messages['paid_to_free_exceeded'])
|
||||
|
||||
if self.split_order and self.split_order.total == 0:
|
||||
if self.split_order and self.split_order.total == 0 and not self.split_order.require_approval:
|
||||
p = self.split_order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider='free',
|
||||
@@ -1043,7 +1072,10 @@ class OrderChangeManager:
|
||||
'addon_to': opa.addon_to_id,
|
||||
'old_price': opa.price,
|
||||
})
|
||||
opa.delete()
|
||||
opa.canceled = True
|
||||
if opa.voucher:
|
||||
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
opa.save(update_fields=['canceled'])
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
@@ -1052,7 +1084,10 @@ class OrderChangeManager:
|
||||
'old_price': op.position.price,
|
||||
'addon_to': None,
|
||||
})
|
||||
op.position.delete()
|
||||
op.position.canceled = True
|
||||
if op.position.voucher:
|
||||
Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
op.position.save(update_fields=['canceled'])
|
||||
elif isinstance(op, self.AddOperation):
|
||||
pos = OrderPosition.objects.create(
|
||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||
@@ -1091,6 +1126,7 @@ class OrderChangeManager:
|
||||
split_order.code = None
|
||||
split_order.datetime = now()
|
||||
split_order.secret = generate_secret()
|
||||
split_order.require_approval = self.order.require_approval and any(p.item.require_approval for p in split_positions)
|
||||
split_order.save()
|
||||
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
|
||||
'original_order': self.order.code
|
||||
@@ -1117,7 +1153,7 @@ class OrderChangeManager:
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
split_order.total = sum([p.price for p in split_positions])
|
||||
split_order.total = sum([p.price for p in split_positions if not p.canceled])
|
||||
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
|
||||
pp = self._get_payment_provider()
|
||||
if pp:
|
||||
@@ -1272,7 +1308,7 @@ class OrderChangeManager:
|
||||
except SendMailException:
|
||||
logger.exception('Order changed email could not be sent')
|
||||
|
||||
def commit(self):
|
||||
def commit(self, check_quotas=True):
|
||||
if self._committed:
|
||||
# an order change can only be committed once
|
||||
raise OrderError(error_messages['internal'])
|
||||
@@ -1289,7 +1325,8 @@ class OrderChangeManager:
|
||||
with self.order.event.lock():
|
||||
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
raise OrderError(self.error_messages['not_pending_or_paid'])
|
||||
self._check_quotas()
|
||||
if check_quotas:
|
||||
self._check_quotas()
|
||||
self._check_complete_cancel()
|
||||
self._perform_operations()
|
||||
self._recalculate_total_and_payment_fee()
|
||||
@@ -1338,10 +1375,59 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||
device=None):
|
||||
device=None, cancellation_fee=None, try_auto_refund=False):
|
||||
try:
|
||||
try:
|
||||
return _cancel_order(order, user, send_mail, api_token, device, oauth_application)
|
||||
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||
cancellation_fee)
|
||||
if try_auto_refund:
|
||||
notify_admin = False
|
||||
error = False
|
||||
order = Order.objects.get(pk=order)
|
||||
refund_amount = order.pending_sum * -1
|
||||
proposals = order.propose_auto_refunds(refund_amount)
|
||||
can_auto_refund = sum(proposals.values()) == refund_amount
|
||||
if can_auto_refund:
|
||||
for p, value in proposals.items():
|
||||
with transaction.atomic():
|
||||
r = order.refunds.create(
|
||||
payment=p,
|
||||
source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
provider=p.provider
|
||||
)
|
||||
order.log_action('pretix.event.order.refund.created', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
})
|
||||
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
with transaction.atomic():
|
||||
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||
r.save()
|
||||
order.log_action('pretix.event.order.refund.failed', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
'error': str(e)
|
||||
})
|
||||
error = True
|
||||
notify_admin = True
|
||||
else:
|
||||
if r.state != OrderRefund.REFUND_STATE_DONE:
|
||||
notify_admin = True
|
||||
elif refund_amount != Decimal('0.00'):
|
||||
notify_admin = True
|
||||
|
||||
if notify_admin:
|
||||
order.log_action('pretix.event.order.refund.requested')
|
||||
if error:
|
||||
raise OrderError(
|
||||
_('There was an error while trying to send the money back to you. Please contact the event organizer for further information.')
|
||||
)
|
||||
return ret
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import F, Max, OuterRef, Q, Subquery
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.base.models import LogEntry, Quota
|
||||
from pretix.celery_app import app
|
||||
@@ -26,7 +29,8 @@ def refresh_quota_caches():
|
||||
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=F('last_activity')) |
|
||||
Q(cached_availability_time__lt=now() - timedelta(hours=2), last_activity__gt=now() - timedelta(days=7))
|
||||
)
|
||||
for q in quotas:
|
||||
q.availability()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from django.db.models import Count, Sum
|
||||
from django.db.models import Case, Count, F, Sum, Value, When
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition
|
||||
@@ -79,18 +79,22 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
||||
'variations'
|
||||
).order_by('category__position', 'category_id', 'position', 'name')
|
||||
|
||||
qs = OrderPosition.objects
|
||||
qs = OrderPosition.all
|
||||
if subevent:
|
||||
qs = qs.filter(subevent=subevent)
|
||||
counters = qs.filter(
|
||||
order__event=event
|
||||
).annotate(
|
||||
status=Case(
|
||||
When(canceled=True, then=Value('c')),
|
||||
default=F('order__status')
|
||||
)
|
||||
).values(
|
||||
'item', 'variation', 'order__status'
|
||||
'item', 'variation', 'status'
|
||||
).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by()
|
||||
|
||||
states = {
|
||||
'canceled': Order.STATUS_CANCELED,
|
||||
'refunded': Order.STATUS_REFUNDED,
|
||||
'paid': Order.STATUS_PAID,
|
||||
'pending': Order.STATUS_PENDING,
|
||||
'expired': Order.STATUS_EXPIRED,
|
||||
@@ -99,7 +103,7 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
||||
for l, s in states.items():
|
||||
num[l] = {
|
||||
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value'])
|
||||
for p in counters if p['order__status'] == s
|
||||
for p in counters if p['status'] == s
|
||||
}
|
||||
|
||||
num['total'] = dictsum(num['pending'], num['paid'])
|
||||
@@ -149,16 +153,21 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
||||
payment_items = []
|
||||
|
||||
if not subevent:
|
||||
counters = OrderFee.objects.filter(
|
||||
counters = OrderFee.all.filter(
|
||||
order__event=event
|
||||
).annotate(
|
||||
status=Case(
|
||||
When(canceled=True, then=Value('c')),
|
||||
default=F('order__status')
|
||||
)
|
||||
).values(
|
||||
'fee_type', 'internal_type', 'order__status'
|
||||
'fee_type', 'internal_type', 'status'
|
||||
).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by()
|
||||
|
||||
for l, s in states.items():
|
||||
num[l] = {
|
||||
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value'])
|
||||
for o in counters if o['order__status'] == s
|
||||
for o in counters if o['status'] == s
|
||||
}
|
||||
num['total'] = dictsum(num['pending'], num['paid'])
|
||||
|
||||
|
||||
@@ -117,6 +117,8 @@ def get_tickets_for_order(order):
|
||||
|
||||
if p.multi_download_enabled:
|
||||
try:
|
||||
if len(list(order.positions_with_tickets)) == 0:
|
||||
continue
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=order, provider=p.identifier, file__isnull=False
|
||||
).last()
|
||||
@@ -132,11 +134,7 @@ def get_tickets_for_order(order):
|
||||
except:
|
||||
logger.exception('Failed to generate ticket.')
|
||||
else:
|
||||
for pos in order.positions.all():
|
||||
if pos.addon_to and not order.event.settings.ticket_download_addons:
|
||||
continue
|
||||
if not pos.item.admission and not order.event.settings.ticket_download_nonadm:
|
||||
continue
|
||||
for pos in order.positions_with_tickets:
|
||||
try:
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=pos, provider=p.identifier, file__isnull=False
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
@@ -64,6 +65,10 @@ DEFAULTS = {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'invoice_address_beneficiary': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'invoice_address_vatid': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
@@ -88,6 +93,10 @@ DEFAULTS = {
|
||||
'default': '30',
|
||||
'type': int
|
||||
},
|
||||
'payment_explanation': {
|
||||
'default': '',
|
||||
'type': LazyI18nString
|
||||
},
|
||||
'payment_term_days': {
|
||||
'default': '14',
|
||||
'type': int
|
||||
@@ -204,6 +213,10 @@ DEFAULTS = {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'event_list_availability': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'event_list_type': {
|
||||
'default': 'list',
|
||||
'type': str
|
||||
@@ -216,6 +229,30 @@ DEFAULTS = {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'cancel_allow_user_until': {
|
||||
'default': None,
|
||||
'type': RelativeDateWrapper,
|
||||
},
|
||||
'cancel_allow_user_paid': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'cancel_allow_user_paid_keep': {
|
||||
'default': '0.00',
|
||||
'type': Decimal,
|
||||
},
|
||||
'cancel_allow_user_paid_keep_fees': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'cancel_allow_user_paid_keep_percentage': {
|
||||
'default': '0.00',
|
||||
'type': Decimal,
|
||||
},
|
||||
'cancel_allow_user_paid_until': {
|
||||
'default': None,
|
||||
'type': RelativeDateWrapper,
|
||||
},
|
||||
'contact_mail': {
|
||||
'default': None,
|
||||
'type': str
|
||||
|
||||
@@ -133,12 +133,12 @@ class EmailAddressShredder(BaseDataShredder):
|
||||
}, indent=4)
|
||||
yield 'emails-by-attendee.json', 'application/json', json.dumps({
|
||||
'{}-{}'.format(op.order.code, op.positionid): op.attendee_email
|
||||
for op in OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False)
|
||||
for op in OrderPosition.all.filter(order__event=self.event, attendee_email__isnull=False)
|
||||
}, indent=4)
|
||||
|
||||
@transaction.atomic
|
||||
def shred_data(self):
|
||||
OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False).update(attendee_email=None)
|
||||
OrderPosition.all.filter(order__event=self.event, attendee_email__isnull=False).update(attendee_email=None)
|
||||
|
||||
for o in self.event.orders.all():
|
||||
o.email = None
|
||||
@@ -202,7 +202,7 @@ class AttendeeNameShredder(BaseDataShredder):
|
||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||
yield 'attendee-names.json', 'application/json', json.dumps({
|
||||
'{}-{}'.format(op.order.code, op.positionid): op.attendee_name
|
||||
for op in OrderPosition.objects.filter(
|
||||
for op in OrderPosition.all.filter(
|
||||
order__event=self.event
|
||||
).filter(
|
||||
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
|
||||
@@ -211,7 +211,7 @@ class AttendeeNameShredder(BaseDataShredder):
|
||||
|
||||
@transaction.atomic
|
||||
def shred_data(self):
|
||||
OrderPosition.objects.filter(
|
||||
OrderPosition.all.filter(
|
||||
order__event=self.event
|
||||
).filter(
|
||||
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
|
||||
@@ -267,7 +267,7 @@ class QuestionAnswerShredder(BaseDataShredder):
|
||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||
yield 'question-answers.json', 'application/json', json.dumps({
|
||||
'{}-{}'.format(op.order.code, op.positionid): AnswerSerializer(op.answers.all(), many=True).data
|
||||
for op in OrderPosition.objects.filter(order__event=self.event).prefetch_related('answers')
|
||||
for op in OrderPosition.all.filter(order__event=self.event).prefetch_related('answers')
|
||||
}, indent=4)
|
||||
|
||||
@transaction.atomic
|
||||
|
||||
@@ -65,11 +65,11 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
if not app_cache:
|
||||
_populate_app_cache()
|
||||
|
||||
for receiver in self._live_receivers(sender):
|
||||
for receiver in self._sorted_receivers(sender):
|
||||
if self._is_active(sender, receiver):
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
responses.append((receiver, response))
|
||||
return sorted(responses, key=lambda r: (receiver.__module__, receiver.__name__))
|
||||
return responses
|
||||
|
||||
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
|
||||
"""
|
||||
@@ -89,12 +89,55 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
if not app_cache:
|
||||
_populate_app_cache()
|
||||
|
||||
for receiver in self._live_receivers(sender):
|
||||
for receiver in self._sorted_receivers(sender):
|
||||
if self._is_active(sender, receiver):
|
||||
named[chain_kwarg_name] = response
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
return response
|
||||
|
||||
def send_robust(self, sender: Event, **named) -> List[Tuple[Callable, Any]]:
|
||||
"""
|
||||
Send signal from sender to all connected receivers. If a receiver raises an exception
|
||||
instead of returning a value, the exception is included as the result instead of
|
||||
stopping the response chain at the offending receiver.
|
||||
|
||||
sender is required to be an instance of ``pretix.base.models.Event``.
|
||||
"""
|
||||
if sender and not isinstance(sender, Event):
|
||||
raise ValueError("Sender needs to be an event.")
|
||||
|
||||
responses = []
|
||||
if (
|
||||
not self.receivers
|
||||
or self.sender_receivers_cache.get(sender) is NO_RECEIVERS
|
||||
):
|
||||
return []
|
||||
|
||||
if not app_cache:
|
||||
_populate_app_cache()
|
||||
|
||||
for receiver in self._sorted_receivers(sender):
|
||||
if self._is_active(sender, receiver):
|
||||
try:
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
except Exception as err:
|
||||
responses.append((receiver, err))
|
||||
else:
|
||||
responses.append((receiver, response))
|
||||
return responses
|
||||
|
||||
def _sorted_receivers(self, sender):
|
||||
orig_list = self._live_receivers(sender)
|
||||
sorted_list = sorted(
|
||||
orig_list,
|
||||
key=lambda receiver: (
|
||||
0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1,
|
||||
receiver.__module__,
|
||||
receiver.__name__,
|
||||
)
|
||||
)
|
||||
return sorted_list
|
||||
|
||||
|
||||
class DeprecatedSignal(django.dispatch.Signal):
|
||||
|
||||
@@ -183,7 +226,7 @@ register_sales_channels = django.dispatch.Signal(
|
||||
)
|
||||
"""
|
||||
This signal is sent out to get all known sales channels types. Receivers should return an
|
||||
instance of a subclass of pretix.base.channels.SalesChannel or a list of such
|
||||
instance of a subclass of ``pretix.base.channels.SalesChannel`` or a list of such
|
||||
instances.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{% trans "Bad Request" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-frown-o big-icon"></i>
|
||||
<h1>{% trans "Bad Request" %}</h1>
|
||||
<p>{% trans "We were unable to parse your request." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
<i class="fa fa-frown-o fa-fw big-icon"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Bad Request" %}</h1>
|
||||
<p>{% trans "We were unable to parse your request." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{% trans "Permission denied" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-lock big-icon"></i>
|
||||
<h1>{% trans "Permission denied" %}</h1>
|
||||
<p>{% trans "You do not have access to this page." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
<i class="fa fa-fw fa-lock big-icon"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Permission denied" %}</h1>
|
||||
<p>{% trans "You do not have access to this page." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{% trans "Not found" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-meh-o big-icon"></i>
|
||||
<h1>{% trans "Not found" %}</h1>
|
||||
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
<i class="fa fa-meh-o fa-fw big-icon"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Not found" %}</h1>
|
||||
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{% trans "Internal Server Error" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-bolt big-icon"></i>
|
||||
<h1>{% trans "Internal Server Error" %}</h1>
|
||||
<p>{% trans "We had trouble processing your request." %}</p>
|
||||
<p>{% trans "If this problem persists, please contact us." %}</p>
|
||||
{% if request.sentry.id %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
If you contact us, please send us the following code:
|
||||
{% endblocktrans %}
|
||||
<br>
|
||||
{{ request.sentry.id }}
|
||||
<i class="fa fa-bolt big-icon fa-fw"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Internal Server Error" %}</h1>
|
||||
<p>{% trans "We had trouble processing your request." %}</p>
|
||||
<p>{% trans "If this problem persists, please contact us." %}</p>
|
||||
{% if sentry_event_id %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
If you contact us, please send us the following code:
|
||||
{% endblocktrans %}
|
||||
<br>
|
||||
{{ sentry_event_id }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{% trans "Verification failed" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-frown-o big-icon"></i>
|
||||
<h1>{% trans "Verification failed" %}</h1>
|
||||
<p>{% blocktrans trimmed %}
|
||||
We could not verify that this request really was sent from you. For security reasons, we therefore cannot process it.
|
||||
{% endblocktrans %}</p>
|
||||
{% if no_referer %}
|
||||
<p>{{ no_referer1 }}</p>
|
||||
<p>{{ no_referer2 }}</p>
|
||||
{% elif no_cookie %}
|
||||
<p>{{ no_cookie1 }}</p>
|
||||
<p>{{ no_cookie2 }}</p>
|
||||
{% else %}
|
||||
<i class="fa fa-frown-o big-icon fa-fw"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Verification failed" %}</h1>
|
||||
<p>{% blocktrans trimmed %}
|
||||
Please go back to the last page, refresh this page and then try again. If the problem persists, please get in touch with us.
|
||||
We could not verify that this request really was sent from you. For security reasons, we therefore cannot
|
||||
process it.
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
</p>
|
||||
{% if no_referer %}
|
||||
<p>{{ no_referer1 }}</p>
|
||||
<p>{{ no_referer2 }}</p>
|
||||
{% elif no_cookie %}
|
||||
<p>{{ no_cookie1 }}</p>
|
||||
<p>{{ no_cookie2 }}</p>
|
||||
{% else %}
|
||||
<p>{% blocktrans trimmed %}
|
||||
Please go back to the last page, refresh this page and then try again. If the problem persists, please
|
||||
get in touch with us.
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
</p>
|
||||
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% load compress %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load staticfiles %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -16,6 +16,6 @@
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="{% static "pretixbase/js/errors.js" %}"></script>
|
||||
</body>
|
||||
<script src="{% static "pretixbase/js/errors.js" %}"></script>
|
||||
</html>
|
||||
|
||||
@@ -84,7 +84,7 @@ def markdown_compile(source):
|
||||
source,
|
||||
extensions=[
|
||||
'markdown.extensions.sane_lists',
|
||||
# 'markdown.extensions.nl2br', # TODO: Enable, but check backwards-compatibility issues e.g. with mails
|
||||
'markdown.extensions.nl2br'
|
||||
]
|
||||
),
|
||||
tags=ALLOWED_TAGS,
|
||||
|
||||
@@ -70,14 +70,12 @@ class BaseTicketOutput:
|
||||
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
|
||||
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
|
||||
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
|
||||
If you want, you can just iterate over ``order.positions_with_tickets`` which applies the
|
||||
appropriate filters for you.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for pos in order.positions.all():
|
||||
if pos.addon_to_id and not self.event.settings.ticket_download_addons:
|
||||
continue
|
||||
if not pos.item.admission and not self.event.settings.ticket_download_nonadm:
|
||||
continue
|
||||
for pos in order.positions_with_tickets:
|
||||
fname, __, content = self.generate(pos)
|
||||
zipf.writestr('{}-{}{}'.format(
|
||||
order.code, pos.positionid, os.path.splitext(fname)[1]
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.template.loader import get_template
|
||||
from django.utils.functional import Promise
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.csrf import requires_csrf_token
|
||||
from sentry_sdk import last_event_id
|
||||
|
||||
|
||||
def csrf_failure(request, reason=""):
|
||||
@@ -65,5 +66,6 @@ def server_error(request):
|
||||
except TemplateDoesNotExist:
|
||||
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
||||
return HttpResponseServerError(template.render({
|
||||
'request': request
|
||||
'request': request,
|
||||
'sentry_event_id': last_event_id(),
|
||||
}))
|
||||
|
||||
@@ -146,6 +146,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
invoice_form_class = BaseInvoiceAddressForm
|
||||
invoice_name_form_class = BaseInvoiceNameForm
|
||||
only_user_visible = True
|
||||
all_optional = False
|
||||
|
||||
@cached_property
|
||||
def _positions_for_questions(self):
|
||||
@@ -189,12 +190,14 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
return self.invoice_name_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
event=self.request.event,
|
||||
instance=self.invoice_address, validate_vat_id=False
|
||||
instance=self.invoice_address, validate_vat_id=False,
|
||||
all_optional=self.all_optional
|
||||
)
|
||||
return self.invoice_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
event=self.request.event,
|
||||
instance=self.invoice_address, validate_vat_id=False
|
||||
instance=self.invoice_address, validate_vat_id=False,
|
||||
all_optional=self.all_optional,
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
@@ -34,6 +34,7 @@ def contextprocessor(request):
|
||||
ctx = {
|
||||
'url_name': url.url_name,
|
||||
'settings': settings,
|
||||
'django_settings': settings,
|
||||
'DEBUG': settings.DEBUG,
|
||||
}
|
||||
_html_head = []
|
||||
@@ -51,6 +52,15 @@ def contextprocessor(request):
|
||||
|
||||
ctx['has_domain'] = request.event.organizer.domains.exists()
|
||||
|
||||
if not request.event.testmode:
|
||||
complain_testmode_orders = request.event.cache.get('complain_testmode_orders')
|
||||
if complain_testmode_orders is None:
|
||||
complain_testmode_orders = request.event.orders.filter(testmode=True).exists()
|
||||
request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30)
|
||||
ctx['complain_testmode_orders'] = complain_testmode_orders
|
||||
else:
|
||||
ctx['complain_testmode_orders'] = False
|
||||
|
||||
if not request.event.live and ctx['has_domain']:
|
||||
child_sess = request.session.get('child_session_{}'.format(request.event.pk))
|
||||
s = SessionStore()
|
||||
|
||||
@@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db.models import Q
|
||||
from django.forms import formset_factory
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import get_current_timezone_name
|
||||
from django.utils.translation import (
|
||||
pgettext, pgettext_lazy, ugettext_lazy as _,
|
||||
@@ -159,6 +161,15 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
return slug
|
||||
|
||||
|
||||
class EventChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
return mark_safe('{}<br /><span class="text-muted">{} · {}</span>'.format(
|
||||
escape(str(obj)),
|
||||
obj.get_date_range_display() if not obj.has_subevents else _("Event series"),
|
||||
obj.slug
|
||||
))
|
||||
|
||||
|
||||
class EventWizardCopyForm(forms.Form):
|
||||
|
||||
@staticmethod
|
||||
@@ -177,7 +188,7 @@ class EventWizardCopyForm(forms.Form):
|
||||
kwargs.pop('has_subevents')
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['copy_from_event'] = forms.ModelChoiceField(
|
||||
self.fields['copy_from_event'] = EventChoiceField(
|
||||
label=_("Copy configuration from"),
|
||||
queryset=EventWizardCopyForm.copy_from_queryset(self.user),
|
||||
widget=forms.RadioSelect,
|
||||
@@ -397,11 +408,6 @@ class EventSettingsForm(SettingsForm):
|
||||
required=False,
|
||||
help_text=_("We'll show this publicly to allow attendees to contact you.")
|
||||
)
|
||||
cancel_allow_user = forms.BooleanField(
|
||||
label=_("Allow users to cancel unpaid orders"),
|
||||
help_text=_("If checked, users can cancel orders by themselves as long as they are not yet paid."),
|
||||
required=False
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
@@ -435,6 +441,39 @@ class EventSettingsForm(SettingsForm):
|
||||
)
|
||||
|
||||
|
||||
class CancelSettingsForm(SettingsForm):
|
||||
cancel_allow_user = forms.BooleanField(
|
||||
label=_("Customers can cancel their unpaid orders"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_until = RelativeDateTimeField(
|
||||
label=_("Do not allow cancellations after"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid = forms.BooleanField(
|
||||
label=_("Customers can cancel their paid orders"),
|
||||
help_text=_("Paid money will be automatically paid back if the payment method allows it. "
|
||||
"Otherwise, a manual refund will be created for you to process manually."),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_keep = forms.DecimalField(
|
||||
label=_("Keep a fixed cancellation fee"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_keep_fees = forms.BooleanField(
|
||||
label=_("Keep payment, shipping and service fees"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_keep_percentage = forms.DecimalField(
|
||||
label=_("Keep a percentual cancellation fee"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_until = RelativeDateTimeField(
|
||||
label=_("Do not allow cancellations after"),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PaymentSettingsForm(SettingsForm):
|
||||
payment_term_days = forms.IntegerField(
|
||||
label=_('Payment term in days'),
|
||||
@@ -478,6 +517,16 @@ class PaymentSettingsForm(SettingsForm):
|
||||
help_text=_("The tax rule that applies for additional fees you configured for single payment methods. This "
|
||||
"will set the tax rate and reverse charge rules, other settings of the tax rule are ignored.")
|
||||
)
|
||||
payment_explanation = I18nFormField(
|
||||
widget=I18nTextarea,
|
||||
widget_kwargs={'attrs': {
|
||||
'rows': 3,
|
||||
}},
|
||||
required=False,
|
||||
label=_("Guidance text"),
|
||||
help_text=_("This text will be shown above the payment options. You can explain the choices to the user here, "
|
||||
"if you want.")
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@@ -567,6 +616,11 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||
required=False
|
||||
)
|
||||
invoice_address_beneficiary = forms.BooleanField(
|
||||
label=_("Ask for beneficiary"),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||
required=False
|
||||
)
|
||||
invoice_include_free = forms.BooleanField(
|
||||
label=_("Show free products on invoices"),
|
||||
help_text=_("Note that invoices will never be generated for orders that contain only free "
|
||||
@@ -919,6 +973,13 @@ class MailSettingsForm(SettingsForm):
|
||||
self.fields['mail_html_renderer'].choices = [
|
||||
(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():
|
||||
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]
|
||||
|
||||
def clean(self):
|
||||
data = self.cleaned_data
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from datetime import datetime, time
|
||||
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.db.models.functions import Coalesce, ExtractWeekDay
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import get_current_timezone, make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms.widgets import DatePickerWidget
|
||||
from pretix.base.models import (
|
||||
Checkin, Event, Invoice, Item, Order, OrderPayment, OrderPosition,
|
||||
OrderRefund, Organizer, Question, QuestionAnswer, SubEvent,
|
||||
@@ -97,14 +100,13 @@ class OrderFilterForm(FilterForm):
|
||||
label=_('Order status'),
|
||||
choices=(
|
||||
('', _('All orders')),
|
||||
('p', _('Paid')),
|
||||
('n', _('Pending')),
|
||||
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
|
||||
(Order.STATUS_PENDING, _('Pending')),
|
||||
('o', _('Pending (overdue)')),
|
||||
('np', _('Pending or paid')),
|
||||
('e', _('Expired')),
|
||||
('ne', _('Pending or expired')),
|
||||
('c', _('Canceled')),
|
||||
('r', _('Refunded')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
||||
(Order.STATUS_EXPIRED, _('Expired')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||
(Order.STATUS_CANCELED, _('Canceled')),
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
@@ -173,7 +175,7 @@ class OrderFilterForm(FilterForm):
|
||||
|
||||
class EventOrderFilterForm(OrderFilterForm):
|
||||
orders = {'code': 'code', 'email': 'email', 'total': 'total',
|
||||
'datetime': 'datetime', 'status': 'status', 'pcnt': 'pcnt'}
|
||||
'datetime': 'datetime', 'status': 'status'}
|
||||
|
||||
item = forms.ModelChoiceField(
|
||||
label=_('Products'),
|
||||
@@ -198,18 +200,19 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
label=_('Order status'),
|
||||
choices=(
|
||||
('', _('All orders')),
|
||||
('p', _('Paid')),
|
||||
('n', _('Pending')),
|
||||
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
|
||||
(Order.STATUS_PENDING, _('Pending')),
|
||||
('o', _('Pending (overdue)')),
|
||||
('np', _('Pending or paid')),
|
||||
('e', _('Expired')),
|
||||
('ne', _('Pending or expired')),
|
||||
('c', _('Canceled')),
|
||||
('r', _('Refunded')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
||||
(Order.STATUS_EXPIRED, _('Expired')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||
(Order.STATUS_CANCELED, _('Canceled')),
|
||||
('cp', _('Canceled (or with paid fee)')),
|
||||
('pa', _('Approval pending')),
|
||||
('overpaid', _('Overpaid')),
|
||||
('underpaid', _('Underpaid')),
|
||||
('pendingpaid', _('Pending (but fully paid)'))
|
||||
('pendingpaid', _('Pending (but fully paid)')),
|
||||
('testmode', _('Test mode')),
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
@@ -243,10 +246,10 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
qs = super().filter_qs(qs)
|
||||
|
||||
if fdata.get('item'):
|
||||
qs = qs.filter(positions__item=fdata.get('item'))
|
||||
qs = qs.filter(all_positions__item=fdata.get('item'), all_positions__canceled=False).distinct()
|
||||
|
||||
if fdata.get('subevent'):
|
||||
qs = qs.filter(positions__subevent=fdata.get('subevent'))
|
||||
qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False).distinct()
|
||||
|
||||
if fdata.get('question') and fdata.get('answer') is not None:
|
||||
q = fdata.get('question')
|
||||
@@ -274,16 +277,19 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
qs = qs.annotate(has_answer=Exists(answers)).filter(has_answer=True)
|
||||
|
||||
if fdata.get('status') == 'overpaid':
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
Q(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0))
|
||||
| Q(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0))
|
||||
Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0))
|
||||
| Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0))
|
||||
)
|
||||
elif fdata.get('status') == 'pendingpaid':
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
||||
& Q(require_approval=False)
|
||||
)
|
||||
elif fdata.get('status') == 'underpaid':
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
status=Order.STATUS_PAID,
|
||||
pending_sum_t__gt=0
|
||||
@@ -293,13 +299,26 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
status=Order.STATUS_PENDING,
|
||||
require_approval=True
|
||||
)
|
||||
elif fdata.get('status') == 'testmode':
|
||||
qs = qs.filter(
|
||||
testmode=True
|
||||
)
|
||||
elif fdata.get('status') == 'cp':
|
||||
s = OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
)
|
||||
qs = qs.annotate(
|
||||
has_pc=Exists(s)
|
||||
).filter(
|
||||
Q(status=Order.STATUS_PAID, has_pc=False) | Q(status=Order.STATUS_CANCELED)
|
||||
)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class OrderSearchFilterForm(OrderFilterForm):
|
||||
orders = {'code': 'code', 'email': 'email', 'total': 'total',
|
||||
'datetime': 'datetime', 'status': 'status', 'pcnt': 'pcnt',
|
||||
'datetime': 'datetime', 'status': 'status',
|
||||
'event': 'event'}
|
||||
|
||||
organizer = forms.ModelChoiceField(
|
||||
@@ -355,6 +374,11 @@ class SubEventFilterForm(FilterForm):
|
||||
),
|
||||
required=False
|
||||
)
|
||||
date = forms.DateField(
|
||||
label=_('Date'),
|
||||
required=False,
|
||||
widget=DatePickerWidget
|
||||
)
|
||||
weekday = forms.ChoiceField(
|
||||
label=_('Weekday'),
|
||||
choices=(
|
||||
@@ -378,6 +402,10 @@ class SubEventFilterForm(FilterForm):
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['date'].widget = DatePickerWidget()
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
|
||||
@@ -407,6 +435,20 @@ class SubEventFilterForm(FilterForm):
|
||||
Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query)
|
||||
)
|
||||
|
||||
if fdata.get('date'):
|
||||
date_start = make_aware(datetime.combine(
|
||||
fdata.get('date'),
|
||||
time(hour=0, minute=0, second=0, microsecond=0)
|
||||
), get_current_timezone())
|
||||
date_end = make_aware(datetime.combine(
|
||||
fdata.get('date'),
|
||||
time(hour=23, minute=59, second=59, microsecond=999999)
|
||||
), get_current_timezone())
|
||||
qs = qs.filter(
|
||||
Q(date_to__isnull=True, date_from__gte=date_start, date_from__lte=date_end) |
|
||||
Q(date_to__isnull=False, date_from__lte=date_end, date_to__gte=date_start)
|
||||
)
|
||||
|
||||
if fdata.get('ordering'):
|
||||
qs = qs.order_by(self.get_order_by())
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs['event']
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['category'].queryset = self.instance.event.categories.all()
|
||||
@@ -239,6 +240,9 @@ class ItemCreateForm(I18nModelForm):
|
||||
if self.cleaned_data.get('quota_option') == self.EXISTING and self.cleaned_data.get('quota_add_existing') is not None:
|
||||
quota = self.cleaned_data.get('quota_add_existing')
|
||||
quota.items.add(self.instance)
|
||||
quota.log_action('pretix.event.quota.changed', user=self.user, data={
|
||||
'item_added': self.instance.pk
|
||||
})
|
||||
elif self.cleaned_data.get('quota_option') == self.NEW:
|
||||
quota_name = self.cleaned_data.get('quota_add_new_name')
|
||||
quota_size = self.cleaned_data.get('quota_add_new_size')
|
||||
@@ -247,6 +251,11 @@ class ItemCreateForm(I18nModelForm):
|
||||
event=self.event, name=quota_name, size=quota_size
|
||||
)
|
||||
quota.items.add(self.instance)
|
||||
quota.log_action('pretix.event.quota.added', user=self.user, data={
|
||||
'name': quota_name,
|
||||
'size': quota_size,
|
||||
'items': [self.instance.pk]
|
||||
})
|
||||
|
||||
if self.cleaned_data.get('has_variations'):
|
||||
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
|
||||
@@ -297,6 +306,16 @@ class ItemCreateForm(I18nModelForm):
|
||||
]
|
||||
|
||||
|
||||
class TicketNullBooleanSelect(forms.NullBooleanSelect):
|
||||
def __init__(self, attrs=None):
|
||||
choices = (
|
||||
('1', _('Choose automatically depending on event settings')),
|
||||
('2', _('Yes, if ticket generation is enabled in general')),
|
||||
('3', _('Never')),
|
||||
)
|
||||
super(forms.NullBooleanSelect, self).__init__(attrs, choices)
|
||||
|
||||
|
||||
class ItemUpdateForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -340,6 +359,7 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'max_per_order',
|
||||
'min_per_order',
|
||||
'checkin_attention',
|
||||
'generate_tickets',
|
||||
'original_price'
|
||||
]
|
||||
field_classes = {
|
||||
@@ -349,6 +369,7 @@ class ItemUpdateForm(I18nModelForm):
|
||||
widgets = {
|
||||
'available_from': SplitDateTimePickerWidget(),
|
||||
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
|
||||
'generate_tickets': TicketNullBooleanSelect()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -75,6 +77,40 @@ class ConfirmPaymentForm(forms.Form):
|
||||
del self.fields['force']
|
||||
|
||||
|
||||
class CancelForm(ConfirmPaymentForm):
|
||||
send_email = forms.BooleanField(
|
||||
required=False,
|
||||
label=_('Notify user by e-mail'),
|
||||
initial=True
|
||||
)
|
||||
cancellation_fee = forms.DecimalField(
|
||||
required=False,
|
||||
max_digits=10, decimal_places=2,
|
||||
localize=True,
|
||||
label=_('Keep a cancellation fee of'),
|
||||
help_text=_('If you keep a fee, all positions within this order will be canceled and the order will be reduced '
|
||||
'to a paid cancellation fee. Payment and shipping fees will be canceled as well, so include them '
|
||||
'in your cancellation fee if you want to keep them. Please always enter a gross value, '
|
||||
'tax will be calculated automatically.'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
prs = self.instance.payment_refund_sum
|
||||
if prs > 0:
|
||||
change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency)
|
||||
self.fields['cancellation_fee'].initial = Decimal('0.00')
|
||||
self.fields['cancellation_fee'].max_value = prs
|
||||
else:
|
||||
del self.fields['cancellation_fee']
|
||||
|
||||
def clean_cancellation_fee(self):
|
||||
val = self.cleaned_data['cancellation_fee']
|
||||
if val > self.instance.payment_refund_sum:
|
||||
raise ValidationError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
|
||||
return val
|
||||
|
||||
|
||||
class MarkPaidForm(ConfirmPaymentForm):
|
||||
amount = forms.DecimalField(
|
||||
required=True,
|
||||
@@ -86,7 +122,7 @@ class MarkPaidForm(ConfirmPaymentForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
change_decimal_field(self.fields['amount'], self.instance.event.currency)
|
||||
self.fields['amount'].initial = max(0, self.instance.pending_sum)
|
||||
self.fields['amount'].initial = max(Decimal('0.00'), self.instance.pending_sum)
|
||||
|
||||
|
||||
class ExporterForm(forms.Form):
|
||||
@@ -141,6 +177,10 @@ class OtherOperationsForm(forms.Form):
|
||||
'Send an email to the customer notifying that their order has been changed.'
|
||||
)
|
||||
)
|
||||
ignore_quotas = forms.BooleanField(
|
||||
label=_('Allow to overbook quotas when performing this operation'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.pop('order')
|
||||
@@ -250,6 +290,7 @@ class OrderPositionChangeForm(forms.Form):
|
||||
('secret', 'Regenerate secret'),
|
||||
)
|
||||
)
|
||||
change_product_keep_price = forms.BooleanField(required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.pop('instance')
|
||||
@@ -365,8 +406,7 @@ class OrderRefundForm(forms.Form):
|
||||
required=False,
|
||||
widget=forms.RadioSelect,
|
||||
choices=(
|
||||
('mark_refunded', _('Mark the complete order as refunded. The order will be canceled and all tickets will '
|
||||
'no longer work. This can not be reverted.')),
|
||||
('mark_refunded', _('Cancel the order. All tickets will no longer work. This can not be reverted.')),
|
||||
('mark_pending', _('Mark the order as pending and allow the user to pay the open amount with another '
|
||||
'payment method.')),
|
||||
('do_nothing', _('Do nothing and keep the order as it is.')),
|
||||
@@ -389,7 +429,7 @@ class OrderRefundForm(forms.Form):
|
||||
self.order = kwargs.pop('order')
|
||||
super().__init__(*args, **kwargs)
|
||||
change_decimal_field(self.fields['partial_amount'], self.order.event.currency)
|
||||
if self.order.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
|
||||
if self.order.status == Order.STATUS_CANCELED:
|
||||
del self.fields['action']
|
||||
|
||||
def clean_partial_amount(self):
|
||||
|
||||
@@ -238,6 +238,13 @@ class OrganizerDisplaySettingsForm(SettingsForm):
|
||||
('calendar', _('Calendar'))
|
||||
)
|
||||
)
|
||||
event_list_availability = forms.BooleanField(
|
||||
label=_('Show availability in event overviews'),
|
||||
help_text=_('If checked, the list of events will show if events are sold out. This might '
|
||||
'make for longer page loading times if you have lots of events and the shown status might be out '
|
||||
'of date for up to two minutes.'),
|
||||
required=False
|
||||
)
|
||||
organizer_link_back = forms.BooleanField(
|
||||
label=_('Link back to organizer overview on all event pages'),
|
||||
required=False
|
||||
@@ -255,6 +262,13 @@ class OrganizerDisplaySettingsForm(SettingsForm):
|
||||
],
|
||||
help_text=_('Only respected by modern browsers.')
|
||||
)
|
||||
favicon = ExtFileField(
|
||||
label=_('Favicon'),
|
||||
ext_whitelist=(".ico", ".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. '
|
||||
'We recommend a size of at least 200x200px to accomodate most devices.')
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -57,7 +57,7 @@ class UserEditForm(forms.ModelForm):
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
if User.objects.filter(Q(email=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_identifier'],
|
||||
code='duplicate_identifier',
|
||||
|
||||
@@ -63,7 +63,7 @@ def _display_order_changed(event: Event, logentry: LogEntry):
|
||||
old_item = str(event.items.get(pk=data['old_item']))
|
||||
if data['old_variation']:
|
||||
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
|
||||
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) removed.').format(
|
||||
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) canceled.').format(
|
||||
posid=data.get('positionid', '?'),
|
||||
old_item=old_item,
|
||||
old_price=money_filter(Decimal(data['old_price']), event.currency),
|
||||
@@ -172,6 +172,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.paid': _('The order has been marked as paid.'),
|
||||
'pretix.event.order.refunded': _('The order has been refunded.'),
|
||||
'pretix.event.order.canceled': _('The order has been canceled.'),
|
||||
'pretix.event.order.deleted': _('The test mode order {code} has been deleted.'),
|
||||
'pretix.event.order.placed': _('The order has been created.'),
|
||||
'pretix.event.order.placed.require_approval': _('The order requires approval before it can continue to be processed.'),
|
||||
'pretix.event.order.approved': _('The order has been approved.'),
|
||||
@@ -187,6 +188,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'toggled.'),
|
||||
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
|
||||
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
|
||||
'pretix.event.order.email.error': _('Sending of an email has failed.'),
|
||||
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
|
||||
'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket '
|
||||
'is available for download.'),
|
||||
@@ -211,8 +213,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'),
|
||||
'pretix.event.order.refund.created': _('Refund {local_id} has been created.'),
|
||||
'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'),
|
||||
'pretix.event.order.refund.requested': _('The customer requested you to issue a refund.'),
|
||||
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
|
||||
'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'),
|
||||
'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'),
|
||||
'pretix.control.auth.user.created': _('The user has been created.'),
|
||||
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
|
||||
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
|
||||
@@ -265,6 +269,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.plugins.disabled': _('A plugin has been disabled.'),
|
||||
'pretix.event.live.activated': _('The shop has been taken live.'),
|
||||
'pretix.event.live.deactivated': _('The shop has been taken offline.'),
|
||||
'pretix.event.testmode.activated': _('The shop has been taken into test mode.'),
|
||||
'pretix.event.testmode.deactivated': _('The test mode has been disabled.'),
|
||||
'pretix.event.added': _('The event has been created.'),
|
||||
'pretix.event.changed': _('The event settings have been changed.'),
|
||||
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
|
||||
|
||||
@@ -88,6 +88,14 @@ def get_event_navigation(request: HttpRequest):
|
||||
}),
|
||||
'active': url.url_name == 'event.settings.invoice',
|
||||
},
|
||||
{
|
||||
'label': pgettext_lazy('action', 'Cancellation'),
|
||||
'url': reverse('control:event.settings.cancel', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
}),
|
||||
'active': url.url_name == 'event.settings.cancel',
|
||||
},
|
||||
{
|
||||
'label': _('Widget'),
|
||||
'url': reverse('control:event.settings.widget', kwargs={
|
||||
|
||||
@@ -201,6 +201,16 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
|
||||
Additionally, the argument ``order`` and ``request`` are available.
|
||||
"""
|
||||
|
||||
order_position_buttons = EventPluginSignal(
|
||||
providing_args=["order", "position", "request"]
|
||||
)
|
||||
"""
|
||||
This signal is sent out to display additional buttons for a single position of an order.
|
||||
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
Additionally, the argument ``order`` and ``request`` are available.
|
||||
"""
|
||||
|
||||
nav_event_settings = EventPluginSignal(
|
||||
providing_args=['request']
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% load compress %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load staticfiles %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ settings.PRETIX_INSTANCE_NAME }}</title>
|
||||
<title>{{ django_settings.PRETIX_INSTANCE_NAME }}</title>
|
||||
{% compress css %}
|
||||
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixcontrol/scss/auth.scss" %}"/>
|
||||
{% endcompress %}
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
<meta name="msapplication-TileColor" content="#3b1c4a">
|
||||
<meta name="msapplication-config" content="{% url "presale:browserconfig.xml" %}">
|
||||
<meta name="theme-color" content="#3b1c4a">
|
||||
<meta name="referrer" content="origin">
|
||||
|
||||
{% block custom_header %}{% endblock %}
|
||||
</head>
|
||||
@@ -234,6 +235,14 @@
|
||||
</ul>
|
||||
</div>
|
||||
<ul class="nav" id="side-menu">
|
||||
{% if request.event and request.event.testmode %}
|
||||
<li class="testmode">
|
||||
<a href="{% url "control:event.live" event=request.event.slug organizer=request.organizer.slug %}">
|
||||
<i class="fa fa-warning fa-fw"></i>
|
||||
{% trans "TEST MODE" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% block nav %}
|
||||
{% for nav in nav_items %}
|
||||
<li>
|
||||
@@ -316,6 +325,20 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if complain_testmode_orders %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
Your event contains <strong>test mode orders</strong> even though <strong>test mode has been disabled</strong>.
|
||||
You should delete those orders to make sure they do not show up in your reports and statistics and block people from
|
||||
actually buying tickets.
|
||||
{% endblocktrans %}
|
||||
<strong>
|
||||
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.organizer.slug %}?status=testmode">
|
||||
{% trans "Show all test mode orders" %}
|
||||
</a>
|
||||
</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if warning_update_check_active %}
|
||||
<div class="alert alert-info">
|
||||
<a href="{% url "control:global.update" %}">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<span class="fa fa-download"></span>
|
||||
{% trans "PDF" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.orders.export" event=request.event.slug organizer=request.event.organizer.slug %}?identifier=checkinlistcsv&checkinlistcsv-list={{ checkinlist.pk }}"
|
||||
<a href="{% url "control:event.orders.export" event=request.event.slug organizer=request.event.organizer.slug %}?identifier=checkinlist&checkinlist-list={{ checkinlist.pk }}"
|
||||
class="btn btn-default" target="_blank">
|
||||
<span class="fa fa-download"></span>
|
||||
{% trans "CSV" %}
|
||||
@@ -89,6 +89,9 @@
|
||||
{% if e.order.status == "n" %}
|
||||
<span class="label label-warning">{% trans "unpaid" %}</span>
|
||||
{% endif %}
|
||||
{% if e.order.testmode %}
|
||||
<span class="label label-warning">{% trans "TEST MODE" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ e.item }}{% if e.variation %} – {{ e.variation }}{% endif %}</td>
|
||||
<td>{{ e.order.email }}</td>
|
||||
|
||||
40
src/pretix/control/templates/pretixcontrol/event/cancel.html
Normal file
40
src/pretix/control/templates/pretixcontrol/event/cancel.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Cancellation settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Cancellation of unpaid or free orders" %}</legend>
|
||||
{% bootstrap_field form.cancel_allow_user layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_until layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Cancellation of paid orders" %}</legend>
|
||||
{% bootstrap_field form.cancel_allow_user_paid layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_keep layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_keep_percentage layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_keep_fees layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_until layout="control" %}
|
||||
{% if not gets_notification %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
If a user requests cancels a paid order and the money can not be refunded automatically, e.g.
|
||||
due to the selected payment method, you will need to take manual action. However, you have
|
||||
currently turned off notifications for this event.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:user.settings.notifications" %}" class="btn btn-default">
|
||||
{% trans "Change notification settings" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -23,6 +23,7 @@
|
||||
{% bootstrap_field form.invoice_name_required layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_company_required layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_vatid layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_beneficiary layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Your invoice details" %}</legend>
|
||||
|
||||
@@ -3,49 +3,113 @@
|
||||
{% load bootstrap3 %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Shop status" %}</h1>
|
||||
{% if request.event.live %}
|
||||
<p>
|
||||
{% trans "Your shop is currently live. If you take it down, it will only be visible to you and your team." %}
|
||||
</p>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="live" value="false">
|
||||
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Go offline" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans "Your ticket shop is currently not live. It is thus only visible to you and your team, not to any visitors." %}
|
||||
</p>
|
||||
{% if issues|length > 0 %}
|
||||
<div class="alert alert-warning">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% trans "Shop visibility" %}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if request.event.live %}
|
||||
<p>
|
||||
{% trans "To publish your ticket shop, you first need to resolve the following issues:" %}
|
||||
{% trans "Your shop is currently live. If you take it down, it will only be visible to you and your team." %}
|
||||
</p>
|
||||
<ul>
|
||||
{% for issue in issues %}
|
||||
<li>{{ issue|safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans "If you want to, you can publish your ticket shop now." %}
|
||||
</p>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="live" value="true">
|
||||
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Go live" %}
|
||||
<form action="" method="post" class="text-right">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="live" value="false">
|
||||
<button type="submit" class="btn btn-lg btn-danger btn-save">
|
||||
{% trans "Go offline" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</form>
|
||||
{% else %}
|
||||
{% if issues|length > 0 %}
|
||||
<p>
|
||||
{% trans "Your ticket shop is currently not live. It is thus only visible to you and your team, not to any visitors." %}
|
||||
</p>
|
||||
<div class="alert alert-warning">
|
||||
<p>
|
||||
{% trans "To publish your ticket shop, you first need to resolve the following issues:" %}
|
||||
</p>
|
||||
<ul>
|
||||
{% for issue in issues %}
|
||||
<li>{{ issue|safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="test-right">
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-save" disabled>
|
||||
{% trans "Go live" %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans "Your ticket shop is currently not live. It is thus only visible to you and your team, not to any visitors." %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "If you want to, you can publish your ticket shop now." %}
|
||||
</p>
|
||||
<form action="" method="post" class="text-right">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="live" value="true">
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-save">
|
||||
{% trans "Go live" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% trans "Test mode" %}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if request.event.testmode %}
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="testmode" value="false">
|
||||
<p>
|
||||
{% trans "Your shop is currently in test mode. All orders are not persistant and can be deleted at any point." %}
|
||||
</p>
|
||||
<div class="form-inline">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="delete" value="yes" />
|
||||
{% trans "Permanently delete all orders created in test mode" %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<button type="submit" class="btn btn-lg btn-primary btn-save">
|
||||
{% trans "Disable test mode" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans "Your shop is currently in production mode." %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "If you want to do some test orders, you can enable test mode for your shop. As long as the shop is in test mode, all orders that are created are marked as test orders and can be deleted again." %}
|
||||
<strong>
|
||||
{% trans "Please note that test orders still count into your quotas, actually use vouchers and might perform actual payments. The only difference is that you can delete test orders. Use at your own risk!" %}
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Also, test mode only covers the main web shop. Orders created through other sales channels such as the box office or resellers module are still created as production orders." %}
|
||||
</p>
|
||||
{% if actual_orders %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "It looks like you already have some real orders in your shop. We do not recommend enabling test mode if your customers already know your shop, as it will confuse them." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form action="" method="post" class="text-right">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="testmode" value="true">
|
||||
<button type="submit" class="btn btn-danger btn-lg btn-save">
|
||||
{% trans "Enable test mode" %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
<div class="clear"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
{% bootstrap_field form.payment_term_expire_automatically layout="control" %}
|
||||
{% bootstrap_field form.payment_term_accept_late layout="control" %}
|
||||
{% bootstrap_field form.tax_rate_default layout="control" %}
|
||||
{% bootstrap_field form.payment_explanation layout="control" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Payment settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal form-plugins">
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
{% bootstrap_field sform.order_email_asked_twice layout="control" %}
|
||||
{% bootstrap_field sform.attendee_emails_asked layout="control" %}
|
||||
{% bootstrap_field sform.attendee_emails_required layout="control" %}
|
||||
{% bootstrap_field sform.cancel_allow_user layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Waiting list" %}</legend>
|
||||
@@ -91,6 +90,11 @@
|
||||
<span class="fa fa-eraser"></span>
|
||||
{% trans "Delete personal data" %}
|
||||
</a>
|
||||
<a href="{% url "control:events.add" %}?clone={{ request.event.pk }}"
|
||||
class="btn btn-default btn-lg">
|
||||
<span class="fa fa-copy"></span>
|
||||
{% trans "Clone event" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user