mirror of
https://github.com/pretix/pretix.git
synced 2026-04-29 00:12:38 +00:00
Compare commits
277 Commits
pretixscan
...
release/3.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b704d21e88 | ||
|
|
4bfe0e3784 | ||
|
|
d4d046ca60 | ||
|
|
fb3fc05522 | ||
|
|
fdcd750487 | ||
|
|
92754136a6 | ||
|
|
3b4d39ec27 | ||
|
|
88bb3b483c | ||
|
|
247370839b | ||
|
|
b0510f47b3 | ||
|
|
4fa086bbc5 | ||
|
|
cb03e7c843 | ||
|
|
d012d0804a | ||
|
|
3731d5f431 | ||
|
|
9f04d53564 | ||
|
|
748a389acb | ||
|
|
05a1df244b | ||
|
|
9f7d5156cc | ||
|
|
143fe6c1a6 | ||
|
|
1e3ccc4449 | ||
|
|
d09000b716 | ||
|
|
fc4572767e | ||
|
|
eeedd9a9c2 | ||
|
|
1d0c148170 | ||
|
|
cb37e7435d | ||
|
|
749ddbf21c | ||
|
|
9f6634025f | ||
|
|
ad039ae08e | ||
|
|
eeaaca574d | ||
|
|
bef15ec442 | ||
|
|
447a8b0a8c | ||
|
|
05b4d954d9 | ||
|
|
515d8c4899 | ||
|
|
82497cfb89 | ||
|
|
4ade9d39cd | ||
|
|
4360d5652b | ||
|
|
9fca3188b2 | ||
|
|
0ed48fac7f | ||
|
|
27a32173e6 | ||
|
|
81b6188777 | ||
|
|
9e85d3c94c | ||
|
|
4e58ba7594 | ||
|
|
248493dbf2 | ||
|
|
f1bd240096 | ||
|
|
0b673dc68c | ||
|
|
b2a7fe13da | ||
|
|
d28f735fca | ||
|
|
7a3b450bc6 | ||
|
|
f1ec129c0a | ||
|
|
ce6e46dfd2 | ||
|
|
f296f262e6 | ||
|
|
7f8d290ae1 | ||
|
|
ca0c0f4ae3 | ||
|
|
ac1e69f2d8 | ||
|
|
6338cc69ae | ||
|
|
39eaf3ad6a | ||
|
|
76e75bef65 | ||
|
|
a39822aedc | ||
|
|
73d5a2cec0 | ||
|
|
9f668e5fd6 | ||
|
|
1b92a891d7 | ||
|
|
827925e3c9 | ||
|
|
c0be574974 | ||
|
|
738413e8fd | ||
|
|
2c6125adeb | ||
|
|
3f7807d242 | ||
|
|
59b7f0a03d | ||
|
|
b7dea16db3 | ||
|
|
3a81706aeb | ||
|
|
ad3369b059 | ||
|
|
ab0709558d | ||
|
|
e2e64ac01d | ||
|
|
cf14dcf889 | ||
|
|
a884a25d2b | ||
|
|
8493778028 | ||
|
|
36a276c360 | ||
|
|
795c423d73 | ||
|
|
3ce9ec79f0 | ||
|
|
debc5e255b | ||
|
|
145aa0d7fd | ||
|
|
3997bdd098 | ||
|
|
a51a905512 | ||
|
|
d3ac9e8880 | ||
|
|
07402c9ea0 | ||
|
|
5f81bf55ca | ||
|
|
0120a5a930 | ||
|
|
ba555f956e | ||
|
|
5e3876ddde | ||
|
|
f5719687aa | ||
|
|
18449efcc7 | ||
|
|
3bb20d943a | ||
|
|
586e544fce | ||
|
|
3a4fc69db1 | ||
|
|
8b5d49d82f | ||
|
|
7665faa39f | ||
|
|
027d28e646 | ||
|
|
babcf66a2e | ||
|
|
20f9e88d61 | ||
|
|
9594607a8c | ||
|
|
ad3bcaf43a | ||
|
|
2c4ee3b3c7 | ||
|
|
21451db412 | ||
|
|
12b1f7d90e | ||
|
|
c3901c567e | ||
|
|
0a3eddcd5c | ||
|
|
dfa99cd325 | ||
|
|
62a040255e | ||
|
|
0e2b02c778 | ||
|
|
0f0ed90be9 | ||
|
|
b1e19d776c | ||
|
|
0bcc784aaf | ||
|
|
262fb82237 | ||
|
|
adbe959314 | ||
|
|
02d0a68d57 | ||
|
|
d6985123b4 | ||
|
|
f7a356c340 | ||
|
|
c6265b4517 | ||
|
|
1ee352e114 | ||
|
|
6c830a7d36 | ||
|
|
129c360fff | ||
|
|
b5c7ad92b6 | ||
|
|
33efd8c157 | ||
|
|
f318c8e017 | ||
|
|
319334706d | ||
|
|
1a25138bef | ||
|
|
daa5383b89 | ||
|
|
31333280d2 | ||
|
|
b78f8d70e8 | ||
|
|
a847538a2e | ||
|
|
e3ef9eba9e | ||
|
|
9e4a4402fb | ||
|
|
96ed9f5cf5 | ||
|
|
7216cebce5 | ||
|
|
a3892fd4de | ||
|
|
7718462528 | ||
|
|
60b20829f3 | ||
|
|
e11d03f418 | ||
|
|
69e4db58fd | ||
|
|
9f27a84f52 | ||
|
|
e5c204dc95 | ||
|
|
7fc7dd0163 | ||
|
|
aa99dbc830 | ||
|
|
e3a4ec93fc | ||
|
|
67da6a18a8 | ||
|
|
adc4128f9f | ||
|
|
eed217262f | ||
|
|
4bae824a03 | ||
|
|
be1a1f7995 | ||
|
|
f4b81aa032 | ||
|
|
2559439c4e | ||
|
|
fce9117dfd | ||
|
|
4c8dc8f31c | ||
|
|
b4f69fb13f | ||
|
|
2aed894bd4 | ||
|
|
2ff5416afb | ||
|
|
59f7098a70 | ||
|
|
83dd865b78 | ||
|
|
ebf411b7a0 | ||
|
|
733a4ce8f4 | ||
|
|
ad94263374 | ||
|
|
102772ec55 | ||
|
|
9a826b694f | ||
|
|
bcf8e9cd04 | ||
|
|
200ce93bb4 | ||
|
|
0582a4d9e5 | ||
|
|
1cbab04108 | ||
|
|
98c18b162f | ||
|
|
d972cd4c49 | ||
|
|
985f354293 | ||
|
|
9c23216bd1 | ||
|
|
f8bf44c262 | ||
|
|
6badfdf576 | ||
|
|
c2eba21359 | ||
|
|
5cda04a994 | ||
|
|
bc9d8f5bd8 | ||
|
|
9d6ff20191 | ||
|
|
82684e6df3 | ||
|
|
d681ae4dce | ||
|
|
213e724e18 | ||
|
|
6e0b80706c | ||
|
|
5363f4206e | ||
|
|
9bdb715874 | ||
|
|
90a9709838 | ||
|
|
669b438c91 | ||
|
|
a1353b3773 | ||
|
|
f5c611982a | ||
|
|
e9ab56486a | ||
|
|
74bc495eb7 | ||
|
|
b0b0f7474d | ||
|
|
663ff60d0a | ||
|
|
7bafb0bc76 | ||
|
|
1ab225f40a | ||
|
|
3b09456755 | ||
|
|
d919605d79 | ||
|
|
547f71aac6 | ||
|
|
191729c07a | ||
|
|
8f0a5d859d | ||
|
|
9286ca14f9 | ||
|
|
c5f9a78bdb | ||
|
|
08eb5bfb8f | ||
|
|
804e33b773 | ||
|
|
c264d8bd5b | ||
|
|
cd7be48cf2 | ||
|
|
2290b00161 | ||
|
|
9fb2d3a43b | ||
|
|
d0f3c24b2a | ||
|
|
94e2c2fa3c | ||
|
|
a0e3bbcc82 | ||
|
|
9a9de523e0 | ||
|
|
6dd1c927ef | ||
|
|
51446574e2 | ||
|
|
cfbfb74996 | ||
|
|
527a250435 | ||
|
|
87fb5f06ff | ||
|
|
661cba876f | ||
|
|
be37e3635b | ||
|
|
8bc4793f4e | ||
|
|
1604d0bf7a | ||
|
|
f042932d1d | ||
|
|
bfc6422e6e | ||
|
|
942feb09fc | ||
|
|
b372ce84a5 | ||
|
|
7c0c7202da | ||
|
|
b8bf5ce2d3 | ||
|
|
d25a9d077d | ||
|
|
4a4dad3d5c | ||
|
|
7b6b83eaf4 | ||
|
|
bf54222cac | ||
|
|
d37939bc2a | ||
|
|
4b3f6ba94b | ||
|
|
18c8933c64 | ||
|
|
6a6a84e8c8 | ||
|
|
32edf4b833 | ||
|
|
35ae7e4968 | ||
|
|
b5fb48a55f | ||
|
|
814364fbda | ||
|
|
5bff5053be | ||
|
|
32f4813d33 | ||
|
|
871a677e5e | ||
|
|
95a777516e | ||
|
|
ad8f109e77 | ||
|
|
c60d1c8a5d | ||
|
|
49288ff4e5 | ||
|
|
e90356546f | ||
|
|
a664d51dbc | ||
|
|
79ee851fae | ||
|
|
00905836dc | ||
|
|
e5f57c8ff4 | ||
|
|
c4cbfc726c | ||
|
|
869694a026 | ||
|
|
843f28d94e | ||
|
|
ce35551e97 | ||
|
|
3dae8bcdec | ||
|
|
3763edbc57 | ||
|
|
c1d89284a4 | ||
|
|
609f0b632c | ||
|
|
10aeadf835 | ||
|
|
26726043c2 | ||
|
|
34d1fcf077 | ||
|
|
e83e8cdcc0 | ||
|
|
2dd75ea252 | ||
|
|
4857cfad6e | ||
|
|
55f8e1c123 | ||
|
|
6df1960f79 | ||
|
|
3091139aab | ||
|
|
020c7faaef | ||
|
|
e9b26cc51e | ||
|
|
7948cefee1 | ||
|
|
62195f14be | ||
|
|
5a216b7be9 | ||
|
|
20d79152a6 | ||
|
|
d97a0b1941 | ||
|
|
fe6e65ccb0 | ||
|
|
9886f22b83 | ||
|
|
591ed969b8 | ||
|
|
3ab475ba6d | ||
|
|
307b1a2748 |
@@ -57,6 +57,8 @@ COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||
COPY src /pretix/src
|
||||
|
||||
RUN cd /pretix/src && pip3 install .
|
||||
|
||||
RUN chmod +x /usr/local/bin/pretix && \
|
||||
rm /etc/nginx/sites-enabled/default && \
|
||||
cd /pretix/src && \
|
||||
|
||||
132
doc/api/guides/custom_checkout.rst
Normal file
132
doc/api/guides/custom_checkout.rst
Normal file
@@ -0,0 +1,132 @@
|
||||
Creating an external checkout process
|
||||
=====================================
|
||||
|
||||
Occasionally, we get asked whether it is possible to just use pretix' powerful backend as a ticketing engine but use
|
||||
a fully-customized checkout process that only communicates via the API. This is possible, but with a few limitations.
|
||||
If you go down this route, you will miss out on many of pretix features and safeguards, as well as the added flexibility
|
||||
by most of pretix' plugins. We strongly recommend to talk this through with us before you decide this is the way to go.
|
||||
|
||||
However, this is really useful if you need to tightly integrate pretix into existing web applications that e.g. control
|
||||
the pricing of your products in a way that cannot be mapped to pretix' product structures.
|
||||
|
||||
Creating orders
|
||||
---------------
|
||||
|
||||
After letting your user select the products to buy in your application, you should create a new order object inside
|
||||
pretix. Below, you can see an example of such an order, but most fields are optional and there are some more features
|
||||
supported. Read :ref:`rest-orders-create` to learn more about this endpoint.
|
||||
|
||||
Please note that this endpoint assumes trustworthy input for the most part. By default, the endpoint checks that
|
||||
you do not exceed any quotas, do not sell any seats twice, or do not use any redeemed vouchers. However, it will not
|
||||
complain about violation of any other availability constraints, such as violation of time frames or minimum/maximum
|
||||
amounts of either your product or event. Bundled products will not be added in automatically and fees will not be
|
||||
calculated automatically.
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/democon/events/3vjrh/orders/ HTTP/1.1
|
||||
Host: test.pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Authorization: …
|
||||
|
||||
{
|
||||
"email": "dummy@example.org",
|
||||
"locale": "en",
|
||||
"sales_channel": "web",
|
||||
"payment_provider": "banktransfer",
|
||||
"invoice_address": {
|
||||
"is_business": false,
|
||||
"company": "Sample company",
|
||||
"name_parts": {"full_name": "John Doe"},
|
||||
"street": "Sesam Street 12",
|
||||
"zipcode": "12345",
|
||||
"city": "Sample City",
|
||||
"country": "US",
|
||||
"state": "NY",
|
||||
"internal_reference": "",
|
||||
"vat_id": ""
|
||||
},
|
||||
"positions": [
|
||||
{
|
||||
"item": 21,
|
||||
"variation": null,
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter"
|
||||
},
|
||||
"answers": [
|
||||
{
|
||||
"question": 1,
|
||||
"answer": "23",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"subevent": null
|
||||
}
|
||||
],
|
||||
"fees": []
|
||||
}
|
||||
|
||||
You will be returned a full order object that you can inspect, store, or use to build emails or confirmation pages for
|
||||
the user. If you don't want to do that yourself, it will also contain the URL to our confirmation page in the ``url``
|
||||
attribute. If you pass the ``"send_mail": true`` option, pretix will also send order confirmations for you.
|
||||
|
||||
Handling payments yourself
|
||||
--------------------------
|
||||
|
||||
If you want to handle payments in your application, you can either just create the orders with status "paid" or you can
|
||||
create them in "pending" state (the default) and later confirm the payment. We strongly advise to use the payment
|
||||
provider ``"manual"`` in this case to avoid interference with payment code with pretix.
|
||||
|
||||
However, it is often unfeasible to implement the payment process yourself, and it also requires you to give up a
|
||||
lot of pretix functionality, such as automatic refunds. Therefore, it is also possible to utilize pretix' native
|
||||
payment process even in this case:
|
||||
|
||||
Using pretix payment providers
|
||||
------------------------------
|
||||
|
||||
If you passed a ``payment_provider`` during order creation above, pretix will have created a payment object with state
|
||||
``created`` that you can see in the returned order object. This payment object will have an attribute ``payment_url``
|
||||
that you can use to let the user pay. For example, you could link or redirect to this page.
|
||||
|
||||
If you want the user to return to your application after the payment is complete, you can pass a query parameter
|
||||
``return_url``. To prepare your event for this, open your event in the pretix backend and go to "Settings", then
|
||||
"Plugins". Enable the plugin "Redirection from order page". Then, go to the new page "Settings", then "Redirection".
|
||||
Enter the base URL of your web application. This will allow you to redirect to pages under this base URL later on.
|
||||
For example, if you want users to be redirected to ``https://example.org/order/return?tx_id=1234``, you could now
|
||||
either enter ``https://example.org`` or ``https://example.org/order/``.
|
||||
|
||||
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
|
||||
**regardless of whether it was successful or not**. Make sure you use our API to check if the payment actually
|
||||
worked! Your final URL could look like this::
|
||||
|
||||
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234
|
||||
|
||||
You can also embed this page in an ``<iframe>`` instead. Note, however, that this causes problems with some payment
|
||||
methods such as PayPal which do not allow being opened in an iframe. pretix can partly work around these issues by
|
||||
opening a new window, but will only to so if you also append an ``iframe=1`` parameter to the URL::
|
||||
|
||||
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234&iframe=1
|
||||
|
||||
If you did **not** pass a payment method since you want us to ask the user which payment method they want to use, you
|
||||
need to construct the URL from the ``url`` attribute of the order and the sub-path ``pay/change```. For example, you
|
||||
would end up with the following URL::
|
||||
|
||||
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/change
|
||||
|
||||
Of course, you can also use the ``iframe`` and ``return_url`` parameters here.
|
||||
|
||||
Optional: Cart reservations
|
||||
---------------------------
|
||||
|
||||
Creating orders is an atomic operation: The order is either created as a whole or not at all. However, pretix'
|
||||
built-in checkout automatically reserves tickets in a user's cart for a configurable amount of time to ensure users
|
||||
will actually get their tickets once they started entering all their details. If you want a similar behavior in your
|
||||
application, you need to create :ref:`rest-carts` through the API.
|
||||
|
||||
When creating your order, you can pass a ``consume_carts`` parameter with the cart ID(s) of your user. This way, the
|
||||
quota reserved by the cart will be credited towards the order and the carts will be destroyed if (and only if) the
|
||||
order creation succeeds.
|
||||
|
||||
Cart creation is currently even more limited than the order creation endpoints, as cart creation currently does not
|
||||
support vouchers or automatic price calculation. If you require these features, please get in touch with us.
|
||||
11
doc/api/guides/index.rst
Normal file
11
doc/api/guides/index.rst
Normal file
@@ -0,0 +1,11 @@
|
||||
.. _`rest-api-guides`:
|
||||
|
||||
API Usage Guides
|
||||
================
|
||||
|
||||
This part of the documentation contains how-to guides on some special use cases of our API.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
custom_checkout
|
||||
@@ -18,3 +18,4 @@ in functionality over time.
|
||||
resources/index
|
||||
ratelimit
|
||||
webhooks
|
||||
guides/index
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. spelling:: checkin
|
||||
|
||||
Check-in lists
|
||||
==============
|
||||
|
||||
@@ -27,6 +29,7 @@ subevent integer ID of the date
|
||||
position_count integer Number of tickets that match this list (read-only).
|
||||
checkin_count integer Number of check-ins performed on this list (read-only).
|
||||
include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state.
|
||||
auto_checkin_sales_channels list of strings All items on the check-in list will be automatically marked as checked-in when purchased through any of the listed sales channels.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.10
|
||||
@@ -41,6 +44,10 @@ include_pending boolean If ``true``, th
|
||||
|
||||
The ``include_pending`` field has been added.
|
||||
|
||||
.. versionchanged:: 3.2
|
||||
|
||||
The ``auto_checkin_sales_channels`` field has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -81,7 +88,10 @@ Endpoints
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"subevent": null
|
||||
"subevent": null,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -122,7 +132,10 @@ Endpoints
|
||||
"all_products": true,
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"subevent": null
|
||||
"subevent": null,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -215,7 +228,10 @@ Endpoints
|
||||
"name": "VIP entry",
|
||||
"all_products": false,
|
||||
"limit_products": [1, 2],
|
||||
"subevent": null
|
||||
"subevent": null,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -234,7 +250,10 @@ Endpoints
|
||||
"all_products": false,
|
||||
"limit_products": [1, 2],
|
||||
"include_pending": false,
|
||||
"subevent": null
|
||||
"subevent": null,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/item to create a list for
|
||||
@@ -283,7 +302,10 @@ Endpoints
|
||||
"all_products": false,
|
||||
"limit_products": [1, 2],
|
||||
"include_pending": false,
|
||||
"subevent": null
|
||||
"subevent": null,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
@@ -342,6 +364,11 @@ Order position endpoints
|
||||
``ignore_status`` filter. The ``attendee_name`` field is now "smart" (see below) and the redemption endpoint
|
||||
returns ``400`` instead of ``404`` on tickets which are known but not paid.
|
||||
|
||||
.. versionchanged:: 3.2
|
||||
|
||||
The ``checkins`` dict now also contains a ``auto_checked_in`` value to indicate if the check-in has been performed
|
||||
automatically by the system.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/
|
||||
|
||||
Returns a list of all order positions within a given event. The result is the same as
|
||||
@@ -400,7 +427,8 @@ Order position endpoints
|
||||
"checkins": [
|
||||
{
|
||||
"list": 1,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": true
|
||||
}
|
||||
],
|
||||
"answers": [
|
||||
@@ -510,7 +538,8 @@ Order position endpoints
|
||||
"checkins": [
|
||||
{
|
||||
"list": 1,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": true
|
||||
}
|
||||
],
|
||||
"answers": [
|
||||
|
||||
@@ -44,6 +44,9 @@ available_from datetime The first date
|
||||
(or ``null``).
|
||||
available_until datetime The last date time at which this item can be bought
|
||||
(or ``null``).
|
||||
hidden_if_available integer The internal ID of a quota object, or ``null``. If
|
||||
set, this item won't be shown publicly as long as this
|
||||
quota is available.
|
||||
require_voucher boolean If ``true``, this item can only be bought using a
|
||||
voucher that is specifically assigned to this item.
|
||||
hide_without_voucher boolean If ``true``, this item is only shown during the voucher
|
||||
@@ -72,6 +75,8 @@ generate_tickets boolean If ``false``, t
|
||||
non-admission or add-on product, regardless of event
|
||||
settings. If this is ``null``, regular ticketing
|
||||
rules apply.
|
||||
allow_waitinglist boolean If ``false``, no waiting list will be shown for this
|
||||
product when it is sold out.
|
||||
show_quota_left boolean Publicly show how many tickets are still available.
|
||||
If this is ``null``, the event default is used.
|
||||
has_variations boolean Shows whether or not this item has variations.
|
||||
@@ -146,7 +151,7 @@ bundles list of objects Definition of b
|
||||
|
||||
.. versionchanged:: 3.0
|
||||
|
||||
The ``show_quota_left`` attribute has been added.
|
||||
The ``show_quota_left``, ``allow_waitinglist``, and ``hidden_if_available`` attributes have been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
@@ -205,6 +210,7 @@ Endpoints
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hidden_if_available": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
@@ -213,6 +219,7 @@ Endpoints
|
||||
"checkin_attention": false,
|
||||
"has_variations": false,
|
||||
"generate_tickets": null,
|
||||
"allow_waitinglist": true,
|
||||
"show_quota_left": null,
|
||||
"require_approval": false,
|
||||
"require_bundling": false,
|
||||
@@ -297,10 +304,12 @@ Endpoints
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hidden_if_available": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"generate_tickets": null,
|
||||
"allow_waitinglist": true,
|
||||
"show_quota_left": null,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
@@ -370,10 +379,12 @@ Endpoints
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hidden_if_available": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"generate_tickets": null,
|
||||
"allow_waitinglist": true,
|
||||
"show_quota_left": null,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
@@ -430,12 +441,14 @@ Endpoints
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hidden_if_available": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"generate_tickets": null,
|
||||
"allow_waitinglist": true,
|
||||
"show_quota_left": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
@@ -522,9 +535,11 @@ Endpoints
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"hidden_if_available": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"generate_tickets": null,
|
||||
"allow_waitinglist": true,
|
||||
"show_quota_left": null,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
|
||||
@@ -53,7 +53,9 @@ invoice_address object Invoice address
|
||||
├ street string Customer street
|
||||
├ zipcode string Customer ZIP code
|
||||
├ city string Customer city
|
||||
├ country string Customer country
|
||||
├ country string Customer country code
|
||||
├ state string Customer state (ISO 3166-2 code). Only supported in
|
||||
AU, BR, CA, CN, MY, MX, and US.
|
||||
├ internal_reference string Customer's internal reference to be printed on the invoice
|
||||
├ vat_id string Customer VAT ID
|
||||
└ vat_id_validated string ``true``, if the VAT ID has been validated against the
|
||||
@@ -82,6 +84,7 @@ require_approval boolean If ``true`` and
|
||||
needs approval by an organizer before it can
|
||||
continue. If ``true`` and the order is canceled,
|
||||
this order has been denied by the event organizer.
|
||||
url string The full URL to the order confirmation page
|
||||
payments list of objects List of payment processes (see below)
|
||||
refunds list of objects List of refund processes (see below)
|
||||
last_modified datetime Last modification of this object
|
||||
@@ -137,6 +140,12 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders.
|
||||
|
||||
.. versionchanged:: 3.1:
|
||||
|
||||
The ``invoice_address.state`` and ``url`` attributes have been added. When creating orders through the API,
|
||||
vouchers are now supported and many fields are now optional.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
Order position resource
|
||||
@@ -166,7 +175,8 @@ subevent integer ID of the date
|
||||
pseudonymization_id string A random ID, e.g. for use in lead scanning apps
|
||||
checkins list of objects List of check-ins with this ticket
|
||||
├ list integer Internal ID of the check-in list
|
||||
└ datetime datetime Time of check-in
|
||||
├ datetime datetime Time of check-in
|
||||
└ auto_checked_in boolean Indicates if this check-in been performed automatically by the system
|
||||
downloads list of objects List of ticket download options
|
||||
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
|
||||
└ url string Download URL
|
||||
@@ -205,6 +215,10 @@ pdf_data object Data object req
|
||||
|
||||
The attribute ``seat`` has been added.
|
||||
|
||||
.. versionchanged:: 3.2
|
||||
|
||||
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
|
||||
|
||||
.. _order-payment-resource:
|
||||
|
||||
Order payment resource
|
||||
@@ -221,13 +235,27 @@ amount money (string) Payment amount
|
||||
created datetime Date and time of creation of this payment
|
||||
payment_date datetime Date and time of completion of this payment (or ``null``)
|
||||
provider string Identification string of the payment provider
|
||||
payment_url string The URL where an user can continue with the payment (or ``null``)
|
||||
details object Payment-specific information. This is a dictionary
|
||||
with various fields that can be different between
|
||||
payment providers, versions, payment states, etc. If
|
||||
you read this field, you always need to be able to
|
||||
deal with situations where values that you expect are
|
||||
missing. Mostly, the field contains various IDs that
|
||||
can be used for matching with other systems. If a
|
||||
payment provider does not implement this feature,
|
||||
the object is empty.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 2.0
|
||||
|
||||
This resource has been added.
|
||||
|
||||
.. _order-payment-resource:
|
||||
.. versionchanged:: 3.1
|
||||
|
||||
The attributes ``payment_url`` and ``details`` have been added.
|
||||
|
||||
.. _order-refund-resource:
|
||||
|
||||
Order refund resource
|
||||
---------------------
|
||||
@@ -288,6 +316,7 @@ List of all orders
|
||||
"status": "p",
|
||||
"testmode": false,
|
||||
"secret": "k24fiuwvu8kxz3y1",
|
||||
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
||||
"email": "tester@example.org",
|
||||
"locale": "en",
|
||||
"sales_channel": "web",
|
||||
@@ -310,7 +339,8 @@ List of all orders
|
||||
"street": "Test street 12",
|
||||
"zipcode": "12345",
|
||||
"city": "Testington",
|
||||
"country": "Testikistan",
|
||||
"country": "DE",
|
||||
"state": "",
|
||||
"internal_reference": "",
|
||||
"vat_id": "EU123456789",
|
||||
"vat_id_validated": false
|
||||
@@ -340,7 +370,8 @@ List of all orders
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
],
|
||||
"answers": [
|
||||
@@ -373,6 +404,8 @@ List of all orders
|
||||
"amount": "23.00",
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"payment_date": "2017-12-04T12:13:12Z",
|
||||
"payment_url": null,
|
||||
"details": {},
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
],
|
||||
@@ -431,6 +464,7 @@ Fetching individual orders
|
||||
"status": "p",
|
||||
"testmode": false,
|
||||
"secret": "k24fiuwvu8kxz3y1",
|
||||
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
|
||||
"email": "tester@example.org",
|
||||
"locale": "en",
|
||||
"sales_channel": "web",
|
||||
@@ -453,7 +487,8 @@ Fetching individual orders
|
||||
"street": "Test street 12",
|
||||
"zipcode": "12345",
|
||||
"city": "Testington",
|
||||
"country": "Testikistan",
|
||||
"country": "DE",
|
||||
"state": "",
|
||||
"internal_reference": "",
|
||||
"vat_id": "EU123456789",
|
||||
"vat_id_validated": false
|
||||
@@ -483,7 +518,8 @@ Fetching individual orders
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
],
|
||||
"answers": [
|
||||
@@ -516,6 +552,8 @@ Fetching individual orders
|
||||
"amount": "23.00",
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"payment_date": "2017-12-04T12:13:12Z",
|
||||
"payment_url": null,
|
||||
"details": {},
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
],
|
||||
@@ -691,6 +729,8 @@ Deleting orders
|
||||
: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.
|
||||
|
||||
.. _rest-orders-create:
|
||||
|
||||
Creating orders
|
||||
---------------
|
||||
|
||||
@@ -716,23 +756,17 @@ Creating orders
|
||||
|
||||
* does not validate the number of items per order or the number of times an item can be included in an order
|
||||
|
||||
* does not validate any requirements related to add-on products
|
||||
* does not validate any requirements related to add-on products and does not add bundled products automatically
|
||||
|
||||
* does not check or calculate prices but believes any prices you send
|
||||
|
||||
* does not support the redemption of vouchers
|
||||
* does not check prices but believes any prices you send
|
||||
|
||||
* does not prevent you from buying items that can only be bought with a voucher
|
||||
|
||||
* does not calculate fees
|
||||
* does not calculate fees automatically
|
||||
|
||||
* does not allow to pass data to plugins and will therefore cause issues with some plugins like the shipping
|
||||
module
|
||||
|
||||
* does not send order confirmations via email
|
||||
|
||||
* does not support reverse charge taxation
|
||||
|
||||
* does not support file upload questions
|
||||
|
||||
You can supply the following fields of the resource:
|
||||
@@ -750,9 +784,9 @@ Creating orders
|
||||
* ``email``
|
||||
* ``locale``
|
||||
* ``sales_channel``
|
||||
* ``payment_provider`` – The identifier of the payment provider set for this order. This needs to be an existing
|
||||
payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` for all
|
||||
orders you create as paid.
|
||||
* ``payment_provider`` (optional) – The identifier of the payment provider set for this order. This needs to be an
|
||||
existing payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"``
|
||||
for all orders you create as paid.
|
||||
* ``payment_info`` (optional) – You can pass a nested JSON object that will be set as the internal ``info``
|
||||
value of the payment object that will be created. How this value is handled is up to the payment provider and you
|
||||
should only use this if you know the specific payment provider in detail. Please keep in mind that the payment
|
||||
@@ -770,17 +804,22 @@ Creating orders
|
||||
* ``zipcode``
|
||||
* ``city``
|
||||
* ``country``
|
||||
* ``state``
|
||||
* ``internal_reference``
|
||||
* ``vat_id``
|
||||
* ``vat_id_validated`` (optional) – If you need support for reverse charge (rarely the case), you need to check
|
||||
yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will
|
||||
trigger reverse charge taxation. Don't forget to set ``is_business`` as well!
|
||||
|
||||
* ``positions``
|
||||
|
||||
* ``positionid`` (optional, see below)
|
||||
* ``item``
|
||||
* ``variation``
|
||||
* ``price``
|
||||
* ``price`` (optional, if set to ``null`` or missing the price will be computed from the given product)
|
||||
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
|
||||
* ``attendee_name`` **or** ``attendee_name_parts``
|
||||
* ``voucher`` (optional, the ``code`` attribute of a valid voucher)
|
||||
* ``attendee_email``
|
||||
* ``secret`` (optional)
|
||||
* ``addon_to`` (optional, see below)
|
||||
@@ -800,6 +839,8 @@ Creating orders
|
||||
* ``tax_rule``
|
||||
|
||||
* ``force`` (optional). If set to ``true``, quotas will be ignored.
|
||||
* ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order. Defaults to
|
||||
``false``.
|
||||
|
||||
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
|
||||
to incrementing integers starting with ``1``. Then, you can reference one of these
|
||||
@@ -837,6 +878,7 @@ Creating orders
|
||||
"zipcode": "12345",
|
||||
"city": "Sample City",
|
||||
"country": "UK",
|
||||
"state": "",
|
||||
"internal_reference": "",
|
||||
"vat_id": ""
|
||||
},
|
||||
@@ -860,7 +902,7 @@ Creating orders
|
||||
],
|
||||
"subevent": null
|
||||
}
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -1251,6 +1293,11 @@ List of all order positions
|
||||
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
|
||||
``pseudonymization_id``.
|
||||
|
||||
.. versionchanged:: 3.2
|
||||
|
||||
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
|
||||
|
||||
|
||||
.. note:: Individually canceled order positions are currently not visible via the API at all.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
||||
@@ -1302,7 +1349,8 @@ List of all order positions
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
],
|
||||
"answers": [
|
||||
@@ -1403,7 +1451,8 @@ Fetching individual positions
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
],
|
||||
"answers": [
|
||||
@@ -1546,6 +1595,8 @@ Order payment endpoints
|
||||
"amount": "23.00",
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"payment_date": "2017-12-04T12:13:12Z",
|
||||
"payment_url": null,
|
||||
"details": {},
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
]
|
||||
@@ -1586,6 +1637,8 @@ Order payment endpoints
|
||||
"amount": "23.00",
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"payment_date": "2017-12-04T12:13:12Z",
|
||||
"payment_url": null,
|
||||
"details": {},
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ ask_during_checkin boolean If ``true``, th
|
||||
the ticket instead.
|
||||
hidden boolean If ``true``, the question will only be shown in the
|
||||
backend.
|
||||
print_on_invoice boolean If ``true``, the question will only be shown on
|
||||
invoices.
|
||||
options list of objects In case of question type ``C`` or ``M``, this lists the
|
||||
available objects. Only writable during creation,
|
||||
use separate endpoint to modify this later.
|
||||
@@ -80,6 +82,10 @@ dependency_value string An old version
|
||||
|
||||
The attribute ``dependency_values`` has been added.
|
||||
|
||||
.. versionchanged:: 3.1
|
||||
|
||||
The attribute ``print_on_invoice`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -123,6 +129,7 @@ Endpoints
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"print_on_invoice": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"dependency_values": [],
|
||||
@@ -192,6 +199,7 @@ Endpoints
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"print_on_invoice": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"dependency_values": [],
|
||||
@@ -245,6 +253,7 @@ Endpoints
|
||||
"position": 1,
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"print_on_invoice": false,
|
||||
"dependency_question": null,
|
||||
"dependency_values": [],
|
||||
"options": [
|
||||
@@ -279,6 +288,7 @@ Endpoints
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"print_on_invoice": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"dependency_values": [],
|
||||
@@ -352,6 +362,7 @@ Endpoints
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": false,
|
||||
"hidden": false,
|
||||
"print_on_invoice": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"dependency_values": [],
|
||||
|
||||
@@ -101,9 +101,12 @@ The template is passed the following context variables:
|
||||
The ``Event`` object
|
||||
|
||||
``signature`` (optional, only if configured)
|
||||
The body as markdown (render with ``{{ signature|safe }}``)
|
||||
The signature with event organizer contact details as markdown (render with ``{{ signature|safe }}``)
|
||||
|
||||
``order`` (optional, only if applicable)
|
||||
The ``Order`` object
|
||||
|
||||
``position`` (optional, only if applicable)
|
||||
The ``OrderPosition`` object
|
||||
|
||||
.. _inlinestyler: https://pypi.org/project/inlinestyler/
|
||||
|
||||
@@ -12,7 +12,7 @@ Core
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
|
||||
item_copy_data, register_sales_channels, register_global_settings, quota_availability
|
||||
item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
@@ -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_position_buttons, nav_item, subevent_forms
|
||||
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms, item_formsets
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
|
||||
@@ -12,6 +12,7 @@ Contents:
|
||||
payment
|
||||
payment_2.0
|
||||
email
|
||||
placeholder
|
||||
invoice
|
||||
shredder
|
||||
customview
|
||||
|
||||
@@ -108,6 +108,8 @@ The provider class
|
||||
|
||||
.. automethod:: execute_refund
|
||||
|
||||
.. automethod:: api_payment_details
|
||||
|
||||
.. automethod:: shred_payment_info
|
||||
|
||||
.. autoattribute:: is_implicit
|
||||
|
||||
79
doc/development/api/placeholder.rst
Normal file
79
doc/development/api/placeholder.rst
Normal file
@@ -0,0 +1,79 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
Writing an HTML e-mail placeholder plugin
|
||||
=========================================
|
||||
|
||||
An email placeholder is a dynamic value that pretix users can use in their email templates.
|
||||
|
||||
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
|
||||
|
||||
Placeholder registration
|
||||
------------------------
|
||||
|
||||
The placeholder API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available email placeholders. Your plugin
|
||||
should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``::
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import register_mail_placeholders
|
||||
|
||||
|
||||
@receiver(register_mail_placeholders, dispatch_uid="placeholder_custom")
|
||||
def register_mail_renderers(sender, **kwargs):
|
||||
from .email import MyPlaceholderClass
|
||||
return MyPlaceholder()
|
||||
|
||||
|
||||
Context mechanism
|
||||
-----------------
|
||||
|
||||
Emails are sent in different "contexts" within pretix. For example, many emails are sent in the
|
||||
the context of an order, but some are not, such as the notification of a waiting list voucher.
|
||||
|
||||
Not all placeholders make sense in every email, and placeholders usually depend some parameters
|
||||
themselves, such as the ``Order`` object. Therefore, placeholders are expected to explicitly declare
|
||||
what values they depend on and they will only be available in an email if all those dependencies are
|
||||
met. Currently, placeholders can depend on the following context parameters:
|
||||
|
||||
* ``event``
|
||||
* ``order``
|
||||
* ``position``
|
||||
* ``waiting_list_entry``
|
||||
* ``invoice_address``
|
||||
* ``payment``
|
||||
|
||||
There are a few more that are only to be used internally but not by plugins.
|
||||
|
||||
The placeholder class
|
||||
---------------------
|
||||
|
||||
.. class:: pretix.base.email.BaseMailTextPlaceholder
|
||||
|
||||
.. autoattribute:: identifier
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: required_context
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. automethod:: render
|
||||
|
||||
This is an abstract method, you **must** implement this!
|
||||
|
||||
.. automethod:: render_sample
|
||||
|
||||
This is an abstract method, you **must** implement this!
|
||||
|
||||
Helper class for simple placeholders
|
||||
------------------------------------
|
||||
|
||||
pretix ships with a helper class that makes it easy to provide placeholders based on simple
|
||||
functions::
|
||||
|
||||
placeholder = SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['order'], lambda order: order.code, sample='F8VVL'
|
||||
)
|
||||
|
||||
@@ -35,9 +35,9 @@ The shredder class
|
||||
|
||||
.. class:: pretix.base.shredder.BaseDataShredder
|
||||
|
||||
The central object of each invoice renderer is the subclass of ``BaseInvoiceRenderer``.
|
||||
The central object of each data shredder is the subclass of ``BaseDataShredder``.
|
||||
|
||||
.. py:attribute:: BaseInvoiceRenderer.event
|
||||
.. py:attribute:: BaseDataShredder.event
|
||||
|
||||
The default constructor sets this property to the event we are currently
|
||||
working for.
|
||||
|
||||
@@ -65,7 +65,7 @@ Then, create the local database::
|
||||
python manage.py migrate
|
||||
|
||||
A first user with username ``admin@localhost`` and password ``admin`` will be automatically
|
||||
created.
|
||||
created.
|
||||
|
||||
If you want to see pretix in a different language than English, you have to compile our language
|
||||
files::
|
||||
@@ -81,8 +81,7 @@ To run the local development webserver, execute::
|
||||
and head to http://localhost:8000/
|
||||
|
||||
As we did not implement an overall front page yet, you need to go directly to
|
||||
http://localhost:8000/control/ for the admin view or, if you imported the test
|
||||
data as suggested above, to the event page at http://localhost:8000/bigevents/2019/
|
||||
http://localhost:8000/control/ for the admin view.
|
||||
|
||||
.. note:: If you want the development server to listen on a different interface or
|
||||
port (for example because you develop on `pretixdroid`_), you can check
|
||||
|
||||
@@ -36,6 +36,8 @@ eu
|
||||
filename
|
||||
filesystem
|
||||
fontawesome
|
||||
formset
|
||||
formsets
|
||||
frontend
|
||||
frontpage
|
||||
gettext
|
||||
@@ -44,6 +46,7 @@ guid
|
||||
hardcoded
|
||||
hostname
|
||||
idempotency
|
||||
iframe
|
||||
incrementing
|
||||
inofficial
|
||||
invalidations
|
||||
@@ -102,6 +105,7 @@ screenshot
|
||||
scss
|
||||
searchable
|
||||
selectable
|
||||
serializable
|
||||
serializers
|
||||
serializers
|
||||
sexualized
|
||||
@@ -135,6 +139,7 @@ versa
|
||||
versioning
|
||||
viewset
|
||||
viewsets
|
||||
waitinglist
|
||||
webhook
|
||||
webhooks
|
||||
webserver
|
||||
|
||||
@@ -45,8 +45,8 @@ In addition, you will need quotas. If you do not care how many of your tickets a
|
||||
|
||||
If you want to limit the number of student tickets to 50 to ensure a certain minimum revenue, but do not want to limit the number of regular tickets artificially, we suggest you to create the same quota of 200 that is linked to both products, and then create a **second quota** of 50 that is only linked to the student ticket. This way, the system will reduce both quotas whenever a student ticket is sold and only the larger quota when a regular ticket is sold.
|
||||
|
||||
Use case: Early-bird tiers
|
||||
--------------------------
|
||||
Use case: Early-bird tiers based on dates
|
||||
-----------------------------------------
|
||||
|
||||
Let's say you run a conference that has the following pricing scheme:
|
||||
|
||||
@@ -58,9 +58,53 @@ Of course, you could just set up one product and change its price at the given d
|
||||
|
||||
Create three products (e.g. "super early bird", "early bird", "regular ticket") with the respective prices and one shared quota of your total event capacity. Then, set the **available from** and **available until** configuration fields of the products to automatically turn them on and off based on the current date.
|
||||
|
||||
.. note::
|
||||
Use case: Early-bird tiers based on ticket numbers
|
||||
--------------------------------------------------
|
||||
|
||||
pretix currently can't do early-bird tiers based on **ticket number** instead of time. We're planning this feature for later in 2019. For now, you'll need to monitor that manually.
|
||||
Let's say you run a conference with 400 tickets that has the following pricing scheme:
|
||||
|
||||
* First 100 tickets ("super early bird"): € 450
|
||||
* Next 100 tickets ("early bird"): € 550
|
||||
* Remaining tickets ("regular"): € 650
|
||||
|
||||
First of all, create three products:
|
||||
|
||||
* "Super early bird ticket"
|
||||
* "Early bird ticket"
|
||||
* "Regular ticket"
|
||||
|
||||
Then, create three quotas:
|
||||
|
||||
* "Super early bird" with a **size of 100** and the "Super early bird ticket" product selected. At "Advanced options",
|
||||
select the box "Close this quota permanently once it is sold out".
|
||||
|
||||
* "Early bird and lower" with a **size of 200** and both of the "Super early bird ticket" and "Early bird ticket"
|
||||
products selected. At "Advanced options", select the box "Close this quota permanently once it is sold out".
|
||||
|
||||
* "All participants" with a **size of 400**, all three products selected and **no additional options**.
|
||||
|
||||
Next, modify the product "Regular ticket". In the section "Availability", you should look for the option "Only show
|
||||
after sellout of" and select your quota "Early bird and lower". Do the same for the "Early bird ticket" with the quota
|
||||
"Super early bird ticket".
|
||||
|
||||
This will ensure the following things:
|
||||
|
||||
* Each ticket level is only visible after the previous level is sold out.
|
||||
|
||||
* As soon as one level is really sold out, it's not coming back, because the quota "closes", i.e. locks in place.
|
||||
|
||||
* By creating a total quota of 400 with all tickets included, you can still make sure to sell the maximum number of
|
||||
tickets, even if e.g. early-bird tickets are canceled.
|
||||
|
||||
Optionally, if you want to hide the early bird prices once they are sold out, go to "Settings", then "Display" and
|
||||
select "Hide all products that are sold out". Of course, it might be a nice idea to keep showing the prices to remind
|
||||
people to buy earlier next time ;)
|
||||
|
||||
Please note that there might be short time intervals where the prices switch back and forth: When the last early bird
|
||||
tickets are in someone's cart (but not yet sold!), the early bird tickets will show as "Reserved" and the regular
|
||||
tickets start showing up. However, if the customers holding the reservations do not complete their order,
|
||||
the early bird tickets will become available again. This is not avoidable if we want to prevent malicious users
|
||||
from blocking all the cheap tickets without an actual sale happening.
|
||||
|
||||
Use case: Up-selling of ticket extras
|
||||
-------------------------------------
|
||||
|
||||
@@ -274,6 +274,9 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
};
|
||||
</script>
|
||||
|
||||
In some combinations with Google Tag Manager, the widget does not load this way. In this case, try replacing
|
||||
``tracker.get('clientId')`` with ``ga.getAll()[0].get('clientId')``.
|
||||
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
|
||||
@@ -22,3 +22,5 @@ recursive-include pretix/plugins/ticketoutputpdf/templates *
|
||||
recursive-include pretix/plugins/ticketoutputpdf/static *
|
||||
recursive-include pretix/plugins/badges/templates *
|
||||
recursive-include pretix/plugins/badges/static *
|
||||
recursive-include pretix/plugins/returnurl/templates *
|
||||
recursive-include pretix/plugins/returnurl/static *
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.9.0.dev0"
|
||||
__version__ = "3.2.0"
|
||||
|
||||
@@ -82,6 +82,8 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError('The specified seat does not exist.')
|
||||
except Seat.MultipleObjectsReturned:
|
||||
raise ValidationError('The specified seat ID is not unique.')
|
||||
else:
|
||||
validated_data['seat'] = seat
|
||||
if not seat.is_available():
|
||||
|
||||
@@ -3,6 +3,7 @@ from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.models import CheckinList
|
||||
|
||||
|
||||
@@ -13,7 +14,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = CheckinList
|
||||
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
|
||||
'include_pending')
|
||||
'include_pending', 'auto_checkin_sales_channels')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -35,4 +36,8 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
if full_data.get('subevent'):
|
||||
raise ValidationError(_('The subevent does not belong to this event.'))
|
||||
|
||||
for channel in full_data.get('auto_checkin_sales_channels') or []:
|
||||
if channel not in get_all_sales_channels():
|
||||
raise ValidationError(_('Unknown sales channel.'))
|
||||
|
||||
return data
|
||||
|
||||
@@ -119,7 +119,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||
'show_quota_left')
|
||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def get_serializer_context(self):
|
||||
@@ -219,7 +219,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
model = Question
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'hidden', 'dependency_value')
|
||||
'hidden', 'dependency_value', 'print_on_invoice')
|
||||
|
||||
def validate_identifier(self, value):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import json
|
||||
from collections import Counter
|
||||
from decimal import Decimal
|
||||
|
||||
import pycountry
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy
|
||||
from django_countries.fields import Country
|
||||
@@ -14,13 +17,17 @@ from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
|
||||
OrderPosition, Question, QuestionAnswer, Seat, SubEvent,
|
||||
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, Voucher,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
)
|
||||
from pretix.base.pdf import get_variables
|
||||
from pretix.base.services.cart import error_messages
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
|
||||
class CompatibleCountryField(serializers.Field):
|
||||
@@ -41,8 +48,8 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country',
|
||||
'vat_id', 'vat_id_validated', 'internal_reference')
|
||||
read_only_fields = ('last_modified', 'vat_id_validated')
|
||||
'state', 'vat_id', 'vat_id_validated', 'internal_reference')
|
||||
read_only_fields = ('last_modified',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -57,6 +64,24 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
|
||||
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
|
||||
if data.get('country'):
|
||||
if not pycountry.countries.get(alpha_2=data.get('country')):
|
||||
raise ValidationError(
|
||||
{'country': ['Invalid country code.']}
|
||||
)
|
||||
|
||||
if data.get('state'):
|
||||
cc = str(data.get('country') or self.instance.country or '')
|
||||
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
raise ValidationError(
|
||||
{'state': ['States are not supported in country "{}".'.format(cc)]}
|
||||
)
|
||||
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
|
||||
raise ValidationError(
|
||||
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -89,7 +114,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ('datetime', 'list')
|
||||
fields = ('datetime', 'list', 'auto_checked_in')
|
||||
|
||||
|
||||
class OrderDownloadsField(serializers.Field):
|
||||
@@ -261,10 +286,33 @@ class OrderFeeSerializer(I18nAwareModelSerializer):
|
||||
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule')
|
||||
|
||||
|
||||
class PaymentURLField(serializers.URLField):
|
||||
def to_representation(self, instance: OrderPayment):
|
||||
if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
|
||||
return None
|
||||
return build_absolute_uri(self.context['event'], 'presale:event.order.pay', kwargs={
|
||||
'order': instance.order.code,
|
||||
'secret': instance.order.secret,
|
||||
'payment': instance.pk,
|
||||
})
|
||||
|
||||
|
||||
class PaymentDetailsField(serializers.Field):
|
||||
def to_representation(self, value: OrderPayment):
|
||||
pp = value.payment_provider
|
||||
if not pp:
|
||||
return {}
|
||||
return pp.api_payment_details(value)
|
||||
|
||||
|
||||
class OrderPaymentSerializer(I18nAwareModelSerializer):
|
||||
payment_url = PaymentURLField(source='*', allow_null=True, read_only=True)
|
||||
details = PaymentDetailsField(source='*', allow_null=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderPayment
|
||||
fields = ('local_id', 'state', 'amount', 'created', 'payment_date', 'provider')
|
||||
fields = ('local_id', 'state', 'amount', 'created', 'payment_date', 'provider', 'payment_url',
|
||||
'details')
|
||||
|
||||
|
||||
class OrderRefundSerializer(I18nAwareModelSerializer):
|
||||
@@ -275,6 +323,14 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
|
||||
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider')
|
||||
|
||||
|
||||
class OrderURLField(serializers.URLField):
|
||||
def to_representation(self, instance: Order):
|
||||
return build_absolute_uri(self.context['event'], 'presale:event.order', kwargs={
|
||||
'order': instance.code,
|
||||
'secret': instance.secret,
|
||||
})
|
||||
|
||||
|
||||
class OrderSerializer(I18nAwareModelSerializer):
|
||||
invoice_address = InvoiceAddressSerializer(allow_null=True)
|
||||
positions = OrderPositionSerializer(many=True, read_only=True)
|
||||
@@ -284,13 +340,15 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
refunds = OrderRefundSerializer(many=True, read_only=True)
|
||||
payment_date = OrderPaymentDateField(source='*', read_only=True)
|
||||
payment_provider = OrderPaymentTypeField(source='*', read_only=True)
|
||||
url = OrderURLField(source='*', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
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'
|
||||
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
|
||||
'url'
|
||||
)
|
||||
read_only_fields = (
|
||||
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
|
||||
@@ -329,7 +387,7 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
}
|
||||
try:
|
||||
ia = instance.invoice_address
|
||||
if iadata.get('vat_id') != ia.vat_id:
|
||||
if iadata.get('vat_id') != ia.vat_id and 'vat_id_validated' not in iadata:
|
||||
ia.vat_id_validated = False
|
||||
self.fields['invoice_address'].update(ia, iadata)
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
@@ -437,11 +495,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
secret = serializers.CharField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
seat = serializers.CharField(required=False, allow_null=True)
|
||||
price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2,
|
||||
max_digits=10)
|
||||
voucher = serializers.SlugRelatedField(slug_field='code', queryset=Voucher.objects.none(),
|
||||
required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'secret', 'addon_to', 'subevent', 'answers', 'seat')
|
||||
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher')
|
||||
|
||||
def validate_secret(self, secret):
|
||||
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
@@ -515,7 +577,7 @@ class CompatibleJSONField(serializers.JSONField):
|
||||
|
||||
class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
invoice_address = InvoiceAddressSerializer(required=False)
|
||||
positions = OrderPositionCreateSerializer(many=True, required=False)
|
||||
positions = OrderPositionCreateSerializer(many=True, required=True)
|
||||
fees = OrderFeeCreateSerializer(many=True, required=False)
|
||||
status = serializers.ChoiceField(choices=(
|
||||
('n', Order.STATUS_PENDING),
|
||||
@@ -527,18 +589,26 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
min_length=5
|
||||
)
|
||||
comment = serializers.CharField(required=False, allow_blank=True)
|
||||
payment_provider = serializers.CharField(required=True)
|
||||
payment_provider = serializers.CharField(required=False, allow_null=True)
|
||||
payment_info = CompatibleJSONField(required=False)
|
||||
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
force = serializers.BooleanField(default=False, required=False)
|
||||
payment_date = serializers.DateTimeField(required=False, allow_null=True)
|
||||
send_mail = serializers.BooleanField(default=False, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts', 'force')
|
||||
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
|
||||
'force', 'send_mail')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
if pp is None:
|
||||
return None
|
||||
if pp not in self.context['event'].get_payment_providers():
|
||||
raise ValidationError('The given payment provider is not known.')
|
||||
return pp
|
||||
@@ -608,10 +678,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
def create(self, validated_data):
|
||||
fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
|
||||
positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
|
||||
payment_provider = validated_data.pop('payment_provider')
|
||||
payment_provider = validated_data.pop('payment_provider', None)
|
||||
payment_info = validated_data.pop('payment_info', '{}')
|
||||
payment_date = validated_data.pop('payment_date', now())
|
||||
force = validated_data.pop('force', False)
|
||||
self._send_mail = validated_data.pop('send_mail', False)
|
||||
|
||||
if 'invoice_address' in validated_data:
|
||||
iadata = validated_data.pop('invoice_address')
|
||||
@@ -630,8 +701,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
consume_carts = validated_data.pop('consume_carts', [])
|
||||
delete_cps = []
|
||||
quota_avail_cache = {}
|
||||
voucher_usage = Counter()
|
||||
if consume_carts:
|
||||
for cp in CartPosition.objects.filter(event=self.context['event'], cart_id__in=consume_carts):
|
||||
for cp in CartPosition.objects.filter(
|
||||
event=self.context['event'], cart_id__in=consume_carts, expires__gt=now()
|
||||
):
|
||||
quotas = (cp.variation.quotas.filter(subevent=cp.subevent)
|
||||
if cp.variation else cp.item.quotas.filter(subevent=cp.subevent))
|
||||
for quota in quotas:
|
||||
@@ -639,6 +713,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
quota_avail_cache[quota] = list(quota.availability())
|
||||
if quota_avail_cache[quota][1] is not None:
|
||||
quota_avail_cache[quota][1] += 1
|
||||
if cp.voucher:
|
||||
voucher_usage[cp.voucher] -= 1
|
||||
if cp.expires > now_dt:
|
||||
if cp.seat:
|
||||
free_seats.add(cp.seat)
|
||||
@@ -646,8 +722,55 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
errs = [{} for p in positions_data]
|
||||
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
if pos_data.get('voucher'):
|
||||
v = pos_data['voucher']
|
||||
|
||||
if not v.applies_to(pos_data['item'], pos_data.get('variation')):
|
||||
errs[i]['voucher'] = [error_messages['voucher_invalid_item']]
|
||||
continue
|
||||
|
||||
if v.subevent_id and pos_data.get('subevent').pk != v.subevent_id:
|
||||
errs[i]['voucher'] = [error_messages['voucher_invalid_subevent']]
|
||||
continue
|
||||
|
||||
if v.valid_until is not None and v.valid_until < now_dt:
|
||||
errs[i]['voucher'] = [error_messages['voucher_expired']]
|
||||
continue
|
||||
|
||||
voucher_usage[v] += 1
|
||||
if voucher_usage[v] > 0:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
|
||||
).exclude(pk__in=[cp.pk for cp in delete_cps])
|
||||
v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
|
||||
if v_avail < voucher_usage[v]:
|
||||
errs[i]['voucher'] = [
|
||||
'The voucher has already been used the maximum number of times.'
|
||||
]
|
||||
|
||||
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
|
||||
if pos_data.get('seat'):
|
||||
if not seated:
|
||||
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
errs[i]['seat'] = ['The specified seat does not exist.']
|
||||
else:
|
||||
pos_data['seat'] = seat
|
||||
if (seat not in free_seats and not seat.is_available()) or seat in seats_seen:
|
||||
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
|
||||
seats_seen.add(seat)
|
||||
elif seated:
|
||||
errs[i]['seat'] = ['The specified product requires to choose a seat.']
|
||||
|
||||
if not force:
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
if pos_data.get('voucher'):
|
||||
if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota:
|
||||
continue
|
||||
|
||||
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
if pos_data.get('variation')
|
||||
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
|
||||
@@ -669,23 +792,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
]
|
||||
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
|
||||
if pos_data.get('seat'):
|
||||
if not seated:
|
||||
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
errs[i]['seat'] = ['The specified seat does not exist.']
|
||||
else:
|
||||
pos_data['seat'] = seat
|
||||
if (seat not in free_seats and not seat.is_available()) or seat in seats_seen:
|
||||
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
|
||||
seats_seen.add(seat)
|
||||
elif seated:
|
||||
errs[i]['seat'] = ['The specified product requires to choose a seat.']
|
||||
|
||||
if any(errs):
|
||||
raise ValidationError({'positions': errs})
|
||||
|
||||
@@ -693,38 +799,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
validated_data['locale'] = self.context['event'].settings.locale
|
||||
order = Order(event=self.context['event'], **validated_data)
|
||||
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
||||
order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00'))
|
||||
order.meta_info = "{}"
|
||||
order.total = Decimal('0.00')
|
||||
order.save()
|
||||
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
order.payments.create(
|
||||
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.')
|
||||
elif validated_data.get('status') == Order.STATUS_PAID:
|
||||
order.payments.create(
|
||||
amount=order.total,
|
||||
provider=payment_provider,
|
||||
info=payment_info,
|
||||
payment_date=payment_date,
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
)
|
||||
elif payment_provider:
|
||||
order.payments.create(
|
||||
amount=order.total,
|
||||
provider=payment_provider,
|
||||
info=payment_info,
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||
)
|
||||
|
||||
if ia:
|
||||
ia.order = order
|
||||
ia.save()
|
||||
|
||||
pos_map = {}
|
||||
for pos_data in positions_data:
|
||||
answers_data = pos_data.pop('answers', [])
|
||||
@@ -736,9 +818,27 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
}
|
||||
pos = OrderPosition(**pos_data)
|
||||
pos.order = order
|
||||
pos._calculate_tax()
|
||||
if addon_to:
|
||||
pos.addon_to = pos_map[addon_to]
|
||||
|
||||
if pos.price is None:
|
||||
price = get_price(
|
||||
item=pos.item,
|
||||
variation=pos.variation,
|
||||
voucher=pos.voucher,
|
||||
custom_price=None,
|
||||
subevent=pos.subevent,
|
||||
addon_to=pos.addon_to,
|
||||
invoice_address=ia,
|
||||
)
|
||||
pos.price = price.gross
|
||||
pos.tax_rate = price.rate
|
||||
pos.tax_value = price.tax
|
||||
pos.tax_rule = pos.item.tax_rule
|
||||
else:
|
||||
pos._calculate_tax()
|
||||
if pos.voucher:
|
||||
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
|
||||
pos.save()
|
||||
pos_map[pos.positionid] = pos
|
||||
for answ_data in answers_data:
|
||||
@@ -748,12 +848,43 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
for cp in delete_cps:
|
||||
cp.delete()
|
||||
|
||||
for fee_data in fees_data:
|
||||
f = OrderFee(**fee_data)
|
||||
f.order = order
|
||||
f._calculate_tax()
|
||||
f.save()
|
||||
|
||||
order.total = sum([p.price for p in order.positions.all()]) + sum([f.value for f in order.fees.all()])
|
||||
order.save(update_fields=['total'])
|
||||
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
order.payments.create(
|
||||
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.')
|
||||
elif validated_data.get('status') == Order.STATUS_PAID:
|
||||
if not payment_provider:
|
||||
raise ValidationError('You cannot create a paid order without a payment provider.')
|
||||
order.payments.create(
|
||||
amount=order.total,
|
||||
provider=payment_provider,
|
||||
info=payment_info,
|
||||
payment_date=payment_date,
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
)
|
||||
elif payment_provider:
|
||||
order.payments.create(
|
||||
amount=order.total,
|
||||
provider=payment_provider,
|
||||
info=payment_info,
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||
)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
qs = self.request.event.checkin_lists.prefetch_related(
|
||||
'limit_products',
|
||||
)
|
||||
qs = CheckinList.annotate_with_numbers(qs, self.request.event)
|
||||
return qs
|
||||
|
||||
def perform_create(self, serializer):
|
||||
|
||||
@@ -41,7 +41,8 @@ from pretix.base.services.invoices import (
|
||||
)
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
|
||||
OrderChangeManager, OrderError, _order_placed_email,
|
||||
_order_placed_email_attendee, approve_order, cancel_order, deny_order,
|
||||
extend_order, mark_order_expired, mark_order_refunded,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
@@ -431,6 +432,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
self.perform_create(serializer)
|
||||
send_mail = serializer._send_mail
|
||||
order = serializer.instance
|
||||
serializer = OrderSerializer(order, context=serializer.context)
|
||||
|
||||
@@ -445,8 +447,42 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
(order.event.settings.get('invoice_generate') == 'True') or
|
||||
(order.event.settings.get('invoice_generate') == 'paid' and order.status == Order.STATUS_PAID)
|
||||
) and not order.invoices.last()
|
||||
invoice = None
|
||||
if gen_invoice:
|
||||
generate_invoice(order, trigger_pdf=True)
|
||||
invoice = generate_invoice(order, trigger_pdf=True)
|
||||
|
||||
if send_mail:
|
||||
payment = order.payments.last()
|
||||
free_flow = (
|
||||
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
|
||||
not order.require_approval and payment.provider == "free"
|
||||
)
|
||||
if free_flow:
|
||||
email_template = request.event.settings.mail_text_order_free
|
||||
log_entry = 'pretix.event.order.email.order_free'
|
||||
email_attendees = request.event.settings.mail_send_order_free_attendee
|
||||
email_attendees_template = request.event.settings.mail_text_order_free_attendee
|
||||
else:
|
||||
email_template = request.event.settings.mail_text_order_placed
|
||||
log_entry = 'pretix.event.order.email.order_placed'
|
||||
email_attendees = request.event.settings.mail_send_order_placed_attendee
|
||||
email_attendees_template = request.event.settings.mail_text_order_placed_attendee
|
||||
|
||||
_order_placed_email(
|
||||
request.event, order, payment.payment_provider if payment else None, email_template,
|
||||
log_entry, invoice, payment
|
||||
)
|
||||
if email_attendees:
|
||||
for p in order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry)
|
||||
|
||||
if not free_flow and order.status == Order.STATUS_PAID and payment:
|
||||
payment._send_paid_mail(invoice, None, '')
|
||||
if self.request.event.settings.mail_send_order_paid_attendee:
|
||||
for p in order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
payment._send_paid_mail_attendee(p, None)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@@ -13,7 +13,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import invoice # NOQA
|
||||
from . import notifications # NOQA
|
||||
from . import email # NOQA
|
||||
from .services import auth, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA
|
||||
from .services import auth, checkin, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA
|
||||
|
||||
try:
|
||||
from .celery_app import app as celery_app # NOQA
|
||||
|
||||
80
src/pretix/base/banlist.py
Normal file
80
src/pretix/base/banlist.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import re
|
||||
|
||||
# banlist based on http://www.bannedwordlist.com/lists/swearWords.txt
|
||||
banlist = [
|
||||
"anal",
|
||||
"anus",
|
||||
"arse",
|
||||
"ass",
|
||||
"balls",
|
||||
"bastard",
|
||||
"bitch",
|
||||
"biatch",
|
||||
"bloody",
|
||||
"blowjob",
|
||||
"bollock",
|
||||
"bollok",
|
||||
"boner",
|
||||
"boob",
|
||||
"bugger",
|
||||
"bum",
|
||||
"butt",
|
||||
"clitoris",
|
||||
"cock",
|
||||
"coon",
|
||||
"crap",
|
||||
"cunt",
|
||||
"damn",
|
||||
"dick",
|
||||
"dildo",
|
||||
"dyke",
|
||||
"fag",
|
||||
"feck",
|
||||
"fellate",
|
||||
"fellatio",
|
||||
"felching",
|
||||
"fuck",
|
||||
"fudgepacker",
|
||||
"flange",
|
||||
"goddamn",
|
||||
"hell",
|
||||
"homo",
|
||||
"jerk",
|
||||
"jizz",
|
||||
"knobend",
|
||||
"labia",
|
||||
"lmao",
|
||||
"lmfao",
|
||||
"muff",
|
||||
"nigger",
|
||||
"nigga",
|
||||
"omg",
|
||||
"penis",
|
||||
"piss",
|
||||
"poop",
|
||||
"prick",
|
||||
"pube",
|
||||
"pussy",
|
||||
"queer",
|
||||
"scrotum",
|
||||
"sex",
|
||||
"shit",
|
||||
"sh1t",
|
||||
"slut",
|
||||
"smegma",
|
||||
"spunk",
|
||||
"tit",
|
||||
"tosser",
|
||||
"turd",
|
||||
"twat",
|
||||
"vagina",
|
||||
"wank",
|
||||
"whore",
|
||||
"wtf"
|
||||
]
|
||||
|
||||
blacklist_regex = re.compile('(' + '|'.join(banlist) + ')')
|
||||
|
||||
|
||||
def banned(string):
|
||||
return bool(blacklist_regex.search(string.lower()))
|
||||
13
src/pretix/base/context.py
Normal file
13
src/pretix/base/context.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def contextprocessor(request):
|
||||
ctx = {}
|
||||
if settings.DEBUG and 'runserver' not in sys.argv:
|
||||
ctx['debug_warning'] = True
|
||||
elif 'runserver' in sys.argv:
|
||||
ctx['development_warning'] = True
|
||||
|
||||
return ctx
|
||||
@@ -1,15 +1,23 @@
|
||||
import inspect
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from smtplib import SMTPResponseException
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
from django.dispatch import receiver
|
||||
from django.template.loader import get_template
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
from pretix.base.models import Event, Order, OrderPosition
|
||||
from pretix.base.signals import register_html_mail_renderers
|
||||
from pretix.base.i18n import LazyCurrencyNumber, LazyDate, LazyNumber
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import (
|
||||
register_html_mail_renderers, register_mail_placeholders,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
|
||||
logger = logging.getLogger('pretix.base.email')
|
||||
@@ -44,8 +52,8 @@ class BaseHTMLMailRenderer:
|
||||
def __str__(self):
|
||||
return self.identifier
|
||||
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None,
|
||||
position: OrderPosition=None) -> str:
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order=None,
|
||||
position=None) -> str:
|
||||
"""
|
||||
This method should generate the HTML part of the email.
|
||||
|
||||
@@ -97,7 +105,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
def template_name(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order, position: OrderPosition) -> str:
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str:
|
||||
body_md = markdown_compile_email(plain_body)
|
||||
htmlctx = {
|
||||
'site': settings.PRETIX_INSTANCE_NAME,
|
||||
@@ -136,3 +144,285 @@ class ClassicMailRenderer(TemplateBasedMailRenderer):
|
||||
@receiver(register_html_mail_renderers, dispatch_uid="pretixbase_email_renderers")
|
||||
def base_renderers(sender, **kwargs):
|
||||
return [ClassicMailRenderer]
|
||||
|
||||
|
||||
class BaseMailTextPlaceholder:
|
||||
"""
|
||||
This is the base class for for all email text placeholders.
|
||||
"""
|
||||
|
||||
@property
|
||||
def required_context(self):
|
||||
"""
|
||||
This property should return a list of all attribute names that need to be
|
||||
contained in the base context so that this placeholder is available. By default,
|
||||
it returns a list containing the string "event".
|
||||
"""
|
||||
return ["event"]
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
This should return the identifier of this placeholder in the email.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render(self, context):
|
||||
"""
|
||||
This method is called to generate the actual text that is being
|
||||
used in the email. You will be passed a context dictionary with the
|
||||
base context attributes specified in ``required_context``. You are
|
||||
expected to return a plain-text string.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, event):
|
||||
"""
|
||||
This method is called to generate a text to be used in email previews.
|
||||
This may only depend on the event.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class SimpleFunctionalMailTextPlaceholder(BaseMailTextPlaceholder):
|
||||
def __init__(self, identifier, args, func, sample):
|
||||
self._identifier = identifier
|
||||
self._args = args
|
||||
self._func = func
|
||||
self._sample = sample
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
@property
|
||||
def required_context(self):
|
||||
return self._args
|
||||
|
||||
def render(self, context):
|
||||
return self._func(**{k: context[k] for k in self._args})
|
||||
|
||||
def render_sample(self, event):
|
||||
if callable(self._sample):
|
||||
return self._sample(event)
|
||||
else:
|
||||
return self._sample
|
||||
|
||||
|
||||
def get_available_placeholders(event, base_parameters):
|
||||
if 'order' in base_parameters:
|
||||
base_parameters.append('invoice_address')
|
||||
params = {}
|
||||
for r, val in register_mail_placeholders.send(sender=event):
|
||||
if not isinstance(val, (list, tuple)):
|
||||
val = [val]
|
||||
for v in val:
|
||||
if all(rp in base_parameters for rp in v.required_context):
|
||||
params[v.identifier] = v
|
||||
return params
|
||||
|
||||
|
||||
def get_email_context(**kwargs):
|
||||
from pretix.base.models import InvoiceAddress
|
||||
|
||||
event = kwargs['event']
|
||||
if 'order' in kwargs:
|
||||
try:
|
||||
kwargs['invoice_address'] = kwargs['order'].invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
kwargs['invoice_address'] = InvoiceAddress()
|
||||
ctx = {}
|
||||
for r, val in register_mail_placeholders.send(sender=event):
|
||||
if not isinstance(val, (list, tuple)):
|
||||
val = [val]
|
||||
for v in val:
|
||||
if all(rp in kwargs for rp in v.required_context):
|
||||
ctx[v.identifier] = v.render(kwargs)
|
||||
return ctx
|
||||
|
||||
|
||||
def _placeholder_payment(order, payment):
|
||||
if not payment:
|
||||
return None
|
||||
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
|
||||
return str(payment.payment_provider.order_pending_mail_render(order, payment))
|
||||
else:
|
||||
return str(payment.payment_provider.order_pending_mail_render(order))
|
||||
|
||||
|
||||
@receiver(register_mail_placeholders, dispatch_uid="pretixbase_register_mail_placeholders")
|
||||
def base_placeholders(sender, **kwargs):
|
||||
from pretix.base.models import InvoiceAddress
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
ph = [
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event', ['event'], lambda event: event.name, lambda event: event.name
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['order'], lambda order: order.code, 'F8VVL'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'total', ['order'], lambda order: LazyNumber(order.total), lambda event: LazyNumber(Decimal('42.23'))
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
|
||||
event.currency),
|
||||
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'expire_date', ['event', 'order'], lambda event, order: LazyDate(order.expires.astimezone(event.timezone)),
|
||||
lambda event: LazyDate(now() + timedelta(days=15))
|
||||
# TODO: This used to be "date" in some placeholders, add a migration!
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['order', 'event'], lambda order, event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}
|
||||
), lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.open', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
'hash': '98kusd8ofsj8dnkd'
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.position',
|
||||
kwargs={
|
||||
'order': position.order.code,
|
||||
'secret': position.web_secret,
|
||||
'position': position.positionid
|
||||
}
|
||||
),
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.order.position', kwargs={
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
'position': '123'
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + waiting_list_entry.voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.redeem',
|
||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'invoice_name', ['invoice_address'], lambda invoice_address: invoice_address.name or '',
|
||||
_('John Doe')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
|
||||
_('Sample Corporation')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
order.full_code,
|
||||
build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
)
|
||||
for order in orders
|
||||
), lambda event: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
'{}-{}'.format(event.slug.upper(), order['code']),
|
||||
build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order['code'],
|
||||
'secret': order['secret']
|
||||
}),
|
||||
)
|
||||
for order in [
|
||||
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy'},
|
||||
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd'},
|
||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd'}
|
||||
]
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
|
||||
event.settings.waiting_list_hours,
|
||||
lambda event: event.settings.waiting_list_hours
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'product', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.item.name,
|
||||
_('Sample Admission Ticket')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code,
|
||||
'68CYU2H6ZTP3WLK5'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'comment', ['comment'], lambda comment: comment,
|
||||
_('An individual text with a reason can be inserted here.'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'payment_info', ['order', 'payment'], _placeholder_payment,
|
||||
_('The amount has been charged to your card.'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'payment_info', ['payment_info'], lambda payment_info: payment_info,
|
||||
_('Please transfer money to this bank account: 9999-9999-9999-9999'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'attendee_name', ['position'], lambda position: position.attendee_name,
|
||||
_('John Doe'),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'name', ['position_or_address'],
|
||||
lambda position_or_address: (
|
||||
position_or_address.name
|
||||
if isinstance(position_or_address, InvoiceAddress)
|
||||
else position_or_address.attendee_name
|
||||
),
|
||||
_('John Doe'),
|
||||
),
|
||||
]
|
||||
|
||||
name_scheme = PERSON_NAME_SCHEMES[sender.settings.name_scheme]
|
||||
for f, l, w in name_scheme['fields']:
|
||||
if f == 'full_name':
|
||||
continue
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'attendee_name_%s' % f, ['position'], lambda position, f=f: position.attendee_name_parts.get(f, ''),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'name_%s' % f, ['position_or_address'],
|
||||
lambda position_or_address, f=f: (
|
||||
position_or_address.name_parts.get(f, '')
|
||||
if isinstance(position_or_address, InvoiceAddress)
|
||||
else position_or_address.attendee_name_parts.get(f, '')
|
||||
),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
|
||||
for k, v in sender.meta_data.items():
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
|
||||
v
|
||||
))
|
||||
|
||||
return ph
|
||||
|
||||
@@ -111,7 +111,7 @@ class ListExporter(BaseExporter):
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
def get_filename(self):
|
||||
return 'export.csv'
|
||||
return 'export'
|
||||
|
||||
def _render_csv(self, form_data, output_file=None, **kwargs):
|
||||
if output_file:
|
||||
|
||||
@@ -129,8 +129,11 @@ class DekodiNREIExporter(BaseExporter):
|
||||
'DIDt': invoice.order.datetime.isoformat().replace('Z', '+00:00'),
|
||||
'DT': '30' if invoice.is_cancellation else '10',
|
||||
'EM': invoice.order.email,
|
||||
'FamN': invoice.invoice_to_name.rsplit(' ', 1)[-1],
|
||||
'FN': invoice.invoice_to_name.rsplit(' ', 1)[0] if ' ' in invoice.invoice_to_name else '',
|
||||
'FamN': invoice.invoice_to_name.rsplit(' ', 1)[-1] if invoice.invoice_to_name else '',
|
||||
'FN': (
|
||||
invoice.invoice_to_name.rsplit(' ', 1)[0]
|
||||
if invoice.invoice_to_name and ' ' in invoice.invoice_to_name else ''
|
||||
),
|
||||
'IDt': invoice.date.isoformat() + 'T08:00:00+01:00',
|
||||
'INo': invoice.full_invoice_no,
|
||||
'IsNet': invoice.reverse_charge,
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import pgettext, ugettext as _, ugettext_lazy
|
||||
|
||||
from pretix.base.models import (
|
||||
InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||
InvoiceAddress, InvoiceLine, Order, OrderPosition, Question,
|
||||
)
|
||||
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
@@ -96,7 +96,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(label)
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
|
||||
_('Date of last payment'), _('Fees'), _('Order locale')
|
||||
]
|
||||
|
||||
@@ -109,6 +109,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
headers.append(_('Invoice numbers'))
|
||||
headers.append(_('Sales channel'))
|
||||
headers.append(_('Requires special attention'))
|
||||
headers.append(_('Comment'))
|
||||
|
||||
yield headers
|
||||
|
||||
@@ -153,10 +155,11 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.state,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
|
||||
row += [
|
||||
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
||||
@@ -178,6 +181,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
row.append(', '.join([i.number for i in order.invoices.all()]))
|
||||
row.append(order.sales_channel)
|
||||
row.append(_('Yes') if order.checkin_attention else _('No'))
|
||||
row.append(order.comment or "")
|
||||
yield row
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
@@ -208,7 +213,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
|
||||
]
|
||||
|
||||
yield headers
|
||||
@@ -243,10 +248,11 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.state,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
yield row
|
||||
|
||||
def iterate_positions(self, form_data: dict):
|
||||
@@ -301,7 +307,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
|
||||
]
|
||||
headers.append(_('Sales channel'))
|
||||
|
||||
@@ -339,7 +345,12 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
]
|
||||
acache = {}
|
||||
for a in op.answers.all():
|
||||
acache[a.question_id] = str(a)
|
||||
# We do not want to localize Date, Time and Datetime question answers, as those can lead
|
||||
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
|
||||
if a.question.type in Question.UNLOCALIZED_TYPES:
|
||||
acache[a.question_id] = a.answer
|
||||
else:
|
||||
acache[a.question_id] = str(a)
|
||||
for q in questions:
|
||||
row.append(acache.get(q.pk, ''))
|
||||
try:
|
||||
@@ -358,10 +369,11 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.state,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
row.append(order.sales_channel)
|
||||
yield row
|
||||
|
||||
@@ -503,6 +515,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||
_('Invoice recipient:') + ' ' + _('City'),
|
||||
_('Invoice recipient:') + ' ' + _('Country'),
|
||||
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
|
||||
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
@@ -552,6 +565,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
i.invoice_to_zipcode,
|
||||
i.invoice_to_city,
|
||||
i.invoice_to_country,
|
||||
i.invoice_to_state,
|
||||
i.invoice_to_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
@@ -591,6 +605,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||
_('Invoice recipient:') + ' ' + _('City'),
|
||||
_('Invoice recipient:') + ' ' + _('Country'),
|
||||
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
|
||||
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
@@ -630,6 +645,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
i.invoice_to_zipcode,
|
||||
i.invoice_to_city,
|
||||
i.invoice_to_country,
|
||||
i.invoice_to_state,
|
||||
i.invoice_to_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
|
||||
@@ -14,7 +14,7 @@ class LoginForm(forms.Form):
|
||||
Base class for authenticating users. Extend this to get a form that accepts
|
||||
username/password logins.
|
||||
"""
|
||||
email = forms.EmailField(label=_("E-mail"), max_length=254)
|
||||
email = forms.EmailField(label=_("E-mail"), max_length=254, widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))
|
||||
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
|
||||
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from decimal import Decimal
|
||||
from urllib.error import HTTPError
|
||||
|
||||
import dateutil.parser
|
||||
import pycountry
|
||||
import pytz
|
||||
import vat_moss.errors
|
||||
import vat_moss.id
|
||||
@@ -15,7 +16,9 @@ from django.db.models import QuerySet
|
||||
from django.forms import Select
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import get_language, ugettext_lazy as _
|
||||
from django.utils.translation import (
|
||||
get_language, pgettext_lazy, ugettext_lazy as _,
|
||||
)
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country, CountryField
|
||||
|
||||
@@ -24,8 +27,11 @@ from pretix.base.forms.widgets import (
|
||||
TimePickerWidget, UploadedFileWidget,
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Question, QuestionOption
|
||||
from pretix.base.models.tax import EU_COUNTRIES
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
|
||||
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
|
||||
from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SCHEMES,
|
||||
PERSON_NAME_TITLE_GROUPS,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.control.forms import SplitDateTimeField
|
||||
from pretix.helpers.escapejson import escapejson_attr
|
||||
@@ -37,6 +43,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class NamePartsWidget(forms.MultiWidget):
|
||||
widget = forms.TextInput
|
||||
autofill_map = {
|
||||
'given_name': 'given-name',
|
||||
'family_name': 'family-name',
|
||||
'middle_name': 'additional-name',
|
||||
'title': 'honorific-prefix',
|
||||
'full_name': 'name',
|
||||
'calling_name': 'nickname',
|
||||
}
|
||||
|
||||
def __init__(self, scheme: dict, field: forms.Field, attrs=None, titles: list=None):
|
||||
widgets = []
|
||||
@@ -83,6 +97,7 @@ class NamePartsWidget(forms.MultiWidget):
|
||||
title=self.scheme['fields'][i][1],
|
||||
placeholder=self.scheme['fields'][i][1],
|
||||
)
|
||||
final_attrs['autocomplete'] = (self.attrs.get('autocomplete', '') + ' ' + self.autofill_map.get(self.scheme['fields'][i][0], 'off')).strip()
|
||||
final_attrs['data-size'] = self.scheme['fields'][i][2]
|
||||
output.append(widget.render(name + '_%s' % i, widget_value, final_attrs, renderer=renderer))
|
||||
return mark_safe(self.format_output(output))
|
||||
@@ -188,7 +203,12 @@ class BaseQuestionsForm(forms.Form):
|
||||
self.fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required,
|
||||
label=_('Attendee email'),
|
||||
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email)
|
||||
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
'autocomplete': 'email'
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
for q in questions:
|
||||
@@ -318,6 +338,10 @@ class BaseQuestionsForm(forms.Form):
|
||||
self.fields[key] = value
|
||||
value.initial = data.get('question_form_data', {}).get(key)
|
||||
|
||||
for k, v in self.fields.items():
|
||||
if v.widget.attrs.get('autocomplete') or k == 'attendee_name_parts':
|
||||
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + v.widget.attrs.get('autocomplete', '')
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
|
||||
@@ -356,13 +380,29 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
'internal_reference', 'beneficiary')
|
||||
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'state',
|
||||
'vat_id', 'internal_reference', 'beneficiary')
|
||||
widgets = {
|
||||
'is_business': BusinessBooleanRadio,
|
||||
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
|
||||
'street': forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
'placeholder': _('Street and Number'),
|
||||
'autocomplete': 'street-address'
|
||||
}),
|
||||
'beneficiary': forms.Textarea(attrs={'rows': 3}),
|
||||
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'country': forms.Select(attrs={
|
||||
'autocomplete': 'country',
|
||||
}),
|
||||
'zipcode': forms.TextInput(attrs={
|
||||
'autocomplete': 'postal-code',
|
||||
}),
|
||||
'city': forms.TextInput(attrs={
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
'company': forms.TextInput(attrs={
|
||||
'data-display-dependency': '#id_is_business_1',
|
||||
'autocomplete': 'organization',
|
||||
}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'internal_reference': forms.TextInput,
|
||||
}
|
||||
@@ -400,6 +440,33 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if not event.settings.invoice_address_vatid:
|
||||
del self.fields['vat_id']
|
||||
|
||||
c = [('', pgettext_lazy('address', 'Select state'))]
|
||||
fprefix = self.prefix + '-' if self.prefix else ''
|
||||
cc = None
|
||||
if fprefix + 'country' in self.data:
|
||||
cc = str(self.data[fprefix + 'country'])
|
||||
elif 'country' in self.initial:
|
||||
cc = str(self.initial['country'])
|
||||
elif self.instance and self.instance.country:
|
||||
cc = str(self.instance.country)
|
||||
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
|
||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
|
||||
elif fprefix + 'state' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
self.fields['state'] = forms.ChoiceField(
|
||||
label=pgettext_lazy('address', 'State'),
|
||||
required=False,
|
||||
choices=c,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'address-level1',
|
||||
}),
|
||||
)
|
||||
self.fields['state'].widget.is_required = True
|
||||
|
||||
if not event.settings.invoice_address_required or self.all_optional:
|
||||
for k, f in self.fields.items():
|
||||
f.required = False
|
||||
@@ -433,6 +500,10 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if not event.settings.invoice_address_beneficiary:
|
||||
del self.fields['beneficiary']
|
||||
|
||||
for k, v in self.fields.items():
|
||||
if v.widget.attrs.get('autocomplete') or k == 'name_parts':
|
||||
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '')
|
||||
|
||||
def clean(self):
|
||||
data = self.cleaned_data
|
||||
if not data.get('is_business'):
|
||||
@@ -446,6 +517,10 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if 'vat_id' in self.changed_data or not data.get('vat_id'):
|
||||
self.instance.vat_id_validated = False
|
||||
|
||||
if data.get('city') and data.get('country') and str(data['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
if not data.get('state'):
|
||||
self.add_error('state', _('This field is required.'))
|
||||
|
||||
self.instance.name_parts = data.get('name_parts')
|
||||
|
||||
if all(
|
||||
@@ -457,7 +532,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
|
||||
if data.get('vat_id')[:2] != str(data.get('country')):
|
||||
if data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))):
|
||||
raise ValidationError(_('Your VAT ID does not match the selected country.'))
|
||||
try:
|
||||
result = vat_moss.id.validate(data.get('vat_id'))
|
||||
@@ -488,9 +563,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
|
||||
class BaseInvoiceNameForm(BaseInvoiceAddressForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for f in list(self.fields.keys()):
|
||||
if f != 'name':
|
||||
if f != 'name_parts':
|
||||
del self.fields[f]
|
||||
|
||||
@@ -115,5 +115,5 @@ class User2FADeviceAddForm(forms.Form):
|
||||
name = forms.CharField(label=_('Device name'), max_length=64)
|
||||
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
|
||||
('totp', _('Smartphone with the Authenticator application')),
|
||||
('u2f', _('U2F-compatible hardware token (e.g. Yubikey)')),
|
||||
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
|
||||
))
|
||||
|
||||
@@ -26,7 +26,7 @@ class PlaceholderValidator(BaseValidator):
|
||||
if value.count('{') != value.count('}'):
|
||||
raise ValidationError(
|
||||
_('Invalid placeholder syntax: You used a different number of "{" than of "}".'),
|
||||
code='invalid',
|
||||
code='invalid_placeholder_syntax',
|
||||
)
|
||||
|
||||
data_placeholders = list(re.findall(r'({[^}]*})', value, re.X))
|
||||
@@ -37,7 +37,7 @@ class PlaceholderValidator(BaseValidator):
|
||||
if invalid_placeholders:
|
||||
raise ValidationError(
|
||||
_('Invalid placeholder(s): %(value)s'),
|
||||
code='invalid',
|
||||
code='invalid_placeholders',
|
||||
params={'value': ", ".join(invalid_placeholders,)})
|
||||
|
||||
def clean(self, x):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
@@ -8,5 +9,12 @@ class Command(BaseCommand):
|
||||
help = "Run periodic tasks"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
periodic_task.send(self)
|
||||
for recv, resp in periodic_task.send_robust(self):
|
||||
if isinstance(resp, Exception):
|
||||
if settings.SENTRY_ENABLED:
|
||||
from sentry_sdk import capture_exception
|
||||
capture_exception(resp)
|
||||
else:
|
||||
raise resp
|
||||
|
||||
call_command('clearsessions')
|
||||
|
||||
21
src/pretix/base/migrations/0129_auto_20190724_1548.py
Normal file
21
src/pretix/base/migrations/0129_auto_20190724_1548.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 2.2.1 on 2019-07-24 15:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0128_auto_20190715_1510'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='hidden_if_available',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='pretixbase.Quota'),
|
||||
),
|
||||
]
|
||||
31
src/pretix/base/migrations/0130_auto_20190729_1311.py
Normal file
31
src/pretix/base/migrations/0130_auto_20190729_1311.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 2.2.1 on 2019-07-29 13:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0129_auto_20190724_1548'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='seat',
|
||||
name='row_name',
|
||||
field=models.CharField(default='', max_length=190),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='seat',
|
||||
name='seat_number',
|
||||
field=models.CharField(default='', max_length=190),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='seat',
|
||||
name='zone_name',
|
||||
field=models.CharField(default='', max_length=190),
|
||||
),
|
||||
]
|
||||
21
src/pretix/base/migrations/0131_auto_20190729_1422.py
Normal file
21
src/pretix/base/migrations/0131_auto_20190729_1422.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 2.2.1 on 2019-07-29 14:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0130_auto_20190729_1311'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='allow_waitinglist',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
27
src/pretix/base/migrations/0132_auto_20190808_1253.py
Normal file
27
src/pretix/base/migrations/0132_auto_20190808_1253.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.2.1 on 2019-08-08 12:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0131_auto_20190729_1422'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_state',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='state',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
21
src/pretix/base/migrations/0133_auto_20190830_1513.py
Normal file
21
src/pretix/base/migrations/0133_auto_20190830_1513.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 2.2.4 on 2019-08-30 15:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0132_auto_20190808_1253'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='print_on_invoice',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
35
src/pretix/base/migrations/0134_auto_20190909_1042.py
Normal file
35
src/pretix/base/migrations/0134_auto_20190909_1042.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 2.2.4 on 2019-09-09 10:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0133_auto_20190830_1513'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WebAuthnDevice',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=64)),
|
||||
('confirmed', models.BooleanField(default=True)),
|
||||
('credential_id', models.CharField(max_length=255, null=True)),
|
||||
('rp_id', models.CharField(max_length=255, null=True)),
|
||||
('icon_url', models.CharField(max_length=255, null=True)),
|
||||
('ukey', models.TextField(null=True)),
|
||||
('pub_key', models.TextField(null=True)),
|
||||
('sign_count', models.IntegerField(default=0)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
27
src/pretix/base/migrations/0135_auto_20191007_0803.py
Normal file
27
src/pretix/base/migrations/0135_auto_20191007_0803.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.2.4 on 2019-10-07 08:03
|
||||
from django.core.cache import cache
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def mail_migrator(app, schema_editor):
|
||||
Event_SettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
|
||||
|
||||
for ss in Event_SettingsStore.objects.filter(
|
||||
key__in=['mail_text_order_approved', 'mail_text_order_placed', 'mail_text_order_placed_require_approval']
|
||||
):
|
||||
chgd = ss.value.replace("{date}", "{expire_date}")
|
||||
if chgd != ss.value:
|
||||
ss.value = chgd
|
||||
ss.save()
|
||||
cache.delete('hierarkey_{}_{}'.format('event', ss.object_id))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0134_auto_20190909_1042'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(mail_migrator, migrations.RunPython.noop)
|
||||
]
|
||||
25
src/pretix/base/migrations/0136_auto_20190918_1742.py
Normal file
25
src/pretix/base/migrations/0136_auto_20190918_1742.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 2.2 on 2019-09-18 17:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0135_auto_20191007_0803'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='auto_checked_in',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkinlist',
|
||||
name='auto_checkin_sales_channels',
|
||||
field=pretix.base.models.fields.MultiStringField(default=[]),
|
||||
)
|
||||
]
|
||||
@@ -1,5 +1,5 @@
|
||||
from ..settings import GlobalSettingsObject_SettingsStore
|
||||
from .auth import U2FDevice, User
|
||||
from .auth import U2FDevice, User, WebAuthnDevice
|
||||
from .base import CachedFile, LoggedModel, cachedfile_name
|
||||
from .checkin import Checkin, CheckinList
|
||||
from .devices import Device
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import binascii
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import webauthn
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser, BaseUserManager, PermissionsMixin,
|
||||
@@ -13,6 +17,9 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
from django_scopes import scopes_disabled
|
||||
from u2flib_server.utils import (
|
||||
pub_key_from_der, websafe_decode, websafe_encode,
|
||||
)
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
@@ -176,6 +183,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
'url': build_absolute_uri('control:user.settings')
|
||||
},
|
||||
event=None,
|
||||
user=self,
|
||||
locale=self.locale
|
||||
)
|
||||
except SendMailException:
|
||||
@@ -191,9 +199,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
'url': (build_absolute_uri('control:auth.forgot.recover')
|
||||
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
|
||||
},
|
||||
None, locale=self.locale
|
||||
None, locale=self.locale, user=self
|
||||
)
|
||||
|
||||
@property
|
||||
def top_logentries(self):
|
||||
return self.all_logentries
|
||||
|
||||
@property
|
||||
def all_logentries(self):
|
||||
from pretix.base.models import LogEntry
|
||||
@@ -375,3 +387,49 @@ class StaffSessionAuditLog(models.Model):
|
||||
|
||||
class U2FDevice(Device):
|
||||
json_data = models.TextField()
|
||||
|
||||
@property
|
||||
def webauthnuser(self):
|
||||
d = json.loads(self.json_data)
|
||||
# We manually need to convert the pubkey from DER format (used in our
|
||||
# former U2F implementation) to the format required by webauthn. This
|
||||
# is based on the following example:
|
||||
# https://www.w3.org/TR/webauthn/#sctn-encoded-credPubKey-examples
|
||||
pub_key = pub_key_from_der(websafe_decode(d['publicKey'].replace('+', '-').replace('/', '_')))
|
||||
pub_key = binascii.unhexlify(
|
||||
'A5010203262001215820{:064x}225820{:064x}'.format(
|
||||
pub_key.public_numbers().x, pub_key.public_numbers().y
|
||||
)
|
||||
)
|
||||
return webauthn.WebAuthnUser(
|
||||
d['keyHandle'],
|
||||
self.user.email,
|
||||
str(self.user),
|
||||
settings.SITE_URL,
|
||||
d['keyHandle'],
|
||||
websafe_encode(pub_key),
|
||||
1,
|
||||
urlparse(settings.SITE_URL).netloc
|
||||
)
|
||||
|
||||
|
||||
class WebAuthnDevice(Device):
|
||||
credential_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
rp_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
icon_url = models.CharField(max_length=255, null=True, blank=True)
|
||||
ukey = models.TextField(null=True)
|
||||
pub_key = models.TextField(null=True)
|
||||
sign_count = models.IntegerField(default=0)
|
||||
|
||||
@property
|
||||
def webauthnuser(self):
|
||||
return webauthn.WebAuthnUser(
|
||||
self.ukey,
|
||||
self.user.email,
|
||||
str(self.user),
|
||||
settings.SITE_URL,
|
||||
self.credential_id,
|
||||
self.pub_key,
|
||||
self.sign_count,
|
||||
urlparse(settings.SITE_URL).netloc
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from django.db import models
|
||||
from django.db.models import Case, Count, F, OuterRef, Q, Subquery, When
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import Exists, OuterRef
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
|
||||
|
||||
class CheckinList(LoggedModel):
|
||||
@@ -18,142 +18,69 @@ class CheckinList(LoggedModel):
|
||||
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
|
||||
default=False,
|
||||
help_text=_('With this option, people will be able to check in even if the '
|
||||
'order have not been paid. This only works with pretixdesk '
|
||||
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
|
||||
'order have not been paid.'))
|
||||
|
||||
auto_checkin_sales_channels = MultiStringField(
|
||||
default=[],
|
||||
blank=True,
|
||||
verbose_name=_('Sales channels to automatically check in'),
|
||||
help_text=_('All items on this check-in list will be automatically marked as checked-in when purchased through '
|
||||
'any of the selected sales channels. This option can be useful when tickets sold at the box office '
|
||||
'are not checked again before entry and should be considered validated directly upon purchase.')
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
ordering = ('subevent__date_from', 'name')
|
||||
|
||||
@property
|
||||
def positions(self):
|
||||
from . import OrderPosition, Order
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.event,
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [Order.STATUS_PAID],
|
||||
subevent=self.subevent
|
||||
)
|
||||
if not self.all_products:
|
||||
qs = qs.filter(item__in=self.limit_products.values_list('id', flat=True))
|
||||
return qs
|
||||
|
||||
@property
|
||||
def checkin_count(self):
|
||||
return self.event.cache.get_or_set(
|
||||
'checkin_list_{}_checkin_count'.format(self.pk),
|
||||
lambda: self.positions.annotate(
|
||||
checkedin=Exists(Checkin.objects.filter(list_id=self.pk, position=OuterRef('pk')))
|
||||
).filter(
|
||||
checkedin=True
|
||||
).count(),
|
||||
60
|
||||
)
|
||||
|
||||
@property
|
||||
def percent(self):
|
||||
pc = self.position_count
|
||||
return round(self.checkin_count * 100 / pc) if pc else 0
|
||||
|
||||
@property
|
||||
def position_count(self):
|
||||
return self.event.cache.get_or_set(
|
||||
'checkin_list_{}_position_count'.format(self.pk),
|
||||
lambda: self.positions.count(),
|
||||
60
|
||||
)
|
||||
|
||||
def touch(self):
|
||||
self.event.cache.delete('checkin_list_{}_position_count'.format(self.pk))
|
||||
self.event.cache.delete('checkin_list_{}_checkin_count'.format(self.pk))
|
||||
|
||||
@staticmethod
|
||||
def annotate_with_numbers(qs, event):
|
||||
"""
|
||||
Modifies a queryset of checkin lists by annotating it with the number of order positions and
|
||||
checkins associated with it.
|
||||
"""
|
||||
# Import here to prevent circular import
|
||||
from . import Order, OrderPosition, Item
|
||||
|
||||
# This is the mother of all subqueries. Sorry. I try to explain it, at least?
|
||||
# First, we prepare a subquery that for every check-in that belongs to a paid-order
|
||||
# position and to the list in question. Then, we check that it also belongs to the
|
||||
# correct subevent (just to be sure) and aggregate over lists (so, over everything,
|
||||
# since we filtered by lists).
|
||||
cqs_paid = Checkin.objects.filter(
|
||||
position__order__event=event,
|
||||
position__order__status=Order.STATUS_PAID,
|
||||
list=OuterRef('pk')
|
||||
).filter(
|
||||
# This assumes that in an event with subevents, *all* positions have subevents
|
||||
# and *all* checkin lists have a subevent assigned
|
||||
Q(position__subevent=OuterRef('subevent'))
|
||||
| (Q(position__subevent__isnull=True))
|
||||
).order_by().values('list').annotate(
|
||||
c=Count('*')
|
||||
).values('c')
|
||||
cqs_paid_and_pending = Checkin.objects.filter(
|
||||
position__order__event=event,
|
||||
position__order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
|
||||
list=OuterRef('pk')
|
||||
).filter(
|
||||
# This assumes that in an event with subevents, *all* positions have subevents
|
||||
# and *all* checkin lists have a subevent assigned
|
||||
Q(position__subevent=OuterRef('subevent'))
|
||||
| (Q(position__subevent__isnull=True))
|
||||
).order_by().values('list').annotate(
|
||||
c=Count('*')
|
||||
).values('c')
|
||||
|
||||
# Now for the hard part: getting all order positions that contribute to this list. This
|
||||
# requires us to use TWO subqueries. The first one, pqs_all, will only be used for check-in
|
||||
# lists that contain all the products of the event. This is the simpler one, it basically
|
||||
# looks like the check-in counter above.
|
||||
pqs_all_paid = OrderPosition.objects.filter(
|
||||
order__event=event,
|
||||
order__status=Order.STATUS_PAID,
|
||||
).filter(
|
||||
# This assumes that in an event with subevents, *all* positions have subevents
|
||||
# and *all* checkin lists have a subevent assigned
|
||||
Q(subevent=OuterRef('subevent'))
|
||||
| (Q(subevent__isnull=True))
|
||||
).order_by().values('order__event').annotate(
|
||||
c=Count('*')
|
||||
).values('c')
|
||||
pqs_all_paid_and_pending = OrderPosition.objects.filter(
|
||||
order__event=event,
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING]
|
||||
).filter(
|
||||
# This assumes that in an event with subevents, *all* positions have subevents
|
||||
# and *all* checkin lists have a subevent assigned
|
||||
Q(subevent=OuterRef('subevent'))
|
||||
| (Q(subevent__isnull=True))
|
||||
).order_by().values('order__event').annotate(
|
||||
c=Count('*')
|
||||
).values('c')
|
||||
|
||||
# Now we need a subquery for the case of checkin lists that are limited to certain
|
||||
# products. We cannot use OuterRef("limit_products") since that would do a cross-product
|
||||
# with the products table and we'd get duplicate rows in the output with different annotations
|
||||
# on them, which isn't useful at all. Therefore, we need to add a second layer of subqueries
|
||||
# to retrieve all of those items and then check if the item_id is IN this subquery result.
|
||||
pqs_limited_paid = OrderPosition.objects.filter(
|
||||
order__event=event,
|
||||
order__status=Order.STATUS_PAID,
|
||||
item_id__in=Subquery(Item.objects.filter(checkinlist__pk=OuterRef(OuterRef('pk'))).values('pk'))
|
||||
).filter(
|
||||
# This assumes that in an event with subevents, *all* positions have subevents
|
||||
# and *all* checkin lists have a subevent assigned
|
||||
Q(subevent=OuterRef('subevent'))
|
||||
| (Q(subevent__isnull=True))
|
||||
).order_by().values('order__event').annotate(
|
||||
c=Count('*')
|
||||
).values('c')
|
||||
pqs_limited_paid_and_pending = OrderPosition.objects.filter(
|
||||
order__event=event,
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
|
||||
item_id__in=Subquery(Item.objects.filter(checkinlist__pk=OuterRef(OuterRef('pk'))).values('pk'))
|
||||
).filter(
|
||||
# This assumes that in an event with subevents, *all* positions have subevents
|
||||
# and *all* checkin lists have a subevent assigned
|
||||
Q(subevent=OuterRef('subevent'))
|
||||
| (Q(subevent__isnull=True))
|
||||
).order_by().values('order__event').annotate(
|
||||
c=Count('*')
|
||||
).values('c')
|
||||
|
||||
# Finally, we put all of this together. We force empty subquery aggregates to 0 by using Coalesce()
|
||||
# and decide which subquery to use for this row. In the end, we compute an integer percentage in case
|
||||
# we want to display a progress bar.
|
||||
return qs.annotate(
|
||||
checkin_count=Coalesce(
|
||||
Case(
|
||||
When(include_pending=True, then=Subquery(cqs_paid_and_pending, output_field=models.IntegerField())),
|
||||
default=Subquery(cqs_paid, output_field=models.IntegerField()),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
0
|
||||
),
|
||||
position_count=Coalesce(
|
||||
Case(
|
||||
When(all_products=True, include_pending=False,
|
||||
then=Subquery(pqs_all_paid, output_field=models.IntegerField())),
|
||||
When(all_products=True, include_pending=True,
|
||||
then=Subquery(pqs_all_paid_and_pending, output_field=models.IntegerField())),
|
||||
When(all_products=False, include_pending=False,
|
||||
then=Subquery(pqs_limited_paid, output_field=models.IntegerField())),
|
||||
default=Subquery(pqs_limited_paid_and_pending, output_field=models.IntegerField()),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
0
|
||||
)
|
||||
).annotate(
|
||||
percent=Case(
|
||||
When(position_count__gt=0, then=F('checkin_count') * 100 / F('position_count')),
|
||||
default=0,
|
||||
output_field=models.IntegerField()
|
||||
)
|
||||
)
|
||||
# This is only kept for backwards-compatibility reasons. This method used to precompute .position_count
|
||||
# and .checkin_count through a huge subquery chain, but was dropped for performance reasons.
|
||||
return qs
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -169,6 +96,7 @@ class Checkin(models.Model):
|
||||
list = models.ForeignKey(
|
||||
'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT,
|
||||
)
|
||||
auto_checked_in = models.BooleanField(default=False)
|
||||
|
||||
objects = ScopedManager(organizer='position__order__event__organizer')
|
||||
|
||||
@@ -182,8 +110,11 @@ class Checkin(models.Model):
|
||||
|
||||
def save(self, **kwargs):
|
||||
self.position.order.touch()
|
||||
self.list.event.cache.delete('checkin_count')
|
||||
self.list.touch()
|
||||
super().save(**kwargs)
|
||||
|
||||
def delete(self, **kwargs):
|
||||
self.position.order.touch()
|
||||
super().delete(**kwargs)
|
||||
self.list.touch()
|
||||
|
||||
@@ -65,7 +65,7 @@ class EventMixin:
|
||||
"SHORT_DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
|
||||
)
|
||||
|
||||
def get_date_from_display(self, tz=None, show_times=True) -> str:
|
||||
def get_date_from_display(self, tz=None, show_times=True, short=False) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start date of the event with respect
|
||||
to the current locale and to the ``show_times`` setting.
|
||||
@@ -73,7 +73,7 @@ class EventMixin:
|
||||
tz = tz or self.timezone
|
||||
return _date(
|
||||
self.date_from.astimezone(tz),
|
||||
"DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT"
|
||||
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT")
|
||||
)
|
||||
|
||||
def get_time_from_display(self, tz=None) -> str:
|
||||
@@ -86,7 +86,7 @@ class EventMixin:
|
||||
self.date_from.astimezone(tz), "TIME_FORMAT"
|
||||
)
|
||||
|
||||
def get_date_to_display(self, tz=None) -> str:
|
||||
def get_date_to_display(self, tz=None, short=False) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start date of the event with respect
|
||||
to the current locale and to the ``show_times`` setting. Returns an empty string
|
||||
@@ -97,7 +97,7 @@ class EventMixin:
|
||||
return ""
|
||||
return _date(
|
||||
self.date_to.astimezone(tz),
|
||||
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
|
||||
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT")
|
||||
)
|
||||
|
||||
def get_date_range_display(self, tz=None, force_show_end=False) -> str:
|
||||
@@ -516,6 +516,7 @@ class Event(EventMixin, LoggedModel):
|
||||
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
|
||||
items = list(q.items.all())
|
||||
vars = list(q.variations.all())
|
||||
oldid = q.pk
|
||||
q.pk = None
|
||||
q.event = self
|
||||
q.cached_availability_state = None
|
||||
@@ -529,6 +530,7 @@ class Event(EventMixin, LoggedModel):
|
||||
q.items.add(item_map[i.pk])
|
||||
for v in vars:
|
||||
q.variations.add(variation_map[v.pk])
|
||||
self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q)
|
||||
|
||||
question_map = {}
|
||||
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
|
||||
@@ -606,22 +608,24 @@ class Event(EventMixin, LoggedModel):
|
||||
question_map=question_map
|
||||
)
|
||||
|
||||
def get_payment_providers(self) -> dict:
|
||||
def get_payment_providers(self, cached=False) -> dict:
|
||||
"""
|
||||
Returns a dictionary of initialized payment providers mapped by their identifiers.
|
||||
"""
|
||||
from ..signals import register_payment_providers
|
||||
|
||||
responses = register_payment_providers.send(self)
|
||||
providers = {}
|
||||
for receiver, response in responses:
|
||||
if not isinstance(response, list):
|
||||
response = [response]
|
||||
for p in response:
|
||||
pp = p(self)
|
||||
providers[pp.identifier] = pp
|
||||
if not cached or not hasattr(self, '_cached_payment_providers'):
|
||||
responses = register_payment_providers.send(self)
|
||||
providers = {}
|
||||
for receiver, response in responses:
|
||||
if not isinstance(response, list):
|
||||
response = [response]
|
||||
for p in response:
|
||||
pp = p(self)
|
||||
providers[pp.identifier] = pp
|
||||
|
||||
return OrderedDict(sorted(providers.items(), key=lambda v: str(v[1].verbose_name)))
|
||||
self._cached_payment_providers = OrderedDict(sorted(providers.items(), key=lambda v: str(v[1].verbose_name)))
|
||||
return self._cached_payment_providers
|
||||
|
||||
def get_html_mail_renderer(self):
|
||||
"""
|
||||
@@ -822,18 +826,24 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
def enable_plugin(self, module, allow_restricted=False):
|
||||
plugins_active = self.get_plugins()
|
||||
from pretix.presale.style import regenerate_css
|
||||
|
||||
if module not in plugins_active:
|
||||
plugins_active.append(module)
|
||||
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
|
||||
|
||||
regenerate_css.apply_async(args=(self.pk,))
|
||||
|
||||
def disable_plugin(self, module):
|
||||
plugins_active = self.get_plugins()
|
||||
from pretix.presale.style import regenerate_css
|
||||
|
||||
if module in plugins_active:
|
||||
plugins_active.remove(module)
|
||||
self.set_active_plugins(plugins_active)
|
||||
|
||||
regenerate_css.apply_async(args=(self.pk,))
|
||||
|
||||
@staticmethod
|
||||
def clean_has_subevents(event, has_subevents):
|
||||
if event is not None and event.has_subevents is not None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import string
|
||||
from decimal import Decimal
|
||||
|
||||
import pycountry
|
||||
from django.db import DatabaseError, models, transaction
|
||||
from django.db.models import Max
|
||||
from django.db.models.functions import Cast
|
||||
@@ -11,6 +12,8 @@ from django.utils.translation import pgettext
|
||||
from django_countries.fields import CountryField
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
|
||||
|
||||
def invoice_filename(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
|
||||
@@ -90,6 +93,7 @@ class Invoice(models.Model):
|
||||
invoice_to_street = models.TextField(null=True)
|
||||
invoice_to_zipcode = models.CharField(max_length=190, null=True)
|
||||
invoice_to_city = models.TextField(null=True)
|
||||
invoice_to_state = models.CharField(max_length=190, null=True)
|
||||
invoice_to_country = CountryField(null=True)
|
||||
invoice_to_vat_id = models.TextField(null=True)
|
||||
invoice_to_beneficiary = models.TextField(null=True)
|
||||
@@ -140,11 +144,21 @@ class Invoice(models.Model):
|
||||
def address_invoice_to(self):
|
||||
if self.invoice_to and not self.invoice_to_company and not self.invoice_to_name:
|
||||
return self.invoice_to
|
||||
|
||||
state_name = ""
|
||||
if self.invoice_to_state:
|
||||
state_name = self.invoice_to_state
|
||||
if str(self.invoice_to_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_to_country)][1] == 'long':
|
||||
state_name = pycountry.subdivisions.get(
|
||||
code='{}-{}'.format(self.invoice_to_country, self.invoice_to_state)
|
||||
).name
|
||||
|
||||
parts = [
|
||||
self.invoice_to_company,
|
||||
self.invoice_to_name,
|
||||
self.invoice_to_street,
|
||||
(self.invoice_to_zipcode or "") + " " + (self.invoice_to_city or ""),
|
||||
((self.invoice_to_zipcode or "") + " " + (self.invoice_to_city or "") + " " + (state_name or "")).strip(),
|
||||
self.invoice_to_country.name if self.invoice_to_country else "",
|
||||
]
|
||||
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||
@@ -175,6 +189,8 @@ class Invoice(models.Model):
|
||||
self.organizer = self.order.event.organizer
|
||||
if not self.prefix:
|
||||
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
|
||||
if self.is_cancellation:
|
||||
self.prefix = self.event.settings.invoice_numbers_prefix_cancellations or self.prefix
|
||||
if not self.invoice_no:
|
||||
if self.order.testmode:
|
||||
self.prefix += 'TEST-'
|
||||
|
||||
@@ -311,6 +311,11 @@ class Item(LoggedModel):
|
||||
verbose_name=_("Generate tickets"),
|
||||
blank=True, null=True,
|
||||
)
|
||||
allow_waitinglist = models.BooleanField(
|
||||
verbose_name=_("Show a waiting list for this ticket"),
|
||||
help_text=_("This will only work of waiting lists are enabled for this event."),
|
||||
default=True
|
||||
)
|
||||
show_quota_left = models.NullBooleanField(
|
||||
verbose_name=_("Show number of tickets left"),
|
||||
help_text=_("Publicly show how many tickets are still available."),
|
||||
@@ -334,6 +339,17 @@ class Item(LoggedModel):
|
||||
null=True, blank=True,
|
||||
help_text=_('This product will not be sold after the given date.')
|
||||
)
|
||||
hidden_if_available = models.ForeignKey(
|
||||
'Quota',
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("Only show after sellout of"),
|
||||
help_text=_("If you select a quota here, this product will only be shown when that quota is "
|
||||
"unavailable. If combined with the option to hide sold-out products, this allows you to "
|
||||
"swap out products for more expensive ones once they are sold out. There might be a short period "
|
||||
"in which both products are visible while all tickets in the referenced quota are reserved, "
|
||||
"but not yet sold.")
|
||||
)
|
||||
require_voucher = models.BooleanField(
|
||||
verbose_name=_('This product can only be bought using a voucher.'),
|
||||
default=False,
|
||||
@@ -477,7 +493,7 @@ class Item(LoggedModel):
|
||||
return check_quotas
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
|
||||
include_bundled=False, trust_parameters=False):
|
||||
include_bundled=False, trust_parameters=False, fail_on_no_quotas=False):
|
||||
"""
|
||||
This method is used to determine whether this Item is currently available
|
||||
for sale.
|
||||
@@ -525,6 +541,8 @@ class Item(LoggedModel):
|
||||
res = (code_avail, num_avail)
|
||||
|
||||
if len(quotacounter) == 0:
|
||||
if fail_on_no_quotas:
|
||||
return Quota.AVAILABILITY_GONE, 0
|
||||
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
|
||||
return res
|
||||
|
||||
@@ -680,7 +698,7 @@ class ItemVariation(models.Model):
|
||||
return check_quotas
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
|
||||
include_bundled=False, trust_parameters=False) -> Tuple[int, int]:
|
||||
include_bundled=False, trust_parameters=False, fail_on_no_quotas=False) -> Tuple[int, int]:
|
||||
"""
|
||||
This method is used to determine whether this ItemVariation is currently
|
||||
available for sale in terms of quotas.
|
||||
@@ -722,6 +740,8 @@ class ItemVariation(models.Model):
|
||||
if code_avail < res[0] or res[1] is None or num_avail < res[1]:
|
||||
res = (code_avail, num_avail)
|
||||
if len(quotacounter) == 0:
|
||||
if fail_on_no_quotas:
|
||||
return Quota.AVAILABILITY_GONE, 0
|
||||
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
|
||||
return res
|
||||
|
||||
@@ -962,6 +982,7 @@ class Question(LoggedModel):
|
||||
(TYPE_DATETIME, _("Date and time")),
|
||||
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
|
||||
)
|
||||
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
@@ -1004,8 +1025,6 @@ class Question(LoggedModel):
|
||||
)
|
||||
ask_during_checkin = models.BooleanField(
|
||||
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
|
||||
help_text=_('This will only work if you handle your check-in with pretixdroid 1.8 or newer or '
|
||||
'pretixdesk 0.2 or newer.'),
|
||||
default=False
|
||||
)
|
||||
hidden = models.BooleanField(
|
||||
@@ -1013,6 +1032,10 @@ class Question(LoggedModel):
|
||||
help_text=_('This question will only show up in the backend.'),
|
||||
default=False
|
||||
)
|
||||
print_on_invoice = models.BooleanField(
|
||||
verbose_name=_('Print answer on invoices'),
|
||||
default=False
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from decimal import Decimal
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
import dateutil
|
||||
import pycountry
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.db import models, transaction
|
||||
@@ -30,7 +31,9 @@ from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
@@ -534,16 +537,30 @@ class Order(LockModel, LoggedModel):
|
||||
# handwriting (2/Z, 4/A, 5/S, 6/G). This allows for better detection e.g. in incoming wire transfers that
|
||||
# might include OCR'd handwritten text
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
iteration = 0
|
||||
length = settings.ENTROPY['order_code']
|
||||
while True:
|
||||
code = get_random_string(length=settings.ENTROPY['order_code'], allowed_chars=charset)
|
||||
code = get_random_string(length=length, allowed_chars=charset)
|
||||
iteration += 1
|
||||
|
||||
if banned(code):
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
if iteration > 20:
|
||||
# Safeguard: If we don't find an unused and non-blacklisted code within 20 iterations, we increase
|
||||
# the length.
|
||||
length += 1
|
||||
iteration = 0
|
||||
|
||||
@property
|
||||
def can_modify_answers(self) -> bool:
|
||||
"""
|
||||
@@ -696,7 +713,7 @@ class Order(LockModel, LoggedModel):
|
||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
auth=None, attach_tickets=False, position: 'OrderPosition'=None):
|
||||
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True):
|
||||
"""
|
||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||
|
||||
@@ -736,7 +753,7 @@ class Order(LockModel, LoggedModel):
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers=headers, sender=sender,
|
||||
invoices=invoices, attach_tickets=attach_tickets,
|
||||
position=position
|
||||
position=position, auto_email=auto_email
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
@@ -756,26 +773,9 @@ class Order(LockModel, LoggedModel):
|
||||
)
|
||||
|
||||
def resend_link(self, user=None, auth=None):
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
with language(self.locale):
|
||||
try:
|
||||
invoice_name = self.invoice_address.name
|
||||
invoice_company = self.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = self.event.settings.mail_text_resend_link
|
||||
email_context = {
|
||||
'event': self.event.name,
|
||||
'url': build_absolute_uri(self.event, 'presale:event.order.open', kwargs={
|
||||
'order': self.code,
|
||||
'secret': self.secret,
|
||||
'hash': self.email_confirm_hash()
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_context = get_email_context(event=self.event, order=self)
|
||||
email_subject = _('Your order: %(code)s') % {'code': self.code}
|
||||
self.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
@@ -1195,7 +1195,7 @@ class OrderPayment(models.Model):
|
||||
"""
|
||||
Cached access to an instance of the payment provider in use.
|
||||
"""
|
||||
return self.order.event.get_payment_providers().get(self.provider)
|
||||
return self.order.event.get_payment_providers(cached=True).get(self.provider)
|
||||
|
||||
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
|
||||
from pretix.base.signals import order_paid
|
||||
@@ -1312,24 +1312,10 @@ class OrderPayment(models.Model):
|
||||
|
||||
def _send_paid_mail_attendee(self, position, user):
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
with language(self.order.locale):
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.order.event.settings.name_scheme]
|
||||
email_template = self.order.event.settings.mail_text_order_paid_attendee
|
||||
email_context = {
|
||||
'event': self.order.event.name,
|
||||
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order.position', kwargs={
|
||||
'order': self.order.code,
|
||||
'secret': position.web_secret,
|
||||
'position': position.positionid
|
||||
}),
|
||||
'attendee_name': position.attendee_name,
|
||||
}
|
||||
for f, l, w in name_scheme['fields']:
|
||||
email_context['attendee_name_%s' % f] = position.attendee_name_parts.get(f, '')
|
||||
|
||||
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
|
||||
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
|
||||
try:
|
||||
self.order.send_mail(
|
||||
@@ -1343,28 +1329,10 @@ class OrderPayment(models.Model):
|
||||
|
||||
def _send_paid_mail(self, invoice, user, mail_text):
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
with language(self.order.locale):
|
||||
try:
|
||||
invoice_name = self.order.invoice_address.name
|
||||
invoice_company = self.order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = self.order.event.settings.mail_text_order_paid
|
||||
email_context = {
|
||||
'event': self.order.event.name,
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order.open', kwargs={
|
||||
'order': self.order.code,
|
||||
'secret': self.order.secret,
|
||||
'hash': self.order.email_confirm_hash()
|
||||
}),
|
||||
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
'payment_info': mail_text
|
||||
}
|
||||
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
|
||||
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
|
||||
try:
|
||||
self.order.send_mail(
|
||||
@@ -1913,25 +1881,26 @@ class OrderPosition(AbstractPosition):
|
||||
"""
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
|
||||
if not self.email:
|
||||
if not self.attendee_email:
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.locale):
|
||||
recipient = self.email
|
||||
with language(self.order.locale):
|
||||
recipient = self.attendee_email
|
||||
try:
|
||||
email_content = render_mail(template, context)
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers, sender,
|
||||
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
|
||||
position=self,
|
||||
invoices=invoices, attach_tickets=attach_tickets
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
else:
|
||||
self.log_action(
|
||||
self.order.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
auth=auth,
|
||||
@@ -1944,6 +1913,18 @@ class OrderPosition(AbstractPosition):
|
||||
}
|
||||
)
|
||||
|
||||
def resend_link(self, user=None, auth=None):
|
||||
|
||||
with language(self.order.locale):
|
||||
email_template = self.event.settings.mail_text_resend_link
|
||||
email_context = get_email_context(event=self.order.event, order=self.order, position=self)
|
||||
email_subject = _('Your event registration: %(code)s') % {'code': self.order.code}
|
||||
self.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.resend', user=user, auth=auth,
|
||||
attach_tickets=True
|
||||
)
|
||||
|
||||
|
||||
class CartPosition(AbstractPosition):
|
||||
"""
|
||||
@@ -2019,6 +2000,7 @@ class InvoiceAddress(models.Model):
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
|
||||
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
|
||||
country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'))
|
||||
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True)
|
||||
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'),
|
||||
help_text=_('Only for business customers within the EU.'))
|
||||
vat_id_validated = models.BooleanField(default=False)
|
||||
@@ -2045,6 +2027,22 @@ class InvoiceAddress(models.Model):
|
||||
self.name_parts = {}
|
||||
super().save(**kwargs)
|
||||
|
||||
@property
|
||||
def state_name(self):
|
||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||
if sd:
|
||||
return sd.name
|
||||
return self.state
|
||||
|
||||
@property
|
||||
def state_for_address(self):
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
if not self.state or str(self.country) not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
return ""
|
||||
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.country)][1] == 'long':
|
||||
return self.state_name
|
||||
return self.state
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
if not self.name_parts:
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext, ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent
|
||||
|
||||
@@ -39,7 +39,7 @@ class SeatingPlan(LoggedModel):
|
||||
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
|
||||
|
||||
Category = namedtuple('Categrory', 'name')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category zone')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -67,6 +67,7 @@ class SeatingPlan(LoggedModel):
|
||||
guid=s['seat_guid'],
|
||||
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
|
||||
row=r['row_number'],
|
||||
zone=z['name'],
|
||||
category=s['category']
|
||||
)
|
||||
|
||||
@@ -90,12 +91,24 @@ class Seat(models.Model):
|
||||
event = models.ForeignKey(Event, related_name='seats', on_delete=models.CASCADE)
|
||||
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=190)
|
||||
zone_name = models.CharField(max_length=190, blank=True, default="")
|
||||
row_name = models.CharField(max_length=190, blank=True, default="")
|
||||
seat_number = models.CharField(max_length=190, blank=True, default="")
|
||||
seat_guid = models.CharField(max_length=190, db_index=True)
|
||||
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
|
||||
blocked = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
parts = []
|
||||
if self.zone_name:
|
||||
parts.append(self.zone_name)
|
||||
if self.row_name:
|
||||
parts.append(gettext('Row {number}').format(number=self.row_name))
|
||||
if self.seat_number:
|
||||
parts.append(gettext('Seat {number}').format(number=self.seat_number))
|
||||
if not parts:
|
||||
return self.name
|
||||
return ', '.join(parts)
|
||||
|
||||
def is_available(self, ignore_cart=None, ignore_orderpos=None):
|
||||
from .orders import Order
|
||||
|
||||
@@ -85,6 +85,12 @@ EU_CURRENCIES = {
|
||||
}
|
||||
|
||||
|
||||
def cc_to_vat_prefix(country_code):
|
||||
if country_code == 'GR':
|
||||
return 'EL'
|
||||
return country_code
|
||||
|
||||
|
||||
class TaxRule(LoggedModel):
|
||||
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
|
||||
name = I18nCharField(
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.models import SeatCategoryMapping
|
||||
|
||||
from ..decimal import round_decimal
|
||||
@@ -21,9 +22,12 @@ from .orders import Order
|
||||
|
||||
def _generate_random_code(prefix=None):
|
||||
charset = list('ABCDEFGHKLMNPQRSTUVWXYZ23456789')
|
||||
rnd = None
|
||||
while not rnd or banned(rnd):
|
||||
rnd = get_random_string(length=settings.ENTROPY['voucher_code'], allowed_chars=charset)
|
||||
if prefix:
|
||||
return prefix + get_random_string(length=settings.ENTROPY['voucher_code'], allowed_chars=charset)
|
||||
return get_random_string(length=settings.ENTROPY['voucher_code'], allowed_chars=charset)
|
||||
return prefix + rnd
|
||||
return rnd
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
|
||||
@@ -6,10 +6,10 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Voucher
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
from .base import LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
@@ -130,13 +130,7 @@ class WaitingListEntry(LoggedModel):
|
||||
self.email,
|
||||
_('You have been selected from the waitinglist for {event}').format(event=str(self.event)),
|
||||
self.event.settings.mail_text_waiting_list,
|
||||
{
|
||||
'event': self.event.name,
|
||||
'url': build_absolute_uri(self.event, 'presale:event.redeem') + '?voucher=' + self.voucher.code,
|
||||
'code': self.voucher.code,
|
||||
'product': str(self.item) + (' - ' + str(self.variation) if self.variation else ''),
|
||||
'hours': self.event.settings.waiting_list_hours,
|
||||
},
|
||||
get_email_context(event=self.event, waiting_list_entry=self),
|
||||
self.event,
|
||||
locale=self.locale
|
||||
)
|
||||
|
||||
@@ -531,7 +531,7 @@ class BasePaymentProvider:
|
||||
containing the URL the user will be redirected to. If you are done with your process
|
||||
you should return the user to the order's detail page.
|
||||
|
||||
If the payment is completed, you should call ``payment.confirm()``. Please note that ``this`` might
|
||||
If the payment is completed, you should call ``payment.confirm()``. Please note that this might
|
||||
raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this order is over and
|
||||
some of the items are sold out. You should use the exception message to display a meaningful error
|
||||
to the user.
|
||||
@@ -657,6 +657,15 @@ class BasePaymentProvider:
|
||||
obj.info = '{}'
|
||||
obj.save(update_fields=['info'])
|
||||
|
||||
def api_payment_details(self, payment: OrderPayment):
|
||||
"""
|
||||
Will be called to populate the ``details`` parameter of the payment in the REST API.
|
||||
|
||||
:param payment: The payment in question.
|
||||
:return: A serializable dictionary
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
class PaymentException(Exception):
|
||||
pass
|
||||
@@ -720,6 +729,12 @@ class BoxOfficeProvider(BasePaymentProvider):
|
||||
def order_change_allowed(self, order: Order) -> bool:
|
||||
return False
|
||||
|
||||
def api_payment_details(self, payment: OrderPayment):
|
||||
return {
|
||||
"pos_id": payment.info_data.get('pos_id', None),
|
||||
"receipt_id": payment.info_data.get('receipt_id', None),
|
||||
}
|
||||
|
||||
def payment_control_render(self, request, payment) -> str:
|
||||
if not payment.info:
|
||||
return
|
||||
@@ -864,6 +879,11 @@ class OffsettingProvider(BasePaymentProvider):
|
||||
def order_change_allowed(self, order: Order) -> bool:
|
||||
return False
|
||||
|
||||
def api_payment_details(self, payment: OrderPayment):
|
||||
return {
|
||||
"orders": payment.info_data.get('orders', []),
|
||||
}
|
||||
|
||||
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
|
||||
return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders']))
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import Paragraph
|
||||
|
||||
from pretix.base.invoice import ThumbnailingImageReader
|
||||
from pretix.base.models import Order, OrderPosition, QuestionAnswer
|
||||
from pretix.base.models import Order, OrderPosition
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import layout_text_variables
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
@@ -239,9 +239,26 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
) if ev.date_admission else ""
|
||||
}),
|
||||
("seat", {
|
||||
"label": _("Seat name"),
|
||||
"editor_sample": _("3, 4-5"),
|
||||
"evaluate": lambda op, order, ev: str(op.seat if op.seat else _('General admission'))
|
||||
"label": _("Seat: Full name"),
|
||||
"editor_sample": _("Ground floor, Row 3, Seat 4"),
|
||||
"evaluate": lambda op, order, ev: str(op.seat if op.seat else
|
||||
_('General admission') if ev.seating_plan_id is not None else "")
|
||||
}),
|
||||
("seat_zone", {
|
||||
"label": _("Seat: zone"),
|
||||
"editor_sample": _("Ground floor"),
|
||||
"evaluate": lambda op, order, ev: str(op.seat.zone_name if op.seat else
|
||||
_('General admission') if ev.seating_plan_id is not None else "")
|
||||
}),
|
||||
("seat_row", {
|
||||
"label": _("Seat: row"),
|
||||
"editor_sample": "3",
|
||||
"evaluate": lambda op, order, ev: str(op.seat.row_name if op.seat else "")
|
||||
}),
|
||||
("seat_number", {
|
||||
"label": _("Seat: seat number"),
|
||||
"editor_sample": 4,
|
||||
"evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "")
|
||||
}),
|
||||
))
|
||||
|
||||
@@ -249,16 +266,28 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
@receiver(layout_text_variables, dispatch_uid="pretix_base_layout_text_variables_questions")
|
||||
def variables_from_questions(sender, *args, **kwargs):
|
||||
def get_answer(op, order, event, question_id):
|
||||
try:
|
||||
if 'answers' in getattr(op, '_prefetched_objects_cache', {}):
|
||||
a = [a for a in op.answers.all() if a.question_id == question_id][0]
|
||||
a = None
|
||||
if op.addon_to:
|
||||
if 'answers' in getattr(op.addon_to, '_prefetched_objects_cache', {}):
|
||||
try:
|
||||
a = [a for a in op.addon_to.answers.all() if a.question_id == question_id][0]
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
a = op.answers.get(question_id=question_id)
|
||||
a = op.addon_to.answers.filter(question_id=question_id).first()
|
||||
|
||||
if 'answers' in getattr(op, '_prefetched_objects_cache', {}):
|
||||
try:
|
||||
a = [a for a in op.answers.all() if a.question_id == question_id][0]
|
||||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
a = op.answers.filter(question_id=question_id).first()
|
||||
|
||||
if not a:
|
||||
return ""
|
||||
else:
|
||||
return str(a).replace("\n", "<br/>\n")
|
||||
except QuestionAnswer.DoesNotExist:
|
||||
return ""
|
||||
except IndexError:
|
||||
return ""
|
||||
|
||||
d = {}
|
||||
for q in sender.questions.all():
|
||||
|
||||
@@ -226,11 +226,15 @@ class CartManager:
|
||||
|
||||
def _check_item_constraints(self, op):
|
||||
if isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
|
||||
if op.item.require_voucher and op.voucher is None:
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
if not (
|
||||
(isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or
|
||||
(isinstance(op, self.ExtendOperation) and op.position.is_bundled)
|
||||
):
|
||||
if op.item.require_voucher and op.voucher is None:
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if op.item.hide_without_voucher and (op.voucher is None or not op.voucher.show_hidden_items):
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
if op.item.hide_without_voucher and (op.voucher is None or not op.voucher.show_hidden_items):
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
if not op.item.is_available() or (op.variation and not op.variation.active):
|
||||
raise CartError(error_messages['unavailable'])
|
||||
@@ -432,6 +436,8 @@ class CartManager:
|
||||
seat = (subevent or self.event).seats.get(seat_guid=i.get('seat'))
|
||||
except Seat.DoesNotExist:
|
||||
raise CartError(error_messages['seat_invalid'])
|
||||
except Seat.MultipleObjectsReturned:
|
||||
raise CartError(error_messages['seat_invalid'])
|
||||
i['item'] = seat.product_id
|
||||
if i['item'] not in self._items_cache:
|
||||
self._update_items_cache([i['item']], [i['variation']])
|
||||
@@ -612,7 +618,7 @@ class CartManager:
|
||||
|
||||
op = self.AddOperation(
|
||||
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
||||
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=cp.seat
|
||||
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
@@ -789,14 +795,20 @@ class CartManager:
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
for b in op.bundled:
|
||||
b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b.quotas))
|
||||
b_quotas = list(b.quotas)
|
||||
if not b_quotas:
|
||||
if not op.voucher or not op.voucher.allow_ignore_quota:
|
||||
err = err or error_messages['unavailable']
|
||||
available_count = 0
|
||||
continue
|
||||
b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b_quotas))
|
||||
if b_quota_available_count < b.count:
|
||||
err = err or error_messages['unavailable']
|
||||
available_count = 0
|
||||
elif b_quota_available_count < available_count * b.count:
|
||||
err = err or error_messages['in_part']
|
||||
available_count = b_quota_available_count // b.count
|
||||
for q in b.quotas:
|
||||
for q in b_quotas:
|
||||
quotas_ok[q] -= available_count * b.count
|
||||
# TODO: is this correct?
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from django.db import transaction
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Order, OrderPosition, Question, QuestionOption,
|
||||
)
|
||||
from pretix.base.signals import order_placed
|
||||
|
||||
|
||||
class CheckInError(Exception):
|
||||
@@ -155,3 +157,18 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
'datetime': dt,
|
||||
'list': clist.pk
|
||||
}, user=user, auth=auth)
|
||||
|
||||
|
||||
@receiver(order_placed, dispatch_uid="autocheckin_order_placed")
|
||||
def order_placed(sender, **kwargs):
|
||||
order = kwargs['order']
|
||||
event = sender
|
||||
|
||||
cls = list(event.checkin_lists.filter(auto_checkin_sales_channels__contains=order.sales_channel).prefetch_related(
|
||||
'limit_products'))
|
||||
if not cls:
|
||||
return
|
||||
for op in order.positions.all():
|
||||
for cl in cls:
|
||||
if cl.all_products or op.item_id in {i.pk for i in cl.limit_products.all()}:
|
||||
Checkin.objects.create(position=op, list=cl, auto_checked_in=True)
|
||||
|
||||
@@ -73,12 +73,15 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
addr_template = pgettext("invoice", """{i.company}
|
||||
{i.name}
|
||||
{i.street}
|
||||
{i.zipcode} {i.city}
|
||||
{i.zipcode} {i.city} {state}
|
||||
{country}""")
|
||||
invoice.invoice_to = addr_template.format(
|
||||
i=ia,
|
||||
country=ia.country.name if ia.country else ia.country_old
|
||||
).strip()
|
||||
invoice.invoice_to = "\n".join(
|
||||
a.strip() for a in addr_template.format(
|
||||
i=ia,
|
||||
country=ia.country.name if ia.country else ia.country_old,
|
||||
state=ia.state_for_address
|
||||
).split("\n") if a.strip()
|
||||
)
|
||||
invoice.internal_reference = ia.internal_reference
|
||||
invoice.invoice_to_company = ia.company
|
||||
invoice.invoice_to_name = ia.name
|
||||
@@ -86,6 +89,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_state = ia.state
|
||||
invoice.invoice_to_beneficiary = ia.beneficiary
|
||||
|
||||
if ia.vat_id:
|
||||
@@ -125,7 +129,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
positions = list(
|
||||
invoice.order.positions.select_related('addon_to', 'item', 'tax_rule', 'subevent', 'variation').annotate(
|
||||
addon_c=Count('addons')
|
||||
).order_by('positionid', 'id')
|
||||
).prefetch_related('answers', 'answers__question').order_by('positionid', 'id')
|
||||
)
|
||||
|
||||
reverse_charge = False
|
||||
@@ -142,6 +146,16 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
desc = " + " + desc
|
||||
if invoice.event.settings.invoice_attendee_name and p.attendee_name:
|
||||
desc += "<br />" + pgettext("invoice", "Attendee: {name}").format(name=p.attendee_name)
|
||||
|
||||
for answ in p.answers.all():
|
||||
if not answ.question.print_on_invoice:
|
||||
continue
|
||||
desc += "<br />{}{} {}".format(
|
||||
answ.question.question,
|
||||
"" if str(answ.question.question).endswith("?") else ":",
|
||||
str(answ)
|
||||
)
|
||||
|
||||
if invoice.event.has_subevents:
|
||||
desc += "<br />" + pgettext("subevent", "Date: {}").format(p.subevent)
|
||||
InvoiceLine.objects.create(
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import warnings
|
||||
from email.mime.image import MIMEImage
|
||||
from email.utils import formataddr
|
||||
from typing import Any, Dict, List, Union
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import cssutils
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from celery import chain
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
@@ -17,12 +23,12 @@ from i18nfield.strings import LazyI18nString
|
||||
from pretix.base.email import ClassicMailRenderer
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Event, Invoice, InvoiceAddress, Order, OrderPosition,
|
||||
Event, Invoice, InvoiceAddress, Order, OrderPosition, User,
|
||||
)
|
||||
from pretix.base.services.invoices import invoice_pdf_task
|
||||
from pretix.base.services.tasks import TransactionAwareTask
|
||||
from pretix.base.services.tickets import get_tickets_for_order
|
||||
from pretix.base.signals import email_filter
|
||||
from pretix.base.signals import email_filter, global_email_filter
|
||||
from pretix.celery_app import app
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
@@ -44,7 +50,7 @@ class SendMailException(Exception):
|
||||
def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, event: Event=None, locale: str=None,
|
||||
order: Order=None, position: OrderPosition=None, headers: dict=None, sender: str=None,
|
||||
invoices: list=None, attach_tickets=False):
|
||||
invoices: list=None, attach_tickets=False, auto_email=True, user=None):
|
||||
"""
|
||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||
|
||||
@@ -79,6 +85,10 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
|
||||
:param attach_tickets: Whether to attach tickets to this email, if they are available to download.
|
||||
|
||||
:param auto_email: Whether this email is auto-generated
|
||||
|
||||
:param user: The user this email is sent to
|
||||
|
||||
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
|
||||
that the email has been sent, just that it has been queued by the email backend.
|
||||
"""
|
||||
@@ -86,6 +96,9 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
return
|
||||
|
||||
headers = headers or {}
|
||||
if auto_email:
|
||||
headers['X-Auto-Response-Suppress'] = 'OOF, NRN, AutoReply, RN'
|
||||
headers['Auto-Submitted'] = 'auto-generated'
|
||||
|
||||
with language(locale):
|
||||
if isinstance(context, dict) and event:
|
||||
@@ -202,7 +215,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
invoices=[i.pk for i in invoices] if invoices and not position else [],
|
||||
order=order.pk if order else None,
|
||||
position=position.pk if position else None,
|
||||
attach_tickets=attach_tickets
|
||||
attach_tickets=attach_tickets,
|
||||
user=user.pk if user else None
|
||||
)
|
||||
|
||||
if invoices:
|
||||
@@ -217,10 +231,15 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
@app.task(base=TransactionAwareTask, bind=True)
|
||||
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
event: int=None, position: int=None, headers: dict=None, bcc: List[str]=None,
|
||||
invoices: List[int]=None, order: int=None, attach_tickets=False) -> bool:
|
||||
invoices: List[int]=None, order: int=None, attach_tickets=False, user=None) -> bool:
|
||||
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||
if html is not None:
|
||||
email.attach_alternative(html, "text/html")
|
||||
html_with_cid, cid_images = replace_images_with_cid_paths(html)
|
||||
email = attach_cid_images(email, cid_images, verify_ssl=True)
|
||||
email.attach_alternative(html_with_cid, "text/html")
|
||||
|
||||
if user:
|
||||
user = User.objects.get(pk=user)
|
||||
|
||||
if event:
|
||||
with scopes_disabled():
|
||||
@@ -283,7 +302,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
}
|
||||
)
|
||||
|
||||
email = email_filter.send_chained(event, 'message', message=email, order=order)
|
||||
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
|
||||
|
||||
email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order)
|
||||
|
||||
try:
|
||||
backend.send_messages([email])
|
||||
@@ -332,3 +353,107 @@ def render_mail(template, context):
|
||||
tpl = get_template(template)
|
||||
body = tpl.render(context)
|
||||
return body
|
||||
|
||||
|
||||
def replace_images_with_cid_paths(body_html):
|
||||
if body_html:
|
||||
email = BeautifulSoup(body_html, "lxml")
|
||||
cid_images = []
|
||||
for image in email.findAll('img'):
|
||||
original_image_src = image['src']
|
||||
|
||||
try:
|
||||
cid_id = "image_%s" % cid_images.index(original_image_src)
|
||||
except ValueError:
|
||||
cid_images.append(original_image_src)
|
||||
cid_id = "image_%s" % (len(cid_images) - 1)
|
||||
|
||||
image['src'] = "cid:%s" % cid_id
|
||||
|
||||
return email.prettify(), cid_images
|
||||
else:
|
||||
return body_html, []
|
||||
|
||||
|
||||
def attach_cid_images(msg, cid_images, verify_ssl=True):
|
||||
if cid_images and len(cid_images) > 0:
|
||||
|
||||
msg.mixed_subtype = 'mixed'
|
||||
for key, image in enumerate(cid_images):
|
||||
cid = 'image_%s' % key
|
||||
try:
|
||||
mime_image = convert_image_to_cid(
|
||||
image, cid, verify_ssl)
|
||||
if mime_image:
|
||||
msg.attach(mime_image)
|
||||
except:
|
||||
logger.exception("ERROR attaching CID image %s[%s]" % (cid, image))
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def encoder_linelength(msg):
|
||||
"""
|
||||
RFC1341 mandates that base64 encoded data may not be longer than 76 characters per line
|
||||
https://www.w3.org/Protocols/rfc1341/5_Content-Transfer-Encoding.html section 5.2
|
||||
"""
|
||||
|
||||
orig = msg.get_payload(decode=True).replace(b"\n", b"").replace(b"\r", b"")
|
||||
max_length = 76
|
||||
pieces = []
|
||||
for i in range(0, len(orig), max_length):
|
||||
chunk = orig[i:i + max_length]
|
||||
pieces.append(chunk)
|
||||
msg.set_payload(b"\r\n".join(pieces))
|
||||
|
||||
|
||||
def convert_image_to_cid(image_src, cid_id, verify_ssl=True):
|
||||
try:
|
||||
if image_src.startswith('data:image/'):
|
||||
image_type, image_content = image_src.split(',', 1)
|
||||
image_type = re.findall(r'data:image/(\w+);base64', image_type)[0]
|
||||
mime_image = MIMEImage(image_content, _subtype=image_type, _encoder=encoder_linelength)
|
||||
mime_image.add_header('Content-Transfer-Encoding', 'base64')
|
||||
elif image_src.startswith('data:'):
|
||||
logger.exception("ERROR creating MIME element %s[%s]" % (cid_id, image_src))
|
||||
return None
|
||||
else:
|
||||
image_src = normalize_image_url(image_src)
|
||||
|
||||
path = urlparse(image_src).path
|
||||
guess_subtype = os.path.splitext(path)[1][1:]
|
||||
|
||||
response = requests.get(image_src, verify=verify_ssl)
|
||||
mime_image = MIMEImage(
|
||||
response.content, _subtype=guess_subtype)
|
||||
|
||||
mime_image.add_header('Content-ID', '<%s>' % cid_id)
|
||||
|
||||
return mime_image
|
||||
except:
|
||||
logger.exception("ERROR creating mime_image %s[%s]" % (cid_id, image_src))
|
||||
return None
|
||||
|
||||
|
||||
def normalize_image_url(url):
|
||||
if '://' not in url:
|
||||
"""
|
||||
If we see a relative URL in an email, we can't know if it is meant to be a media file
|
||||
or a static file, so we need to guess. If it is a static file included with the
|
||||
``{% static %}`` template tag (as it should be), then ``STATIC_URL`` is already prepended.
|
||||
If ``STATIC_URL`` is absolute, then ``url`` should already be absolute and this
|
||||
function should not be triggered. Thus, if we see a relative URL and ``STATIC_URL``
|
||||
is absolute *or* ``url`` does not start with ``STATIC_URL``, we can be sure this
|
||||
is a media file (or a programmer error …).
|
||||
|
||||
Constructing the URL of either a static file or a media file from settings is still
|
||||
not clean, since custom storage backends might very well use more complex approaches
|
||||
to build those URLs. However, this is good enough as a best-effort approach. Complex
|
||||
storage backends (such as cloud storages) will return absolute URLs anyways so this
|
||||
function is not needed in that case.
|
||||
"""
|
||||
if '://' not in settings.STATIC_URL and url.startswith(settings.STATIC_URL):
|
||||
url = urljoin(settings.SITE_URL, url)
|
||||
else:
|
||||
url = urljoin(settings.MEDIA_URL, url)
|
||||
return url
|
||||
|
||||
@@ -120,4 +120,5 @@ def send_notification_mail(notification: Notification, user: User):
|
||||
'html': body_html,
|
||||
'sender': settings.MAIL_FROM,
|
||||
'headers': {},
|
||||
'user': user.pk
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from collections import Counter, namedtuple
|
||||
@@ -6,23 +5,20 @@ from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
import pytz
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Exists, F, Max, OuterRef, 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
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import ugettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.api.models import OAuthApplication
|
||||
from pretix.base.i18n import (
|
||||
LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language,
|
||||
)
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment,
|
||||
OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher,
|
||||
@@ -45,7 +41,6 @@ from pretix.base.services.locking import LockTimeoutException, NoLockManager
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import (
|
||||
allow_ticket_download, order_approved, order_canceled, order_changed,
|
||||
order_denied, order_expired, order_fee_calculation, order_placed,
|
||||
@@ -53,7 +48,6 @@ from pretix.base.signals import (
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.models import modelcopy
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
error_messages = {
|
||||
'unavailable': _('Some of the products you selected were no longer available. '
|
||||
@@ -206,13 +200,6 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
# send_mail will trigger PDF generation later
|
||||
|
||||
if send_mail:
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
|
||||
with language(order.locale):
|
||||
if order.total == Decimal('0.00'):
|
||||
email_template = order.event.settings.mail_text_order_free
|
||||
@@ -221,20 +208,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
email_template = order.event.settings.mail_text_order_approved
|
||||
email_subject = _('Order approved and awaiting payment: %(code)s') % {'code': order.code}
|
||||
|
||||
email_context = {
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': order.event.currency,
|
||||
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
|
||||
'date': LazyDate(order.expires),
|
||||
'event': order.event.name,
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_context = get_email_context(event=order.event, order=order)
|
||||
try:
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
@@ -275,28 +249,8 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
order_denied.send(order.event, order=order)
|
||||
|
||||
if send_mail:
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = order.event.settings.mail_text_order_denied
|
||||
email_context = {
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': order.event.currency,
|
||||
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
|
||||
'date': LazyDate(order.expires),
|
||||
'event': order.event.name,
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}),
|
||||
'comment': comment,
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_context = get_email_context(event=order.event, order=order, comment=comment)
|
||||
with language(order.locale):
|
||||
email_subject = _('Order denied: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
@@ -379,16 +333,8 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
|
||||
if send_mail:
|
||||
email_template = order.event.settings.mail_text_order_canceled
|
||||
email_context = {
|
||||
'event': order.event.name,
|
||||
'code': order.code,
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
})
|
||||
}
|
||||
with language(order.locale):
|
||||
email_context = get_email_context(event=order.event, order=order)
|
||||
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
order.send_mail(
|
||||
@@ -403,7 +349,15 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
|
||||
|
||||
class OrderError(LazyLocaleException):
|
||||
pass
|
||||
def __init__(self, *args):
|
||||
msg = args[0]
|
||||
msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
if msgargs:
|
||||
msg = _(msg) % msgargs
|
||||
else:
|
||||
msg = _(msg)
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
def _check_date(event: Event, now_dt: datetime):
|
||||
@@ -501,12 +455,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
if cp.seat:
|
||||
seats_seen.add(cp.seat)
|
||||
|
||||
if cp.item.require_voucher and cp.voucher is None:
|
||||
if cp.item.require_voucher and cp.voucher is None and not cp.is_bundled:
|
||||
delete(cp)
|
||||
err = err or error_messages['voucher_required']
|
||||
break
|
||||
|
||||
if cp.item.hide_without_voucher and (cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)):
|
||||
if cp.item.hide_without_voucher and (
|
||||
cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)
|
||||
) and not cp.is_bundled:
|
||||
delete(cp)
|
||||
cp.delete()
|
||||
err = error_messages['voucher_required']
|
||||
@@ -672,36 +628,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
|
||||
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
|
||||
invoice, payment: OrderPayment):
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
|
||||
if pprov:
|
||||
if 'payment' in inspect.signature(pprov.order_pending_mail_render).parameters:
|
||||
payment_info = str(pprov.order_pending_mail_render(order, payment))
|
||||
else:
|
||||
payment_info = str(pprov.order_pending_mail_render(order))
|
||||
else:
|
||||
payment_info = None
|
||||
|
||||
email_context = {
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': event.currency,
|
||||
'total_with_currency': LazyCurrencyNumber(order.total, event.currency),
|
||||
'date': LazyDate(order.expires),
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}),
|
||||
'payment_info': payment_info,
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_context = get_email_context(event=event, order=order, payment=payment if pprov else None)
|
||||
email_subject = _('Your order: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
order.send_mail(
|
||||
@@ -715,19 +642,7 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
|
||||
|
||||
|
||||
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str):
|
||||
name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
email_context = {
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order.position', kwargs={
|
||||
'order': order.code,
|
||||
'secret': position.web_secret,
|
||||
'position': position.positionid
|
||||
}),
|
||||
'attendee_name': position.attendee_name,
|
||||
}
|
||||
for f, l, w in name_scheme['fields']:
|
||||
email_context['attendee_name_%s' % f] = position.attendee_name_parts.get(f, '')
|
||||
|
||||
email_context = get_email_context(event=event, order=order, position=position)
|
||||
email_subject = _('Your event registration: %(code)s') % {'code': order.code}
|
||||
|
||||
try:
|
||||
@@ -874,29 +789,12 @@ def send_expiry_warnings(sender, **kwargs):
|
||||
eventcache[o.event.pk] = eventsettings
|
||||
|
||||
days = eventsettings.get('mail_days_order_expire_warning', as_type=int)
|
||||
tz = pytz.timezone(eventsettings.get('timezone', settings.TIME_ZONE))
|
||||
if days and (o.expires - today).days <= days:
|
||||
with language(o.locale):
|
||||
o.expiry_reminder_sent = True
|
||||
o.save(update_fields=['expiry_reminder_sent'])
|
||||
try:
|
||||
invoice_name = o.invoice_address.name
|
||||
invoice_company = o.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = eventsettings.mail_text_order_expire_warning
|
||||
email_context = {
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order.open', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret,
|
||||
'hash': o.email_confirm_hash()
|
||||
}),
|
||||
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_context = get_email_context(event=o.event, order=o)
|
||||
if eventsettings.payment_term_expire_automatically:
|
||||
email_subject = _('Your order is about to expire: %(code)s') % {'code': o.code}
|
||||
else:
|
||||
@@ -939,14 +837,7 @@ def send_download_reminders(sender, **kwargs):
|
||||
o.download_reminder_sent = True
|
||||
o.save(update_fields=['download_reminder_sent'])
|
||||
email_template = e.settings.mail_text_download_reminder
|
||||
email_context = {
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order.open', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret,
|
||||
'hash': o.email_confirm_hash()
|
||||
}),
|
||||
}
|
||||
email_context = get_email_context(event=e, order=o)
|
||||
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
|
||||
try:
|
||||
o.send_mail(
|
||||
@@ -958,21 +849,10 @@ def send_download_reminders(sender, **kwargs):
|
||||
logger.exception('Reminder email could not be sent')
|
||||
|
||||
if e.settings.mail_send_download_reminder_attendee:
|
||||
name_scheme = PERSON_NAME_SCHEMES[e.settings.name_scheme]
|
||||
for p in o.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
|
||||
email_template = e.settings.mail_text_download_reminder_attendee
|
||||
email_context = {
|
||||
'event': e.name,
|
||||
'url': build_absolute_uri(e, 'presale:event.order.position', kwargs={
|
||||
'order': o.code,
|
||||
'secret': p.web_secret,
|
||||
'position': p.positionid
|
||||
}),
|
||||
'attendee_name': p.attendee_name,
|
||||
}
|
||||
for f, l, w in name_scheme['fields']:
|
||||
email_context['attendee_name_%s' % f] = p.attendee_name_parts.get(f, '')
|
||||
email_context = get_email_context(event=e, order=o, position=p)
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
@@ -985,23 +865,8 @@ def send_download_reminders(sender, **kwargs):
|
||||
|
||||
def notify_user_changed_order(order, user=None, auth=None):
|
||||
with language(order.locale):
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = order.event.settings.mail_text_order_changed
|
||||
email_context = {
|
||||
'event': order.event.name,
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash()
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_context = get_email_context(event=order.event, order=order)
|
||||
email_subject = _('Your order has been changed: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
order.send_mail(
|
||||
@@ -1039,12 +904,13 @@ class OrderChangeManager:
|
||||
SplitOperation = namedtuple('SplitOperation', ('position',))
|
||||
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
|
||||
|
||||
def __init__(self, order: Order, user=None, auth=None, notify=True):
|
||||
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
|
||||
self.order = order
|
||||
self.user = user
|
||||
self.auth = auth
|
||||
self.event = order.event
|
||||
self.split_order = None
|
||||
self.reissue_invoice = reissue_invoice
|
||||
self._committed = False
|
||||
self._totaldiff = 0
|
||||
self._quotadiff = Counter()
|
||||
@@ -1527,7 +1393,7 @@ class OrderChangeManager:
|
||||
|
||||
def _reissue_invoice(self):
|
||||
i = self.order.invoices.filter(is_cancellation=False).last()
|
||||
if i and self._invoice_dirty:
|
||||
if self.reissue_invoice and i and self._invoice_dirty:
|
||||
generate_cancellation(i)
|
||||
generate_invoice(self.order)
|
||||
|
||||
@@ -1674,7 +1540,9 @@ def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_tok
|
||||
raise OrderError(error_messages['busy'])
|
||||
|
||||
|
||||
def change_payment_provider(order: Order, payment_provider, amount=None, new_payment=None):
|
||||
def change_payment_provider(order: Order, payment_provider, amount=None, new_payment=None, create_log=True,
|
||||
recreate_invoices=True):
|
||||
oldtotal = order.total
|
||||
e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
OrderPayment.PAYMENT_STATE_REFUNDED))
|
||||
open_fees = list(
|
||||
@@ -1720,4 +1588,30 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
|
||||
|
||||
order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0)
|
||||
order.save(update_fields=['total'])
|
||||
return old_fee, new_fee, fee
|
||||
|
||||
if not new_payment:
|
||||
new_payment = order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider=payment_provider.identifier,
|
||||
amount=order.pending_sum,
|
||||
fee=fee
|
||||
)
|
||||
if create_log and new_payment:
|
||||
order.log_action(
|
||||
'pretix.event.order.payment.changed' if open_payment else 'pretix.event.order.payment.started',
|
||||
{
|
||||
'fee': new_fee,
|
||||
'old_fee': old_fee,
|
||||
'provider': payment_provider.identifier,
|
||||
'payment': new_payment.pk,
|
||||
'local_id': new_payment.local_id,
|
||||
}
|
||||
)
|
||||
|
||||
if recreate_invoices:
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i and order.total != oldtotal:
|
||||
generate_cancellation(i)
|
||||
generate_invoice(order)
|
||||
|
||||
return old_fee, new_fee, fee, new_payment
|
||||
|
||||
@@ -25,18 +25,33 @@ def validate_plan_change(event, subevent, plan):
|
||||
|
||||
|
||||
def generate_seats(event, subevent, plan, mapping):
|
||||
current_seats = {
|
||||
s.seat_guid: s for s in
|
||||
event.seats.select_related('product').annotate(has_op=Count('orderposition')).filter(subevent=subevent)
|
||||
}
|
||||
current_seats = {}
|
||||
for s in event.seats.select_related('product').annotate(has_op=Count('orderposition')).filter(subevent=subevent):
|
||||
if s.seat_guid in current_seats:
|
||||
s.delete() # Duplicates should not exist
|
||||
else:
|
||||
current_seats[s.seat_guid] = s
|
||||
|
||||
def update(o, a, v):
|
||||
if getattr(o, a) != v:
|
||||
setattr(o, a, v)
|
||||
return True
|
||||
return False
|
||||
|
||||
create_seats = []
|
||||
if plan:
|
||||
for ss in plan.iter_all_seats():
|
||||
p = mapping.get(ss.category)
|
||||
if ss.guid in current_seats:
|
||||
seat = current_seats.pop(ss.guid)
|
||||
if seat.product != p:
|
||||
seat.product = p
|
||||
updated = any([
|
||||
update(seat, 'product', p),
|
||||
update(seat, 'name', ss.name),
|
||||
update(seat, 'row_name', ss.row),
|
||||
update(seat, 'seat_number', ss.number),
|
||||
update(seat, 'zone_name', ss.zone),
|
||||
])
|
||||
if updated:
|
||||
seat.save()
|
||||
else:
|
||||
create_seats.append(Seat(
|
||||
@@ -44,6 +59,9 @@ def generate_seats(event, subevent, plan, mapping):
|
||||
subevent=subevent,
|
||||
seat_guid=ss.guid,
|
||||
name=ss.name,
|
||||
row_name=ss.row,
|
||||
seat_number=ss.number,
|
||||
zone_name=ss.zone,
|
||||
product=p,
|
||||
))
|
||||
|
||||
|
||||
@@ -76,7 +76,8 @@ def dictsum(*dicts) -> dict:
|
||||
|
||||
|
||||
def order_overview(
|
||||
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None
|
||||
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
|
||||
admission_only=False
|
||||
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
|
||||
items = event.items.all().select_related(
|
||||
'category', # for re-grouping
|
||||
@@ -87,6 +88,9 @@ def order_overview(
|
||||
qs = OrderPosition.all
|
||||
if subevent:
|
||||
qs = qs.filter(subevent=subevent)
|
||||
if admission_only:
|
||||
qs = qs.filter(item__admission=True)
|
||||
items = items.filter(admission=True)
|
||||
|
||||
if date_from and isinstance(date_from, date):
|
||||
date_from = make_aware(datetime.combine(
|
||||
@@ -189,7 +193,7 @@ def order_overview(
|
||||
payment_cat_obj.name = _('Fees')
|
||||
payment_items = []
|
||||
|
||||
if not subevent:
|
||||
if not subevent and fees:
|
||||
qs = OrderFee.all.filter(
|
||||
order__event=event
|
||||
).annotate(
|
||||
|
||||
@@ -106,3 +106,15 @@ class TransactionAwareTask(ProfiledTask):
|
||||
transaction.on_commit(
|
||||
lambda: super(TransactionAwareTask, self).apply_async(*args, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
class TransactionAwareProfiledEventTask(ProfiledEventTask):
|
||||
|
||||
def apply_async(self, *args, **kwargs):
|
||||
"""
|
||||
Unlike the default task in celery, this task does not return an async
|
||||
result
|
||||
"""
|
||||
transaction.on_commit(
|
||||
lambda: super(TransactionAwareProfiledEventTask, self).apply_async(*args, **kwargs)
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
||||
|
||||
qs = WaitingListEntry.objects.filter(
|
||||
event=event, voucher__isnull=True
|
||||
).select_related('item', 'variation').prefetch_related(
|
||||
).select_related('item', 'variation', 'subevent').prefetch_related(
|
||||
'item__quotas', 'variation__quotas'
|
||||
).order_by('-priority', 'created')
|
||||
|
||||
@@ -34,12 +34,14 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
||||
|
||||
with event.lock():
|
||||
for wle in qs:
|
||||
if (wle.item, wle.variation) in gone:
|
||||
if (wle.item, wle.variation, wle.subevent) in gone:
|
||||
continue
|
||||
|
||||
ev = (wle.subevent or event)
|
||||
if not ev.presale_is_running or (wle.subevent and not wle.subevent.active):
|
||||
continue
|
||||
if wle.subevent and not wle.subevent.presale_is_running:
|
||||
continue
|
||||
|
||||
quotas = (wle.variation.quotas.filter(subevent=wle.subevent)
|
||||
if wle.variation
|
||||
@@ -63,7 +65,7 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
||||
quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize
|
||||
)
|
||||
else:
|
||||
gone.add((wle.item, wle.variation))
|
||||
gone.add((wle.item, wle.variation, wle.subevent))
|
||||
|
||||
return sent
|
||||
|
||||
@@ -75,5 +77,5 @@ def process_waitinglist(sender, **kwargs):
|
||||
live=True
|
||||
).prefetch_related('_settings_objects', 'organizer___settings_objects').select_related('organizer')
|
||||
for e in qs:
|
||||
if e.settings.waiting_list_auto and e.presale_is_running:
|
||||
if e.settings.waiting_list_auto and (e.presale_is_running or e.has_subevents):
|
||||
assign_automatically.apply_async(args=(e.pk,))
|
||||
|
||||
@@ -93,6 +93,10 @@ DEFAULTS = {
|
||||
'default': '',
|
||||
'type': str,
|
||||
},
|
||||
'invoice_numbers_prefix_cancellations': {
|
||||
'default': '',
|
||||
'type': str,
|
||||
},
|
||||
'invoice_renderer': {
|
||||
'default': 'classic',
|
||||
'type': str,
|
||||
@@ -382,7 +386,7 @@ Your {event} team"""))
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||
|
||||
we successfully received your order for {event} with a total value
|
||||
of {total_with_currency}. Please complete your payment before {date}.
|
||||
of {total_with_currency}. Please complete your payment before {expire_date}.
|
||||
|
||||
{payment_info}
|
||||
|
||||
@@ -510,7 +514,7 @@ Your {event} team"""))
|
||||
we approved your order for {event} and will be happy to welcome you
|
||||
at our event.
|
||||
|
||||
Please continue by paying for your order before {date}.
|
||||
Please continue by paying for your order before {expire_date}.
|
||||
|
||||
You can select a payment method and perform the payment here:
|
||||
|
||||
@@ -876,6 +880,19 @@ PERSON_NAME_SCHEMES = OrderedDict([
|
||||
},
|
||||
}),
|
||||
])
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS = {
|
||||
# Source: http://www.bitboost.com/ref/international-address-formats.html
|
||||
# This is not a list of countries that *have* states, this is a list of countries where states
|
||||
# are actually *used* in postal addresses. This is obviously not complete and opinionated.
|
||||
# Country: [(List of subdivision types as defined by pycountry), (short or long form to be used)]
|
||||
'AU': (['State', 'Territory'], 'short'),
|
||||
'BR': (['State'], 'short'),
|
||||
'CA': (['Province', 'Territory'], 'short'),
|
||||
'CN': (['Province', 'Autonomous region', 'Munincipality'], 'long'),
|
||||
'MY': (['State'], 'long'),
|
||||
'MX': (['State', 'Federal District'], 'short'),
|
||||
'US': (['State', 'Outlying area', 'District'], 'short'),
|
||||
}
|
||||
|
||||
|
||||
settings_hierarkey = Hierarkey(attribute_name='settings')
|
||||
|
||||
@@ -139,6 +139,24 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
return sorted_list
|
||||
|
||||
|
||||
class GlobalSignal(django.dispatch.Signal):
|
||||
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
|
||||
"""
|
||||
Send signal from sender to all connected receivers. The return value of the first receiver
|
||||
will be used as the keyword argument specified by ``chain_kwarg_name`` in the input to the
|
||||
second receiver and so on. The return value of the last receiver is returned by this method.
|
||||
|
||||
"""
|
||||
response = named.get(chain_kwarg_name)
|
||||
if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS:
|
||||
return response
|
||||
|
||||
for receiver in self._live_receivers(sender):
|
||||
named[chain_kwarg_name] = response
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
return response
|
||||
|
||||
|
||||
class DeprecatedSignal(django.dispatch.Signal):
|
||||
|
||||
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
|
||||
@@ -168,6 +186,16 @@ subclass of pretix.base.payment.BasePaymentProvider or a list of these
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
register_mail_placeholders = EventPluginSignal(
|
||||
providing_args=[]
|
||||
)
|
||||
"""
|
||||
This signal is sent out to get all known email text placeholders. Receivers should return
|
||||
an instance of a subclass of pretix.base.email.BaseMailTextPlaceholder or a list of these.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
register_html_mail_renderers = EventPluginSignal(
|
||||
providing_args=[]
|
||||
)
|
||||
@@ -500,7 +528,7 @@ As with all event-plugin signals, the ``sender`` keyword argument will contain t
|
||||
"""
|
||||
|
||||
email_filter = EventPluginSignal(
|
||||
providing_args=['message', 'order']
|
||||
providing_args=['message', 'order', 'user']
|
||||
)
|
||||
"""
|
||||
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
||||
@@ -510,8 +538,24 @@ As with all event-plugin signals, the ``sender`` keyword argument will contain t
|
||||
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
||||
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
||||
it will be ``None``.
|
||||
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
||||
well, otherwise it will be ``None``.
|
||||
"""
|
||||
|
||||
global_email_filter = GlobalSignal(
|
||||
providing_args=['message', 'order', 'user']
|
||||
)
|
||||
"""
|
||||
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
||||
return a (possibly modified) copy of the message object passed to you.
|
||||
|
||||
This signal is called on all events and even if there is no known event. ``sender`` is an event or None.
|
||||
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
||||
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
||||
it will be ``None``.
|
||||
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
||||
well, otherwise it will be ``None``.
|
||||
"""
|
||||
|
||||
layout_text_variables = EventPluginSignal()
|
||||
"""
|
||||
|
||||
@@ -159,8 +159,7 @@
|
||||
<!--[if !mso]><!-- -->
|
||||
<tr>
|
||||
<td>
|
||||
<img class="wide" src="data:image/png;base64,
|
||||
iVBORw0KGgoAAAANSUhEUgAAAlgAAAA8CAAAAACf95tlAAAAAXNCSVQI5gpbmQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAG/SURBVHja7dvRboMwDIXhvf/DLiQQAwkku9+qDgq2hPyfN6j1qTlx06/uMunbLMnnhL98fuzRDtYILEeZ7GBNwAIWsIB1LdkOVgaWo4gdLAGWo6x2sFZgOUq1g1WB5SjNDlYDlqcEK1dDB5anmK3eE7C4FnIpBNbVFLo7sB7d3huwKFlULGA9pWQJsJxls4G1ActbooWr2IHlLbMFrBlY7rJbwNqBxb2QZ8nAuiUGO9ICLOo71R1YN0X9td8KLJ8ZeDEDrAd+Za3A4mLIz4TAujGqv+tUYPmN4v8LcweW3zS1t++hActzCrtRYD3pMJQOLOeJ7NyBpZFdoWaFDVjuJ6BRswpTBZbCAn5hpsDq/fbHpDMTBZbC1TAzT2ApyMIVsDROQ2GWwFJo8PR2YP3eOtywzwrsGYD1J9vlHXzcmSKw7q/wU2OEwHpdtALHILA00jJfV8DSaVofvYOPlckB658sp/8VNrBkANahqnXqfhhXJgasgymHD8REZwfWmezzga+tQdhcAet0qry1FYV3osD6dP1QJL3YbYUkhfUCsK6einWRPI0pxjROWZbK+QcsAiwCLEKARYBFgEXIu/wAYbjtwujw8KwAAAAASUVORK5CYII="
|
||||
<img class="wide" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAlgAAAA8CAAAAACf95tlAAAAAXNCSVQI5gpbmQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAG/SURBVHja7dvRboMwDIXhvf/DLiQQAwkku9+qDgq2hPyfN6j1qTlx06/uMunbLMnnhL98fuzRDtYILEeZ7GBNwAIWsIB1LdkOVgaWo4gdLAGWo6x2sFZgOUq1g1WB5SjNDlYDlqcEK1dDB5anmK3eE7C4FnIpBNbVFLo7sB7d3huwKFlULGA9pWQJsJxls4G1ActbooWr2IHlLbMFrBlY7rJbwNqBxb2QZ8nAuiUGO9ICLOo71R1YN0X9td8KLJ8ZeDEDrAd+Za3A4mLIz4TAujGqv+tUYPmN4v8LcweW3zS1t++hActzCrtRYD3pMJQOLOeJ7NyBpZFdoWaFDVjuJ6BRswpTBZbCAn5hpsDq/fbHpDMTBZbC1TAzT2ApyMIVsDROQ2GWwFJo8PR2YP3eOtywzwrsGYD1J9vlHXzcmSKw7q/wU2OEwHpdtALHILA00jJfV8DSaVofvYOPlckB658sp/8VNrBkANahqnXqfhhXJgasgymHD8REZwfWmezzga+tQdhcAet0qry1FYV3osD6dP1QJL3YbYUkhfUCsK6einWRPI0pxjROWZbK+QcsAiwCLEKARYBFgEXIu/wAYbjtwujw8KwAAAAASUVORK5CYII="
|
||||
style="max-height: 60px;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -14,6 +14,8 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
|
||||
if isinstance(value, float) or isinstance(value, int):
|
||||
value = Decimal(value)
|
||||
if not isinstance(value, Decimal):
|
||||
if value == '':
|
||||
return value
|
||||
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
|
||||
if not arg:
|
||||
raise ValueError("No currency passed.")
|
||||
|
||||
@@ -54,7 +54,7 @@ ALLOWED_ATTRIBUTES = {
|
||||
'td': ['width', 'align'],
|
||||
'div': ['class'],
|
||||
'p': ['class'],
|
||||
'span': ['class'],
|
||||
'span': ['class', 'title'],
|
||||
# Update doc/user/markdown.rst if you change this!
|
||||
}
|
||||
|
||||
|
||||
16
src/pretix/base/views/js_helpers.py
Normal file
16
src/pretix/base/views/js_helpers.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import pycountry
|
||||
from django.http import JsonResponse
|
||||
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
|
||||
|
||||
def states(request):
|
||||
cc = request.GET.get("country", "DE")
|
||||
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
return JsonResponse({'data': []})
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
|
||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||
return JsonResponse({'data': [
|
||||
{'name': s.name, 'code': s.code[3:]}
|
||||
for s in sorted(statelist, key=lambda s: s.name)
|
||||
]})
|
||||
@@ -160,7 +160,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
def positions(self):
|
||||
qqs = self.request.event.questions.all()
|
||||
if self.only_user_visible:
|
||||
qqs = qqs.filter(ask_during_checkin=False)
|
||||
qqs = qqs.filter(ask_during_checkin=False, hidden=False)
|
||||
return list(self.order.positions.select_related(
|
||||
'item', 'variation'
|
||||
).prefetch_related(
|
||||
|
||||
@@ -99,11 +99,6 @@ def contextprocessor(request):
|
||||
ctx['js_locale'] = get_moment_locale()
|
||||
ctx['select2locale'] = get_language()[:2]
|
||||
|
||||
if settings.DEBUG and 'runserver' not in sys.argv:
|
||||
ctx['debug_warning'] = True
|
||||
elif 'runserver' in sys.argv:
|
||||
ctx['development_warning'] = True
|
||||
|
||||
ctx['warning_update_available'] = False
|
||||
ctx['warning_update_check_active'] = False
|
||||
gs = GlobalSettingsObject()
|
||||
|
||||
@@ -5,6 +5,7 @@ from django_scopes.forms import (
|
||||
SafeModelChoiceField, SafeModelMultipleChoiceField,
|
||||
)
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.models.checkin import CheckinList
|
||||
from pretix.control.forms.widgets import Select2
|
||||
|
||||
@@ -15,6 +16,16 @@ class CheckinListForm(forms.ModelForm):
|
||||
kwargs.pop('locales', None)
|
||||
super().__init__(**kwargs)
|
||||
self.fields['limit_products'].queryset = self.event.items.all()
|
||||
self.fields['auto_checkin_sales_channels'] = forms.MultipleChoiceField(
|
||||
label=self.fields['auto_checkin_sales_channels'].label,
|
||||
help_text=self.fields['auto_checkin_sales_channels'].help_text,
|
||||
required=self.fields['auto_checkin_sales_channels'].required,
|
||||
choices=(
|
||||
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
|
||||
),
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
@@ -40,12 +51,14 @@ class CheckinListForm(forms.ModelForm):
|
||||
'all_products',
|
||||
'limit_products',
|
||||
'subevent',
|
||||
'include_pending'
|
||||
'include_pending',
|
||||
'auto_checkin_sales_channels'
|
||||
]
|
||||
widgets = {
|
||||
'limit_products': forms.CheckboxSelectMultiple(attrs={
|
||||
'data-inverse-dependency': '<[name$=all_products]'
|
||||
}),
|
||||
'auto_checkin_sales_channels': forms.CheckboxSelectMultiple()
|
||||
}
|
||||
field_classes = {
|
||||
'limit_products': SafeModelMultipleChoiceField,
|
||||
|
||||
@@ -20,6 +20,7 @@ from i18nfield.forms import (
|
||||
from pytz import common_timezones, timezone
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.email import get_available_placeholders
|
||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
|
||||
from pretix.base.models import Event, Organizer, TaxRule
|
||||
from pretix.base.models.event import EventMetaValue, SubEvent
|
||||
@@ -177,7 +178,7 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
return slug
|
||||
|
||||
|
||||
class EventChoiceField(forms.ModelChoiceField):
|
||||
class EventChoiceMixin:
|
||||
def label_from_instance(self, obj):
|
||||
return mark_safe('{}<br /><span class="text-muted">{} · {}</span>'.format(
|
||||
escape(str(obj)),
|
||||
@@ -186,6 +187,16 @@ class EventChoiceField(forms.ModelChoiceField):
|
||||
))
|
||||
|
||||
|
||||
class EventChoiceField(forms.ModelChoiceField):
|
||||
pass
|
||||
|
||||
|
||||
class SafeEventMultipleChoiceField(EventChoiceMixin, forms.ModelMultipleChoiceField):
|
||||
def __init__(self, queryset, *args, **kwargs):
|
||||
queryset = queryset.model.objects.none()
|
||||
super().__init__(queryset, *args, **kwargs)
|
||||
|
||||
|
||||
class EventWizardCopyForm(forms.Form):
|
||||
|
||||
@staticmethod
|
||||
@@ -325,7 +336,7 @@ class EventSettingsForm(SettingsForm):
|
||||
)
|
||||
timezone = forms.ChoiceField(
|
||||
choices=((a, a) for a in common_timezones),
|
||||
label=_("Default timezone"),
|
||||
label=_("Event timezone"),
|
||||
)
|
||||
locales = forms.MultipleChoiceField(
|
||||
choices=settings.LANGUAGES,
|
||||
@@ -395,8 +406,8 @@ class EventSettingsForm(SettingsForm):
|
||||
"only to that email address. If you enable this option, the system will additionally ask for "
|
||||
"individual email addresses for every admission ticket. This might be useful if you want to "
|
||||
"obtain individual addresses for every attendee even in case of group orders. However, "
|
||||
"pretix will send the order confirmation only to the one primary email address, not to the "
|
||||
"per-attendee addresses."),
|
||||
"pretix will send the order confirmation by default only to the one primary email address, not to "
|
||||
"the per-attendee addresses. You can however enable this in the E-mail settings."),
|
||||
required=False
|
||||
)
|
||||
attendee_emails_required = forms.BooleanField(
|
||||
@@ -441,10 +452,100 @@ class EventSettingsForm(SettingsForm):
|
||||
required=False,
|
||||
help_text=_("We'll show this publicly to allow attendees to contact you.")
|
||||
)
|
||||
show_variations_expanded = forms.BooleanField(
|
||||
label=_("Show variations of a product expanded by default"),
|
||||
required=False
|
||||
)
|
||||
hide_sold_out = forms.BooleanField(
|
||||
label=_("Hide all products that are sold out"),
|
||||
required=False
|
||||
)
|
||||
meta_noindex = forms.BooleanField(
|
||||
label=_('Ask search engines not to index the ticket shop'),
|
||||
required=False
|
||||
)
|
||||
redirect_to_checkout_directly = forms.BooleanField(
|
||||
label=_('Directly redirect to check-out after a product has been added to the cart.'),
|
||||
required=False
|
||||
)
|
||||
frontpage_subevent_ordering = forms.ChoiceField(
|
||||
label=pgettext('subevent', 'Date ordering'),
|
||||
choices=[
|
||||
('date_ascending', _('Event start time')),
|
||||
('date_descending', _('Event start time (descending)')),
|
||||
('name_ascending', _('Name')),
|
||||
('name_descending', _('Name (descending)')),
|
||||
], # When adding a new ordering, remember to also define it in the event model
|
||||
)
|
||||
logo_image = ExtFileField(
|
||||
label=_('Logo image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
help_text=_('If you provide a logo image, we will by default not show your events name and date '
|
||||
'in the page header. We will show your logo with a maximal height of 120 pixels.')
|
||||
)
|
||||
frontpage_text = I18nFormField(
|
||||
label=_("Frontpage text"),
|
||||
required=False,
|
||||
widget=I18nTextarea
|
||||
)
|
||||
presale_has_ended_text = I18nFormField(
|
||||
label=_("End of presale text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
widget_kwargs={'attrs': {'rows': '2'}},
|
||||
help_text=_("This text will be shown above the ticket shop once the designated sales timeframe for this event "
|
||||
"is over. You can use it to describe other options to get a ticket, such as a box office.")
|
||||
)
|
||||
voucher_explanation_text = I18nFormField(
|
||||
label=_("Voucher explanation"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
widget_kwargs={'attrs': {'rows': '2'}},
|
||||
help_text=_("This text will be shown next to the input for a voucher code. You can use it e.g. to explain "
|
||||
"how to obtain a voucher code.")
|
||||
)
|
||||
primary_color = forms.CharField(
|
||||
label=_("Primary color"),
|
||||
required=False,
|
||||
validators=[
|
||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||
],
|
||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||
)
|
||||
theme_color_success = forms.CharField(
|
||||
label=_("Accent color for success"),
|
||||
help_text=_("We strongly suggest to use a shade of green."),
|
||||
required=False,
|
||||
validators=[
|
||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||
],
|
||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||
)
|
||||
theme_color_danger = forms.CharField(
|
||||
label=_("Accent color for errors"),
|
||||
help_text=_("We strongly suggest to use a dark shade of red."),
|
||||
required=False,
|
||||
validators=[
|
||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||
],
|
||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||
)
|
||||
primary_font = forms.ChoiceField(
|
||||
label=_('Font'),
|
||||
choices=[
|
||||
('Open Sans', 'Open Sans')
|
||||
],
|
||||
widget=FontSelect,
|
||||
help_text=_('Only respected by modern browsers.')
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if data['locale'] not in data['locales']:
|
||||
if 'locales' in data and data['locale'] not in data['locales']:
|
||||
raise ValidationError({
|
||||
'locale': _('Your default locale must also be enabled for your event (see box above).')
|
||||
})
|
||||
@@ -459,6 +560,7 @@ class EventSettingsForm(SettingsForm):
|
||||
return data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs['obj']
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['confirm_text'].widget.attrs['rows'] = '3'
|
||||
self.fields['confirm_text'].widget.attrs['placeholder'] = _(
|
||||
@@ -479,6 +581,11 @@ class EventSettingsForm(SettingsForm):
|
||||
))
|
||||
for k, v in PERSON_NAME_TITLE_GROUPS.items()
|
||||
]
|
||||
if not event.has_subevents:
|
||||
del self.fields['frontpage_subevent_ordering']
|
||||
self.fields['primary_font'].choices += [
|
||||
(a, a) for a in get_fonts()
|
||||
]
|
||||
|
||||
|
||||
class CancelSettingsForm(SettingsForm):
|
||||
@@ -691,6 +798,12 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
"used at most once over all of your events. This setting only affects future invoices."),
|
||||
required=False,
|
||||
)
|
||||
invoice_numbers_prefix_cancellations = forms.CharField(
|
||||
label=_("Invoice number prefix for cancellations"),
|
||||
help_text=_("This will be prepended to invoice numbers of cancellations. If you leave this field empty, "
|
||||
"the same numbering scheme will be used that you configured for regular invoices."),
|
||||
required=False,
|
||||
)
|
||||
invoice_generate = forms.ChoiceField(
|
||||
label=_("Generate invoices"),
|
||||
required=False,
|
||||
@@ -824,6 +937,10 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
(r.identifier, r.verbose_name) for r in event.get_invoice_renderers().values()
|
||||
]
|
||||
self.fields['invoice_numbers_prefix'].widget.attrs['placeholder'] = event.slug.upper() + '-'
|
||||
if event.settings.invoice_numbers_prefix:
|
||||
self.fields['invoice_numbers_prefix_cancellations'].widget.attrs['placeholder'] = event.settings.invoice_numbers_prefix
|
||||
else:
|
||||
self.fields['invoice_numbers_prefix_cancellations'].widget.attrs['placeholder'] = event.slug.upper() + '-'
|
||||
locale_names = dict(settings.LANGUAGES)
|
||||
self.fields['invoice_language'].choices = [('__user__', _('The user\'s language'))] + [(a, locale_names[a]) for a in event.settings.locales]
|
||||
self.fields['invoice_generate_sales_channels'].choices = (
|
||||
@@ -886,10 +1003,6 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {total_with_currency}, {total}, {currency}, {date}, "
|
||||
"{payment_info}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}',
|
||||
'{payment_info}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_send_order_placed_attendee = forms.BooleanField(
|
||||
label=_("Send an email to attendees"),
|
||||
@@ -901,16 +1014,12 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {attendee_name}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])],
|
||||
)
|
||||
|
||||
mail_text_order_paid = I18nFormField(
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}, {payment_info}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}', '{payment_info}'])]
|
||||
)
|
||||
mail_send_order_paid_attendee = forms.BooleanField(
|
||||
label=_("Send an email to attendees"),
|
||||
@@ -922,16 +1031,12 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {attendee_name}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])],
|
||||
)
|
||||
|
||||
mail_text_order_free = I18nFormField(
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_send_order_free_attendee = forms.BooleanField(
|
||||
label=_("Send an email to attendees"),
|
||||
@@ -943,34 +1048,26 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {attendee_name}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])],
|
||||
)
|
||||
|
||||
mail_text_order_changed = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_resend_link = I18nFormField(
|
||||
label=_("Text (sent by admin)"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_resend_all_links = I18nFormField(
|
||||
label=_("Text (requested by user)"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {orders}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{orders}'])]
|
||||
)
|
||||
mail_days_order_expire_warning = forms.IntegerField(
|
||||
label=_("Number of days"),
|
||||
required=False,
|
||||
required=True,
|
||||
min_value=0,
|
||||
help_text=_("This email will be sent out this many days before the order expires. If the "
|
||||
"value is 0, the mail will never be sent.")
|
||||
@@ -979,38 +1076,26 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{expire_date}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_waiting_list = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {product}, {hours}, {code}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{product}', '{hours}', '{code}'])]
|
||||
)
|
||||
mail_text_order_canceled = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {code}, {url}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{code}', '{url}'])]
|
||||
)
|
||||
mail_text_order_custom_mail = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {expire_date}, {event}, {code}, {date}, {url}, "
|
||||
"{invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{expire_date}', '{event}', '{code}', '{date}', '{url}',
|
||||
'{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_download_reminder = I18nFormField(
|
||||
label=_("Text sent to order contact address"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}'])]
|
||||
)
|
||||
mail_send_download_reminder_attendee = forms.BooleanField(
|
||||
label=_("Send an email to attendees"),
|
||||
@@ -1022,8 +1107,6 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {attendee_name}, {event}, {url}"),
|
||||
validators=[PlaceholderValidator(['{attendee_name}', '{event}', '{url}'])]
|
||||
)
|
||||
mail_days_download_reminder = forms.IntegerField(
|
||||
label=_("Number of days"),
|
||||
@@ -1036,29 +1119,18 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Received order"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {total_with_currency}, {total}, {currency}, {date}, "
|
||||
"{url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}',
|
||||
'{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_order_approved = I18nFormField(
|
||||
label=_("Approved order"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("This will only be sent out for non-free orders. Free orders will receive the free order "
|
||||
"template from above instead. Available placeholders: {event}, {total_with_currency}, {total}, "
|
||||
"{currency}, {date}, {payment_info}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}',
|
||||
'{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
"template from above instead."),
|
||||
)
|
||||
mail_text_order_denied = I18nFormField(
|
||||
label=_("Denied order"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {total_with_currency}, {total}, {currency}, {date}, "
|
||||
"{comment}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}',
|
||||
'{comment}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
smtp_use_custom = forms.BooleanField(
|
||||
label=_("Use custom SMTP server"),
|
||||
@@ -1097,29 +1169,53 @@ class MailSettingsForm(SettingsForm):
|
||||
help_text=_("Commonly enabled on port 465."),
|
||||
required=False
|
||||
)
|
||||
base_context = {
|
||||
'mail_text_order_placed': ['event', 'order', 'payment'],
|
||||
'mail_text_order_placed_attendee': ['event', 'order', 'position'],
|
||||
'mail_text_order_placed_require_approval': ['event', 'order'],
|
||||
'mail_text_order_approved': ['event', 'order'],
|
||||
'mail_text_order_denied': ['event', 'order', 'comment'],
|
||||
'mail_text_order_paid': ['event', 'order', 'payment_info'],
|
||||
'mail_text_order_paid_attendee': ['event', 'order', 'position'],
|
||||
'mail_text_order_free': ['event', 'order'],
|
||||
'mail_text_order_free_attendee': ['event', 'order', 'position'],
|
||||
'mail_text_order_changed': ['event', 'order'],
|
||||
'mail_text_order_canceled': ['event', 'order'],
|
||||
'mail_text_order_expire_warning': ['event', 'order'],
|
||||
'mail_text_order_custom_mail': ['event', 'order'],
|
||||
'mail_text_download_reminder': ['event', 'order'],
|
||||
'mail_text_download_reminder_attendee': ['event', 'order', 'position'],
|
||||
'mail_text_resend_link': ['event', 'order'],
|
||||
'mail_text_waiting_list': ['event', 'waiting_list_entry'],
|
||||
'mail_text_resend_all_links': ['event', 'orders']
|
||||
}
|
||||
|
||||
def _set_field_placeholders(self, fn, base_parameters):
|
||||
phs = [
|
||||
'{%s}' % p
|
||||
for p in sorted(get_available_placeholders(self.event, base_parameters).keys())
|
||||
]
|
||||
ht = _('Available placeholders: {list}').format(
|
||||
list=', '.join(phs)
|
||||
)
|
||||
if self.fields[fn].help_text:
|
||||
self.fields[fn].help_text += ' ' + str(ht)
|
||||
else:
|
||||
self.fields[fn].help_text = ht
|
||||
self.fields[fn].validators.append(
|
||||
PlaceholderValidator(phs)
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs.get('obj')
|
||||
self.event = event = kwargs.get('obj')
|
||||
super().__init__(*args, **kwargs)
|
||||
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())
|
||||
name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
for k, v in self.base_context.items():
|
||||
self._set_field_placeholders(k, v)
|
||||
|
||||
for k, v in list(self.fields.items()):
|
||||
if k.startswith('mail_text_'):
|
||||
v.help_text = str(v.help_text) + ', ' + ', '.join({
|
||||
'{meta_' + p + '}' for p in keys
|
||||
})
|
||||
v.validators[0].limit_value += ['{meta_' + p + '}' for p in keys]
|
||||
|
||||
if '{attendee_name}' in v.validators[0].limit_value:
|
||||
for f, l, w in name_scheme['fields']:
|
||||
if f == 'full_name':
|
||||
continue
|
||||
v.help_text = str(v.help_text) + ', ' + '{attendee_name_%s}' % f
|
||||
v.validators[0].limit_value += ['{attendee_name_' + f + '}']
|
||||
|
||||
if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
|
||||
# If we don't ask for attendee emails, we can't send them anything and we don't need to clutter
|
||||
# the user interface with it
|
||||
@@ -1137,108 +1233,6 @@ class MailSettingsForm(SettingsForm):
|
||||
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
|
||||
|
||||
|
||||
class DisplaySettingsForm(SettingsForm):
|
||||
primary_color = forms.CharField(
|
||||
label=_("Primary color"),
|
||||
required=False,
|
||||
validators=[
|
||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||
],
|
||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||
)
|
||||
theme_color_success = forms.CharField(
|
||||
label=_("Accent color for success"),
|
||||
help_text=_("We strongly suggest to use a shade of green."),
|
||||
required=False,
|
||||
validators=[
|
||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||
],
|
||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||
)
|
||||
theme_color_danger = forms.CharField(
|
||||
label=_("Accent color for errors"),
|
||||
help_text=_("We strongly suggest to use a dark shade of red."),
|
||||
required=False,
|
||||
validators=[
|
||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.')),
|
||||
],
|
||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||
)
|
||||
logo_image = ExtFileField(
|
||||
label=_('Logo image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
help_text=_('If you provide a logo image, we will by default not show your events name and date '
|
||||
'in the page header. We will show your logo with a maximal height of 120 pixels.')
|
||||
)
|
||||
primary_font = forms.ChoiceField(
|
||||
label=_('Font'),
|
||||
choices=[
|
||||
('Open Sans', 'Open Sans')
|
||||
],
|
||||
widget=FontSelect,
|
||||
help_text=_('Only respected by modern browsers.')
|
||||
)
|
||||
frontpage_text = I18nFormField(
|
||||
label=_("Frontpage text"),
|
||||
required=False,
|
||||
widget=I18nTextarea
|
||||
)
|
||||
presale_has_ended_text = I18nFormField(
|
||||
label=_("End of presale text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
widget_kwargs={'attrs': {'rows': '2'}},
|
||||
help_text=_("This text will be shown above the ticket shop once the designated sales timeframe for this event "
|
||||
"is over. You can use it to describe other options to get a ticket, such as a box office.")
|
||||
)
|
||||
voucher_explanation_text = I18nFormField(
|
||||
label=_("Voucher explanation"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
widget_kwargs={'attrs': {'rows': '2'}},
|
||||
help_text=_("This text will be shown next to the input for a voucher code. You can use it e.g. to explain "
|
||||
"how to obtain a voucher code.")
|
||||
)
|
||||
show_variations_expanded = forms.BooleanField(
|
||||
label=_("Show variations of a product expanded by default"),
|
||||
required=False
|
||||
)
|
||||
hide_sold_out = forms.BooleanField(
|
||||
label=_("Hide all products that are sold out"),
|
||||
required=False
|
||||
)
|
||||
frontpage_subevent_ordering = forms.ChoiceField(
|
||||
label=pgettext('subevent', 'Date ordering'),
|
||||
choices=[
|
||||
('date_ascending', _('Event start time')),
|
||||
('date_descending', _('Event start time (descending)')),
|
||||
('name_ascending', _('Name')),
|
||||
('name_descending', _('Name (descending)')),
|
||||
], # When adding a new ordering, remember to also define it in the event model
|
||||
)
|
||||
meta_noindex = forms.BooleanField(
|
||||
label=_('Ask search engines not to index the ticket shop'),
|
||||
required=False
|
||||
)
|
||||
redirect_to_checkout_directly = forms.BooleanField(
|
||||
label=_('Directly redirect to check-out after a product has been added to the cart.'),
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs['obj']
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['primary_font'].choices += [
|
||||
(a, a) for a in get_fonts()
|
||||
]
|
||||
if not event.has_subevents:
|
||||
del self.fields['frontpage_subevent_ordering']
|
||||
|
||||
|
||||
class TicketSettingsForm(SettingsForm):
|
||||
ticket_download = forms.BooleanField(
|
||||
label=_("Use feature"),
|
||||
|
||||
@@ -902,6 +902,43 @@ class VoucherFilterForm(FilterForm):
|
||||
return qs
|
||||
|
||||
|
||||
class VoucherTagFilterForm(FilterForm):
|
||||
subevent = forms.ModelChoiceField(
|
||||
label=pgettext_lazy('subevent', 'Date'),
|
||||
queryset=SubEvent.objects.none(),
|
||||
required=False,
|
||||
empty_label=pgettext_lazy('subevent', 'All dates')
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'All dates')
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
elif 'subevent':
|
||||
del self.fields['subevent']
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
|
||||
if fdata.get('subevent'):
|
||||
qs = qs.filter(subevent_id=fdata.get('subevent').pk)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class RefundFilterForm(FilterForm):
|
||||
provider = forms.ChoiceField(
|
||||
label=_('Payment provider'),
|
||||
|
||||
@@ -94,7 +94,8 @@ class QuestionForm(I18nModelForm):
|
||||
'identifier',
|
||||
'items',
|
||||
'dependency_question',
|
||||
'dependency_values'
|
||||
'dependency_values',
|
||||
'print_on_invoice',
|
||||
]
|
||||
widgets = {
|
||||
'items': forms.CheckboxSelectMultiple(
|
||||
@@ -124,7 +125,7 @@ class QuotaForm(I18nModelForm):
|
||||
items = kwargs.pop('items', None) or self.event.items.prefetch_related('variations')
|
||||
self.original_instance = modelcopy(self.instance) if self.instance else None
|
||||
initial = kwargs.get('initial', {})
|
||||
if self.instance and self.instance.pk:
|
||||
if self.instance and self.instance.pk and 'itemvars' not in initial:
|
||||
initial['itemvars'] = [str(i.pk) for i in self.instance.items.all()] + [
|
||||
'{}-{}'.format(v.item_id, v.pk) for v in self.instance.variations.all()
|
||||
]
|
||||
@@ -358,7 +359,6 @@ class ItemCreateForm(I18nModelForm):
|
||||
'admission',
|
||||
'default_price',
|
||||
'tax_rule',
|
||||
'allow_cancel'
|
||||
]
|
||||
|
||||
|
||||
@@ -403,6 +403,19 @@ class ItemUpdateForm(I18nModelForm):
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
change_decimal_field(self.fields['default_price'], self.event.currency)
|
||||
self.fields['hidden_if_available'].queryset = self.event.quotas.all()
|
||||
self.fields['hidden_if_available'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:event.items.quotas.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Quota')
|
||||
}
|
||||
)
|
||||
self.fields['hidden_if_available'].widget.choices = self.fields['hidden_if_available'].choices
|
||||
self.fields['hidden_if_available'].required = False
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
@@ -425,17 +438,20 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'require_approval',
|
||||
'hide_without_voucher',
|
||||
'allow_cancel',
|
||||
'allow_waitinglist',
|
||||
'max_per_order',
|
||||
'min_per_order',
|
||||
'checkin_attention',
|
||||
'generate_tickets',
|
||||
'original_price',
|
||||
'require_bundling',
|
||||
'show_quota_left'
|
||||
'show_quota_left',
|
||||
'hidden_if_available',
|
||||
]
|
||||
field_classes = {
|
||||
'available_from': SplitDateTimeField,
|
||||
'available_until': SplitDateTimeField,
|
||||
'hidden_if_available': SafeModelChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'available_from': SplitDateTimePickerWidget(),
|
||||
@@ -446,6 +462,9 @@ class ItemUpdateForm(I18nModelForm):
|
||||
|
||||
|
||||
class ItemVariationsFormSet(I18nFormSet):
|
||||
template = "pretixcontrol/item/include_variations.html"
|
||||
title = _('Variations')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
for f in self.forms:
|
||||
@@ -502,6 +521,9 @@ class ItemVariationForm(I18nModelForm):
|
||||
|
||||
|
||||
class ItemAddOnsFormSet(I18nFormSet):
|
||||
title = _('Add-ons')
|
||||
template = "pretixcontrol/item/include_addons.html"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.get('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -567,6 +589,9 @@ class ItemAddOnForm(I18nModelForm):
|
||||
|
||||
|
||||
class ItemBundleFormSet(I18nFormSet):
|
||||
template = "pretixcontrol/item/include_bundles.html"
|
||||
title = _('Bundled products')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.get('event')
|
||||
self.item = kwargs.pop('item')
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.email import get_available_placeholders
|
||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator
|
||||
from pretix.base.forms.widgets import DatePickerWidget
|
||||
from pretix.base.models import (
|
||||
@@ -167,6 +168,15 @@ class OtherOperationsForm(forms.Form):
|
||||
'Use with care and only if you need to. Note that rounding differences might occur in this procedure.'
|
||||
)
|
||||
)
|
||||
reissue_invoice = forms.BooleanField(
|
||||
label=_('Issue a new invoice if required'),
|
||||
required=False,
|
||||
initial=True,
|
||||
help_text=_(
|
||||
'If an invoice exists for this order and this operation would change its contents, the old invoice will '
|
||||
'be cancelled and a new invoice will be issued.'
|
||||
)
|
||||
)
|
||||
notify = forms.BooleanField(
|
||||
label=_('Notify user'),
|
||||
required=False,
|
||||
@@ -186,10 +196,6 @@ class OtherOperationsForm(forms.Form):
|
||||
|
||||
|
||||
class OrderPositionAddForm(forms.Form):
|
||||
do = forms.BooleanField(
|
||||
label=_('Add a new product to the order'),
|
||||
required=False
|
||||
)
|
||||
itemvar = forms.ChoiceField(
|
||||
label=_('Product')
|
||||
)
|
||||
@@ -281,6 +287,28 @@ class OrderPositionAddForm(forms.Form):
|
||||
change_decimal_field(self.fields['price'], order.event.currency)
|
||||
|
||||
|
||||
class OrderPositionAddFormset(forms.BaseFormSet):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.order = kwargs.pop('order', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _construct_form(self, i, **kwargs):
|
||||
kwargs['order'] = self.order
|
||||
return super()._construct_form(i, **kwargs)
|
||||
|
||||
@property
|
||||
def empty_form(self):
|
||||
form = self.form(
|
||||
auto_id=self.auto_id,
|
||||
prefix=self.add_prefix('__prefix__'),
|
||||
empty_permitted=True,
|
||||
use_required_attribute=False,
|
||||
order=self.order,
|
||||
)
|
||||
self.add_fields(form, None)
|
||||
return form
|
||||
|
||||
|
||||
class OrderPositionChangeForm(forms.Form):
|
||||
itemvar = forms.ChoiceField(
|
||||
required=False,
|
||||
@@ -408,8 +436,24 @@ class OrderMailForm(forms.Form):
|
||||
required=True
|
||||
)
|
||||
|
||||
def _set_field_placeholders(self, fn, base_parameters):
|
||||
phs = [
|
||||
'{%s}' % p
|
||||
for p in sorted(get_available_placeholders(self.order.event, base_parameters).keys())
|
||||
]
|
||||
ht = _('Available placeholders: {list}').format(
|
||||
list=', '.join(phs)
|
||||
)
|
||||
if self.fields[fn].help_text:
|
||||
self.fields[fn].help_text += ' ' + str(ht)
|
||||
else:
|
||||
self.fields[fn].help_text = ht
|
||||
self.fields[fn].validators.append(
|
||||
PlaceholderValidator(phs)
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
order = kwargs.pop('order')
|
||||
order = self.order = kwargs.pop('order')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['sendto'] = forms.EmailField(
|
||||
label=_("Recipient"),
|
||||
@@ -422,11 +466,8 @@ class OrderMailForm(forms.Form):
|
||||
required=True,
|
||||
widget=forms.Textarea,
|
||||
initial=order.event.settings.mail_text_order_custom_mail.localize(order.locale),
|
||||
help_text=_("Available placeholders: {expire_date}, {event}, {code}, {date}, {url}, "
|
||||
"{invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{expire_date}', '{event}', '{code}', '{date}', '{url}',
|
||||
'{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
self._set_field_placeholders('message', ['event', 'order'])
|
||||
|
||||
|
||||
class OrderRefundForm(forms.Form):
|
||||
|
||||
@@ -16,6 +16,7 @@ from pretix.base.models import Device, Organizer, Team
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, FontSelect, MultipleLanguagesWidget,
|
||||
)
|
||||
from pretix.control.forms.event import SafeEventMultipleChoiceField
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
@@ -136,7 +137,9 @@ class TeamForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
organizer = kwargs.pop('organizer')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['limit_events'].queryset = organizer.events.all()
|
||||
self.fields['limit_events'].queryset = organizer.events.all().order_by(
|
||||
'-has_subevents', '-date_from'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
@@ -147,11 +150,12 @@ class TeamForm(forms.ModelForm):
|
||||
'can_view_vouchers', 'can_change_vouchers']
|
||||
widgets = {
|
||||
'limit_events': forms.CheckboxSelectMultiple(attrs={
|
||||
'data-inverse-dependency': '#id_all_events'
|
||||
'data-inverse-dependency': '#id_all_events',
|
||||
'class': 'scrolling-multiple-choice scrolling-multiple-choice-large',
|
||||
}),
|
||||
}
|
||||
field_classes = {
|
||||
'limit_events': SafeModelMultipleChoiceField
|
||||
'limit_events': SafeEventMultipleChoiceField
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
@@ -171,7 +175,9 @@ class DeviceForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
organizer = kwargs.pop('organizer')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['limit_events'].queryset = organizer.events.all()
|
||||
self.fields['limit_events'].queryset = organizer.events.all().order_by(
|
||||
'-has_subevents', '-date_from'
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
@@ -185,11 +191,12 @@ class DeviceForm(forms.ModelForm):
|
||||
fields = ['name', 'all_events', 'limit_events']
|
||||
widgets = {
|
||||
'limit_events': forms.CheckboxSelectMultiple(attrs={
|
||||
'data-inverse-dependency': '#id_all_events'
|
||||
'data-inverse-dependency': '#id_all_events',
|
||||
'class': 'scrolling-multiple-choice scrolling-multiple-choice-large',
|
||||
}),
|
||||
}
|
||||
field_classes = {
|
||||
'limit_events': SafeModelMultipleChoiceField
|
||||
'limit_events': SafeEventMultipleChoiceField
|
||||
}
|
||||
|
||||
|
||||
@@ -201,9 +208,6 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
widget=I18nTextarea,
|
||||
help_text=_('Not displayed anywhere by default, but if you want to, you can use this e.g. in ticket templates.')
|
||||
)
|
||||
|
||||
|
||||
class OrganizerDisplaySettingsForm(SettingsForm):
|
||||
primary_color = forms.CharField(
|
||||
label=_("Primary color"),
|
||||
required=False,
|
||||
|
||||
@@ -179,9 +179,7 @@ class VoucherForm(I18nModelForm):
|
||||
return data
|
||||
|
||||
def save(self, commit=True):
|
||||
super().save(commit)
|
||||
|
||||
return ['item']
|
||||
return super().save(commit)
|
||||
|
||||
|
||||
class VoucherBulkForm(VoucherForm):
|
||||
|
||||
@@ -48,14 +48,6 @@ def get_event_navigation(request: HttpRequest):
|
||||
}),
|
||||
'active': url.url_name == 'event.settings.plugins',
|
||||
},
|
||||
{
|
||||
'label': _('Display'),
|
||||
'url': reverse('control:event.settings.display', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
}),
|
||||
'active': url.url_name == 'event.settings.display',
|
||||
},
|
||||
{
|
||||
'label': _('Tickets'),
|
||||
'url': reverse('control:event.settings.tickets', kwargs={
|
||||
@@ -78,7 +70,7 @@ def get_event_navigation(request: HttpRequest):
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
}),
|
||||
'active': url.url_name == 'event.settings.tax',
|
||||
'active': url.url_name.startswith('event.settings.tax'),
|
||||
},
|
||||
{
|
||||
'label': _('Invoicing'),
|
||||
@@ -425,13 +417,6 @@ def get_organizer_navigation(request):
|
||||
}),
|
||||
'active': url.url_name == 'organizer.edit',
|
||||
},
|
||||
{
|
||||
'label': _('Display'),
|
||||
'url': reverse('control:organizer.display', kwargs={
|
||||
'organizer': request.organizer.slug
|
||||
}),
|
||||
'active': url.url_name == 'organizer.display',
|
||||
},
|
||||
]
|
||||
})
|
||||
if 'can_change_teams' in request.orgapermset:
|
||||
|
||||
@@ -237,24 +237,6 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
|
||||
A second keyword argument ``request`` will contain the request object.
|
||||
"""
|
||||
|
||||
nav_item = EventPluginSignal(
|
||||
providing_args=['request', 'item']
|
||||
)
|
||||
"""
|
||||
This signal is sent out to include tab links on the settings page of an item.
|
||||
Receivers are expected to return a list of dictionaries. The dictionaries
|
||||
should contain at least the keys ``label`` and ``url``. You should also return
|
||||
an ``active`` key with a boolean set to ``True``, when this item should be marked
|
||||
as active.
|
||||
|
||||
If your linked view should stay in the tab-like context of this page, we recommend
|
||||
that you use ``pretix.control.views.item.ItemDetailMixin`` for your view
|
||||
and your template inherits from ``pretixcontrol/item/base.html``.
|
||||
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
A second keyword argument ``request`` will contain the request object.
|
||||
"""
|
||||
|
||||
event_settings_widget = EventPluginSignal(
|
||||
providing_args=['request']
|
||||
)
|
||||
@@ -279,6 +261,24 @@ styles. It is advisable to set a prefix for your form to avoid clashes with othe
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
item_formsets = EventPluginSignal(
|
||||
providing_args=['request', 'item']
|
||||
)
|
||||
"""
|
||||
This signal allows you to return additional formsets that should be rendered on the product
|
||||
modification page. You are passed ``request`` and ``item`` arguments and are expected to return
|
||||
an instance of a formset class that you bind yourself when appropriate. Your formset will be
|
||||
executed as part of the standard validation and rendering cycle and rendered using default
|
||||
bootstrap styles. It is advisable to set a prefix for your formset to avoid clashes with other
|
||||
plugins.
|
||||
|
||||
Your formset needs to have two special properties: ``template`` with a template that will be
|
||||
included to render the formset and ``title`` that will be used as a headline. Your template
|
||||
will be passed a ``formset`` variable with your formset.
|
||||
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
subevent_forms = EventPluginSignal(
|
||||
providing_args=['request', 'subevent']
|
||||
)
|
||||
|
||||
@@ -10,12 +10,16 @@
|
||||
{% endcompress %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link rel="shortcut icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
{% if development_warning or debug_warning %}
|
||||
<link rel="shortcut icon" href="{% static "pretixbase/img/favicon-debug.ico" %}">
|
||||
{% else %}
|
||||
<link rel="shortcut icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{% static "pretixbase/img/icons/favicon-32x32.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="194x194" href="{% static "pretixbase/img/icons/favicon-194x194.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{% static "pretixbase/img/icons/favicon-16x16.png" %}">
|
||||
{% endif %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static "pretixbase/img/icons/apple-touch-icon.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{% static "pretixbase/img/icons/favicon-32x32.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="194x194" href="{% static "pretixbase/img/icons/favicon-194x194.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="{% static "pretixbase/img/icons/android-chrome-192x192.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{% static "pretixbase/img/icons/favicon-16x16.png" %}">
|
||||
<link rel="manifest" href="{% url "presale:site.webmanifest" %}">
|
||||
<link rel="mask-icon" href="{% static "pretixbase/img/icons/safari-pinned-tab.svg" %}" color="#3b1c4a">
|
||||
<meta name="msapplication-TileColor" content="#3b1c4a">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
{% block content %}
|
||||
<form class="form-signin" action="" method="post" id="u2f-form">
|
||||
<form class="form-signin" action="" method="post" id="webauthn-form">
|
||||
{% csrf_token %}
|
||||
<h3>{% trans "Welcome back!" %}</h3>
|
||||
<p>
|
||||
@@ -12,14 +12,14 @@
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<input class="form-control" name="token" placeholder="{% trans "Token" %}"
|
||||
type="text" required="required" autofocus="autofocus" id="u2f-response">
|
||||
type="text" required="required" autofocus="autofocus" id="webauthn-response">
|
||||
</div>
|
||||
<div class="sr-only alert alert-danger" id="u2f-error">
|
||||
{% trans "U2F failed. Check that the correct authentication device is correctly plugged in." %}
|
||||
<div class="sr-only alert alert-danger" id="webauthn-error">
|
||||
{% trans "WebAuthn failed. Check that the correct authentication device is correctly plugged in." %}
|
||||
</div>
|
||||
{% if jsondata %}
|
||||
<p><small>
|
||||
{% trans "Alternatively, connect your U2F device. If it has a button, touch it now. You might have to unplug the device and plug it back in again." %}
|
||||
{% trans "Alternatively, connect your WebAuthn device. If it has a button, touch it now. You might have to unplug the device and plug it back in again." %}
|
||||
</small></p>
|
||||
{% endif %}
|
||||
<div class="form-group buttons">
|
||||
@@ -29,14 +29,14 @@
|
||||
</div>
|
||||
</form>
|
||||
{% if jsondata %}
|
||||
<script type="text/json" id="u2f-login">
|
||||
<script type="text/json" id="webauthn-login">
|
||||
{{ jsondata|safe }}
|
||||
|
||||
</script>
|
||||
{% endif %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/u2f-api.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/u2f.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/base64js.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/webauthn.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/orderchange.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/typeahead.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quicksetup.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/dashboard.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/tabs.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
|
||||
@@ -57,12 +59,16 @@
|
||||
{{ html_head|safe }}
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
{% if development_warning or debug_warning %}
|
||||
<link rel="shortcut icon" href="{% static "pretixbase/img/favicon-debug.ico" %}">
|
||||
{% else %}
|
||||
<link rel="shortcut icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{% static "pretixbase/img/icons/favicon-32x32.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="194x194" href="{% static "pretixbase/img/icons/favicon-194x194.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{% static "pretixbase/img/icons/favicon-16x16.png" %}">
|
||||
{% endif %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static "pretixbase/img/icons/apple-touch-icon.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{% static "pretixbase/img/icons/favicon-32x32.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="194x194" href="{% static "pretixbase/img/icons/favicon-194x194.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="{% static "pretixbase/img/icons/android-chrome-192x192.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{% static "pretixbase/img/icons/favicon-16x16.png" %}">
|
||||
<link rel="manifest" href="{% url "presale:site.webmanifest" %}">
|
||||
<link rel="mask-icon" href="{% static "pretixbase/img/icons/safari-pinned-tab.svg" %}" color="#3b1c4a">
|
||||
<meta name="msapplication-TileColor" content="#3b1c4a">
|
||||
@@ -159,13 +165,13 @@
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
{% for item in nav.children %}
|
||||
<li>
|
||||
<a href="{{ item.url }}"
|
||||
<a {% if item.url %}href="{{ item.url }}"{% endif %}
|
||||
{% if item.external %}target="_blank"{% endif %}
|
||||
{% if item.active %}class="active"{% endif %}>
|
||||
{% if item.icon %}
|
||||
<span class="fa fa-{{ item.icon }}"></span>
|
||||
<span class="fa fa-fw fa-{{ item.icon }}"></span>
|
||||
{% endif %}
|
||||
{{ item.label|safe }}
|
||||
</a>
|
||||
{{ item.label|safe }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -254,7 +260,7 @@
|
||||
<div class="form-box">
|
||||
<input type="text" class="form-control" id="event-dropdown-field"
|
||||
placeholder="{% trans "Search for events" %}"
|
||||
data-typeahead-query>
|
||||
data-typeahead-query autocomplete="off">
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if e.addon_to %}
|
||||
{% if e.addon_to and not e.attendee_name %}
|
||||
{{ e.addon_to.attendee_name }}
|
||||
{% elif e.attendee_name %}
|
||||
{{ e.attendee_name }}
|
||||
@@ -119,6 +119,10 @@
|
||||
<span class="label label-danger">{% trans "Not checked in" %}</span>
|
||||
{% else %}
|
||||
<span class="label label-success">{% trans "Checked in" %}</span>
|
||||
{% if e.auto_checked_in %}
|
||||
<span class="fa fa-magic text-muted"
|
||||
data-toggle="tooltip" title="{% trans "Checked in automatically" %}"></span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
{% bootstrap_field form.subevent layout="control" %}
|
||||
{% endif %}
|
||||
{% bootstrap_field form.include_pending layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
<legend>{% trans "Products" %}</legend>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
@@ -33,6 +36,10 @@
|
||||
{% bootstrap_field form.all_products layout="control" %}
|
||||
{% bootstrap_field form.limit_products layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Advanced" %}</legend>
|
||||
{% bootstrap_field form.auto_checkin_sales_channels layout="control" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
{% if request.event.has_subevents %}
|
||||
<th>{% trans "Date" context "subevent" %}</th>
|
||||
{% endif %}
|
||||
<th class="iconcol">{% trans "Automated check-in" %}</th>
|
||||
<th>{% trans "Products" %}</th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
@@ -84,6 +85,12 @@
|
||||
{% if request.event.has_subevents %}
|
||||
<td>{{ cl.subevent.name }} – {{ cl.subevent.get_date_range_display }}</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
{% for channel in cl.auto_checkin_sales_channels %}
|
||||
<span class="fa fa-{{ channel.icon }} text-muted"
|
||||
data-toggle="tooltip" title="{% trans channel.verbose_name %}"></span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if cl.all_products %}
|
||||
<em>{% trans "All" %}</em>
|
||||
|
||||
@@ -20,9 +20,13 @@
|
||||
</a>
|
||||
</div>
|
||||
{% for w in upcoming %}
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }}">
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }} {% if w.lazy %}widget-lazy-loading{% endif %}" data-lazy-id="{{ w.lazy }}">
|
||||
<div class="widget">
|
||||
{{ w.content|safe }}
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -38,9 +42,13 @@
|
||||
<h2>{% trans "Your most recent events" %}</h2>
|
||||
<div class="dashboard">
|
||||
{% for w in past %}
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }}">
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }} {% if w.lazy %}widget-lazy-loading{% endif %}" data-lazy-id="{{ w.lazy }}">
|
||||
<div class="widget">
|
||||
{{ w.content|safe }}
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -55,9 +63,13 @@
|
||||
<h2>{% trans "Your event series" %}</h2>
|
||||
<div class="dashboard">
|
||||
{% for w in series %}
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }}">
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }} {% if w.lazy %}widget-lazy-loading{% endif %}" data-lazy-id="{{ w.lazy }}">
|
||||
<div class="widget">
|
||||
{{ w.content|safe }}
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -72,14 +84,22 @@
|
||||
<h2>{% trans "Other features" %}</h2>
|
||||
<div class="dashboard">
|
||||
{% for w in widgets %}
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }}">
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }} {% if w.lazy %}widget-lazy-loading{% endif %}" data-lazy-id="{{ w.lazy }}">
|
||||
{% if w.url %}
|
||||
<a href="{{ w.url }}" class="widget">
|
||||
{{ w.content|safe }}
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="widget">
|
||||
{{ w.content|safe }}
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -6,31 +6,33 @@
|
||||
<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="tabbed-form">
|
||||
<fieldset>
|
||||
<legend>{% trans "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 "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>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load hierarkey_form %}
|
||||
{% block custom_header %}
|
||||
{{ block.super }}
|
||||
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
|
||||
{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Display settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Event page" %}</legend>
|
||||
{% bootstrap_field form.logo_image layout="control" %}
|
||||
{% bootstrap_field form.frontpage_text layout="control" %}
|
||||
{% bootstrap_field form.presale_has_ended_text layout="control" %}
|
||||
{% bootstrap_field form.voucher_explanation_text layout="control" %}
|
||||
{% bootstrap_field form.show_variations_expanded layout="control" %}
|
||||
{% bootstrap_field form.hide_sold_out layout="control" %}
|
||||
{% bootstrap_field form.meta_noindex layout="control" %}
|
||||
{% if form.frontpage_subevent_ordering %}
|
||||
{% bootstrap_field form.frontpage_subevent_ordering layout="control" %}
|
||||
{% endif %}
|
||||
{% bootstrap_field form.redirect_to_checkout_directly layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Shop design" %}</legend>
|
||||
{% url "control:organizer.display" organizer=request.organizer.slug as org_url %}
|
||||
{% propagated request.event org_url "primary_color" "primary_font" "theme_color_success" "theme_color_danger" %}
|
||||
{% bootstrap_field form.primary_color layout="control" %}
|
||||
{% bootstrap_field form.theme_color_success layout="control" %}
|
||||
{% bootstrap_field form.theme_color_danger layout="control" %}
|
||||
{% bootstrap_field form.primary_font layout="control" %}
|
||||
{% endpropagated %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -95,18 +95,30 @@
|
||||
{% endif %}
|
||||
<div class="dashboard">
|
||||
{% for w in widgets %}
|
||||
<div class="widget-container widget-{{ w.display_size|default:"small" }}">
|
||||
<div class="widget-container widget-{{ w.display_size|default:"small" }} {% if w.lazy %}widget-lazy-loading{% endif %}" data-lazy-id="{{ w.lazy }}">
|
||||
{% if w.url %}{# backwards compatibility #}
|
||||
<a href="{{ w.url }}" class="widget">
|
||||
{{ w.content|safe }}
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% elif w.link %}
|
||||
<a href="{{ w.link }}" class="widget">
|
||||
{{ w.content|safe }}
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x´"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="widget">
|
||||
{{ w.content|safe }}
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -4,48 +4,51 @@
|
||||
{% block inside %}
|
||||
<h1>{% trans "Invoice settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "General settings" %}</legend>
|
||||
{% bootstrap_field form.invoice_generate layout="control" %}
|
||||
{% bootstrap_field form.invoice_generate_sales_channels layout="control" %}
|
||||
{% bootstrap_field form.invoice_email_attachment layout="control" %}
|
||||
{% bootstrap_field form.invoice_numbers_prefix layout="control" %}
|
||||
{% bootstrap_field form.invoice_numbers_consecutive layout="control" %}
|
||||
{% bootstrap_field form.invoice_language layout="control" %}
|
||||
{% bootstrap_field form.invoice_include_free layout="control" %}
|
||||
{% bootstrap_field form.invoice_attendee_name layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Invoice address form" %}</legend>
|
||||
{% bootstrap_field form.invoice_address_asked layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_required layout="control" %}
|
||||
{% 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" %}
|
||||
{% bootstrap_field form.invoice_address_not_asked_free layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Your invoice details" %}</legend>
|
||||
{% bootstrap_field form.invoice_address_from_name layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_zipcode layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_city layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_country layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_tax_id layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_vat_id layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Invoice customization" %}</legend>
|
||||
{% bootstrap_field form.invoice_renderer layout="control" %}
|
||||
{% bootstrap_field form.invoice_introductory_text layout="control" %}
|
||||
{% bootstrap_field form.invoice_additional_text layout="control" %}
|
||||
{% bootstrap_field form.invoice_footer_text layout="control" %}
|
||||
{% bootstrap_field form.invoice_logo_image layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_explanation_text layout="control" %}
|
||||
</fieldset>
|
||||
<div class="tabbed-form">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Invoice generation" %}</legend>
|
||||
{% bootstrap_field form.invoice_generate layout="control" %}
|
||||
{% bootstrap_field form.invoice_generate_sales_channels layout="control" %}
|
||||
{% bootstrap_field form.invoice_email_attachment layout="control" %}
|
||||
{% bootstrap_field form.invoice_language layout="control" %}
|
||||
{% bootstrap_field form.invoice_include_free layout="control" %}
|
||||
{% bootstrap_field form.invoice_numbers_consecutive layout="control" %}
|
||||
{% bootstrap_field form.invoice_numbers_prefix layout="control" %}
|
||||
{% bootstrap_field form.invoice_numbers_prefix_cancellations layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Address form" %}</legend>
|
||||
{% bootstrap_field form.invoice_address_asked layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_required layout="control" %}
|
||||
{% 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" %}
|
||||
{% bootstrap_field form.invoice_address_not_asked_free layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Issuer details" %}</legend>
|
||||
{% bootstrap_field form.invoice_address_from_name layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_zipcode layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_city layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_country layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_tax_id layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_vat_id layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Invoice customization" %}</legend>
|
||||
{% bootstrap_field form.invoice_renderer layout="control" %}
|
||||
{% bootstrap_field form.invoice_attendee_name layout="control" %}
|
||||
{% bootstrap_field form.invoice_introductory_text layout="control" %}
|
||||
{% bootstrap_field form.invoice_additional_text layout="control" %}
|
||||
{% bootstrap_field form.invoice_footer_text layout="control" %}
|
||||
{% bootstrap_field form.invoice_logo_image layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_explanation_text layout="control" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-default btn-lg" name="preview" value="preview" formtarget="_blank">
|
||||
{% trans "Save and show preview" %}
|
||||
|
||||
@@ -5,85 +5,87 @@
|
||||
{% block inside %}
|
||||
<h1>{% trans "E-mail settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data"
|
||||
mail-preview-url="{% url "control:event.settings.mail.preview" event=request.event.slug organizer=request.event.organizer.slug %}">
|
||||
mail-preview-url="{% url "control:event.settings.mail.preview" event=request.event.slug organizer=request.event.organizer.slug %}">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "General settings" %}</legend>
|
||||
{% bootstrap_field form.mail_prefix layout="control" %}
|
||||
{% bootstrap_field form.mail_from layout="control" %}
|
||||
{% bootstrap_field form.mail_from_name layout="control" %}
|
||||
{% bootstrap_field form.mail_text_signature layout="control" %}
|
||||
{% bootstrap_field form.mail_bcc layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "E-mail design" %}</legend>
|
||||
<div class="row">
|
||||
{% for r in renderers.values %}
|
||||
<div class="col-md-3">
|
||||
<div class="well maildesignpreview text-center">
|
||||
<label class="radio">
|
||||
<input type="radio" name="mail_html_renderer" value="{{ r.identifier }}"
|
||||
{% if request.event.settings.mail_html_renderer == r.identifier %}checked{% endif %}>
|
||||
{{ r.verbose_name }}
|
||||
</label>
|
||||
<img src="{% static r.thumbnail_filename %}">
|
||||
<a class="btn btn-default btn-sm" target="_blank"
|
||||
href="{% url "control:event.settings.mail.preview.layout" event=request.event.slug organizer=request.event.organizer.slug %}?renderer={{ r.identifier }}">
|
||||
{% trans "Preview" %}
|
||||
</a>
|
||||
<div class="tabbed-form">
|
||||
<fieldset>
|
||||
<legend>{% trans "General" %}</legend>
|
||||
{% bootstrap_field form.mail_prefix layout="control" %}
|
||||
{% bootstrap_field form.mail_from layout="control" %}
|
||||
{% bootstrap_field form.mail_from_name layout="control" %}
|
||||
{% bootstrap_field form.mail_text_signature layout="control" %}
|
||||
{% bootstrap_field form.mail_bcc layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "E-mail design" %}</legend>
|
||||
<div class="row">
|
||||
{% for r in renderers.values %}
|
||||
<div class="col-md-3">
|
||||
<div class="well maildesignpreview text-center">
|
||||
<label class="radio">
|
||||
<input type="radio" name="mail_html_renderer" value="{{ r.identifier }}"
|
||||
{% if request.event.settings.mail_html_renderer == r.identifier %}checked{% endif %}>
|
||||
{{ r.verbose_name }}
|
||||
</label>
|
||||
<img src="{% static r.thumbnail_filename %}">
|
||||
<a class="btn btn-default btn-sm" target="_blank"
|
||||
href="{% url "control:event.settings.mail.preview.layout" event=request.event.slug organizer=request.event.organizer.slug %}?renderer={{ r.identifier }}">
|
||||
{% trans "Preview" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "E-mail content" %}</legend>
|
||||
<div class="panel-group" id="questions_group">
|
||||
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed,mail_send_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "E-mail content" %}</legend>
|
||||
<div class="panel-group" id="questions_group">
|
||||
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed,mail_send_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %}
|
||||
|
||||
{% blocktrans asvar title_paid_order %}Paid order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_paid" title=title_paid_order items="mail_text_order_paid,mail_send_order_paid_attendee,mail_text_order_paid_attendee" exclude="mail_send_order_paid_attendee" %}
|
||||
{% blocktrans asvar title_paid_order %}Paid order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_paid" title=title_paid_order items="mail_text_order_paid,mail_send_order_paid_attendee,mail_text_order_paid_attendee" exclude="mail_send_order_paid_attendee" %}
|
||||
|
||||
{% blocktrans asvar title_free_order %}Free order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_free" title=title_free_order items="mail_text_order_free,mail_send_order_free_attendee,mail_text_order_free_attendee" exclude="mail_send_order_free_attendee" %}
|
||||
{% blocktrans asvar title_free_order %}Free order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_free" title=title_free_order items="mail_text_order_free,mail_send_order_free_attendee,mail_text_order_free_attendee" exclude="mail_send_order_free_attendee" %}
|
||||
|
||||
{% blocktrans asvar title_resend_link %}Resend link{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="resend_link" title=title_resend_link items="mail_text_resend_link,mail_text_resend_all_links" %}
|
||||
{% blocktrans asvar title_resend_link %}Resend link{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="resend_link" title=title_resend_link items="mail_text_resend_link,mail_text_resend_all_links" %}
|
||||
|
||||
{% blocktrans asvar title_order_changed %}Order changed{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_changed" title=title_order_changed items="mail_text_order_changed" %}
|
||||
{% blocktrans asvar title_order_changed %}Order changed{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_changed" title=title_order_changed items="mail_text_order_changed" %}
|
||||
|
||||
{% blocktrans asvar title_payment_reminder %}Payment reminder{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_text_order_expire_warning" exclude="mail_days_order_expire_warning" %}
|
||||
{% blocktrans asvar title_payment_reminder %}Payment reminder{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_text_order_expire_warning" exclude="mail_days_order_expire_warning" %}
|
||||
|
||||
{% blocktrans asvar title_waiting_list_notification %}Waiting list notification{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="waiting_list" title=title_waiting_list_notification items="mail_text_waiting_list" %}
|
||||
{% blocktrans asvar title_waiting_list_notification %}Waiting list notification{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="waiting_list" title=title_waiting_list_notification items="mail_text_waiting_list" %}
|
||||
|
||||
{% blocktrans asvar title_order_canceled %}Order canceled{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_canceled" title=title_order_canceled items="mail_text_order_canceled" %}
|
||||
{% blocktrans asvar title_order_canceled %}Order canceled{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_canceled" title=title_order_canceled items="mail_text_order_canceled" %}
|
||||
|
||||
{% blocktrans asvar title_order_custom_mail %}Order custom mail{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="custom_mail" title=title_order_custom_mail items="mail_text_order_custom_mail" %}
|
||||
{% blocktrans asvar title_order_custom_mail %}Order custom mail{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="custom_mail" title=title_order_custom_mail items="mail_text_order_custom_mail" %}
|
||||
|
||||
{% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_text_download_reminder_attendee" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee" %}
|
||||
{% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_text_download_reminder_attendee" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee" %}
|
||||
|
||||
{% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_denied" %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "SMTP settings" %}</legend>
|
||||
{% bootstrap_field form.smtp_use_custom layout="control" %}
|
||||
{% bootstrap_field form.smtp_host layout="control" %}
|
||||
{% bootstrap_field form.smtp_port layout="control" %}
|
||||
{% bootstrap_field form.smtp_username layout="control" %}
|
||||
{% bootstrap_field form.smtp_password layout="control" %}
|
||||
{% bootstrap_field form.smtp_use_tls layout="control" %}
|
||||
{% bootstrap_field form.smtp_use_ssl layout="control" %}
|
||||
</fieldset>
|
||||
{% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_denied" %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "SMTP settings" %}</legend>
|
||||
{% bootstrap_field form.smtp_use_custom layout="control" %}
|
||||
{% bootstrap_field form.smtp_host layout="control" %}
|
||||
{% bootstrap_field form.smtp_port layout="control" %}
|
||||
{% bootstrap_field form.smtp_username layout="control" %}
|
||||
{% bootstrap_field form.smtp_password layout="control" %}
|
||||
{% bootstrap_field form.smtp_use_tls layout="control" %}
|
||||
{% bootstrap_field form.smtp_use_ssl layout="control" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
|
||||
@@ -5,60 +5,67 @@
|
||||
<h1>{% trans "Payment settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal form-plugins">
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Payment providers" %}</legend>
|
||||
<table class="table table-payment-providers">
|
||||
<tbody>
|
||||
{% for provider in providers %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ provider.verbose_name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if provider.show_enabled %}
|
||||
<span class="text-success">
|
||||
<div class="tabbed-form">
|
||||
<fieldset>
|
||||
<legend>{% trans "Payment providers" %}</legend>
|
||||
<table class="table table-payment-providers">
|
||||
<tbody>
|
||||
{% for provider in providers %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ provider.verbose_name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
{% if provider.show_enabled %}
|
||||
<span class="text-success">
|
||||
<span class="fa fa-check"></span>
|
||||
{% trans "Enabled" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-danger">
|
||||
{% else %}
|
||||
<span class="text-danger">
|
||||
<span class="fa fa-times"></span>
|
||||
{% trans "Disabled" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url 'control:event.settings.payment.provider' event=request.event.slug organizer=request.organizer.slug provider=provider.identifier %}"
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-cog"></span>
|
||||
{% trans "Settings" %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
|
||||
{% blocktrans trimmed with plugin_settings_href='href="'|add:plugin_settings_url|add:'"'|safe %}
|
||||
There are no payment providers available. Please go to the <a {{ plugin_settings_href }}>plugin settings</a> and activate one or more payment plugins.
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "General payment settings" %}</legend>
|
||||
{% bootstrap_form_errors form layout="control" %}
|
||||
{% bootstrap_field form.payment_term_days layout="control" %}
|
||||
{% bootstrap_field form.payment_term_last layout="control" %}
|
||||
{% bootstrap_field form.payment_term_weekdays layout="control" %}
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url 'control:event.settings.payment.provider' event=request.event.slug organizer=request.organizer.slug provider=provider.identifier %}"
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-cog"></span>
|
||||
{% trans "Settings" %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
|
||||
{% blocktrans trimmed with plugin_settings_href='href="'|add:plugin_settings_url|add:'"'|safe %}
|
||||
There are no payment providers available. Please go to the
|
||||
<a {{ plugin_settings_href }}>plugin settings</a> and activate one or more payment plugins.
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Deadlines" %}</legend>
|
||||
{% bootstrap_form_errors form layout="control" %}
|
||||
{% bootstrap_field form.payment_term_days layout="control" %}
|
||||
{% bootstrap_field form.payment_term_last layout="control" %}
|
||||
{% bootstrap_field form.payment_term_weekdays layout="control" %}
|
||||
{% bootstrap_field form.payment_term_expire_automatically layout="control" %}
|
||||
{% bootstrap_field form.payment_term_accept_late layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Advanced" %}</legend>
|
||||
{% bootstrap_form_errors form layout="control" %}
|
||||
{% bootstrap_field form.tax_rate_default layout="control" %}
|
||||
{% bootstrap_field form.payment_explanation layout="control" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
|
||||
@@ -3,77 +3,69 @@
|
||||
{% load bootstrap3 %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Installed plugins" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal form-plugins">
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
{% if "success" in request.GET %}
|
||||
<div class="alert alert-success">
|
||||
{% trans "Your changes have been saved." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row row-plugins">
|
||||
{% for plugin in plugins %}
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="panel panel-{% if plugin.app.compatibility_errors %}warning{% elif plugin.module in plugins_active %}success{% else %}default{% endif %}">
|
||||
<div class="panel-heading">
|
||||
<div class="row">
|
||||
<div class="col-sm-8">
|
||||
<h3 class="panel-title">{{ plugin.name }}</h3>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
{% if plugin.app.compatibility_errors %}
|
||||
<button class="btn disabled btn-block btn-default" disabled="disabled">{% trans "Incompatible" %}</button>
|
||||
{% elif plugin.restricted and not staff_session %}
|
||||
<button class="btn disabled btn-block btn-default" disabled="disabled">{% trans "Not available" %}</button>
|
||||
{% elif plugin.module in plugins_active %}
|
||||
<button class="btn btn-default btn-block" name="plugin:{{ plugin.module }}" value="disable">{% trans "Disable" %}</button>
|
||||
{% else %}
|
||||
<button class="btn btn-default btn-block" name="plugin:{{ plugin.module }}" value="enable">{% trans "Enable" %}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if plugin.author %}
|
||||
<p class="meta">{% blocktrans trimmed with v=plugin.version a=plugin.author %}
|
||||
Version {{ v }} by <em>{{ a }}</em>
|
||||
{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<p class="meta">{% blocktrans trimmed with v=plugin.version a=plugin.author %}
|
||||
Version {{ v }}
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p>{{ plugin.description }}</p>
|
||||
{% if plugin.restricted and not request.user.is_staff %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "This plugin needs to be enabled by a system administrator for your event." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plugin.app.compatibility_errors %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "This plugin cannot be enabled for the following reasons:" %}
|
||||
<ul>
|
||||
{% for e in plugin.app.compatibility_errors %}
|
||||
<li>{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plugin.app.compatibility_warnings %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "This plugin reports the following problems:" %}
|
||||
<ul>
|
||||
{% for e in plugin.app.compatibility_warnings %}
|
||||
<li>{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<form action="" method="post" class="form-horizontal form-plugins">
|
||||
{% csrf_token %}
|
||||
{% if "success" in request.GET %}
|
||||
<div class="alert alert-success">
|
||||
{% trans "Your changes have been saved." %}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
{% for plugin in plugins %}
|
||||
<tr class="{% if plugin.app.compatibility_errors %}warning{% elif plugin.module in plugins_active %}success{% else %}default{% endif %}">
|
||||
<td>
|
||||
<strong>{{ plugin.name }}</strong>
|
||||
{% if plugin.author %}
|
||||
<p class="meta text-muted">{% blocktrans trimmed with v=plugin.version a=plugin.author %}
|
||||
Version {{ v }} by <em>{{ a }}</em>
|
||||
{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<p class="meta text-muted">{% blocktrans trimmed with v=plugin.version a=plugin.author %}
|
||||
Version {{ v }}
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p>{{ plugin.description }}</p>
|
||||
{% if plugin.restricted and not request.user.is_staff %}
|
||||
<span class="text-muted">
|
||||
{% trans "This plugin needs to be enabled by a system administrator for your event." %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if plugin.app.compatibility_errors %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "This plugin cannot be enabled for the following reasons:" %}
|
||||
<ul>
|
||||
{% for e in plugin.app.compatibility_errors %}
|
||||
<li>{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plugin.app.compatibility_warnings %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "This plugin reports the following problems:" %}
|
||||
<ul>
|
||||
{% for e in plugin.app.compatibility_warnings %}
|
||||
<li>{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if plugin.app.compatibility_errors %}
|
||||
<button class="btn disabled btn-block btn-default" disabled="disabled">{% trans "Incompatible" %}</button>
|
||||
{% elif plugin.restricted and not staff_session %}
|
||||
<button class="btn disabled btn-block btn-default" disabled="disabled">{% trans "Not available" %}</button>
|
||||
{% elif plugin.module in plugins_active %}
|
||||
<button class="btn btn-default btn-block" name="plugin:{{ plugin.module }}" value="disable">{% trans "Disable" %}</button>
|
||||
{% else %}
|
||||
<button class="btn btn-default btn-block" name="plugin:{{ plugin.module }}" value="enable">{% trans "Enable" %}</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user