mirror of
https://github.com/pretix/pretix.git
synced 2025-12-07 22:42:26 +00:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33382681f3 | ||
|
|
340f7aaba4 | ||
|
|
f96e525cb5 | ||
|
|
a87fe32a77 | ||
|
|
55feddc239 | ||
|
|
28e146f2ad | ||
|
|
e7d6265b22 | ||
|
|
2603d321bb | ||
|
|
88f35cb361 | ||
|
|
aed6515971 | ||
|
|
a0be1b8389 | ||
|
|
17eb0a3817 | ||
|
|
a87cb75ed5 | ||
|
|
f70eb95346 | ||
|
|
9abdc6a3cc | ||
|
|
5f607cc034 | ||
|
|
3b9f508be9 | ||
|
|
89e381b7ea | ||
|
|
57869b2145 | ||
|
|
46976900d7 | ||
|
|
a1535da117 | ||
|
|
f43d782b5c | ||
|
|
5c443e2f93 | ||
|
|
54f01f63f7 | ||
|
|
c64b4473e4 | ||
|
|
d413a37c1f | ||
|
|
202fb12008 | ||
|
|
59dea63870 | ||
|
|
9a18f2b553 | ||
|
|
4293ec3805 | ||
|
|
f3b616e495 | ||
|
|
003ea24990 | ||
|
|
92d4566a54 | ||
|
|
70a933edc1 | ||
|
|
d2d77f28aa | ||
|
|
39179971c5 | ||
|
|
c06f36e8c2 | ||
|
|
1d2d9d8b99 | ||
|
|
5f529817ef | ||
|
|
7c91bc2f37 | ||
|
|
ef022f5a6d | ||
|
|
54ce00c8b9 | ||
|
|
baabbfb1ea | ||
|
|
28e676ac9a | ||
|
|
21fac9ec7a | ||
|
|
335955820b | ||
|
|
d2b0e7209f | ||
|
|
f8ed21c819 | ||
|
|
9c2143effe | ||
|
|
8a3fa6aff6 | ||
|
|
7e304bb231 | ||
|
|
99d614289e | ||
|
|
b90894c20f | ||
|
|
921834c917 | ||
|
|
83df4451e6 | ||
|
|
2ad9e1bb43 | ||
|
|
c9990e5ca4 | ||
|
|
c95e61db09 | ||
|
|
7bb12ff0ec | ||
|
|
670bfa18de | ||
|
|
130f619b05 | ||
|
|
f900c842cb | ||
|
|
c2844a8f35 | ||
|
|
a864dabbaf | ||
|
|
02786f4801 | ||
|
|
5a4fe266c6 | ||
|
|
b30a4db0b8 | ||
|
|
2e76e07764 | ||
|
|
1be92f5078 | ||
|
|
8afff29cd4 | ||
|
|
9c6090a355 | ||
|
|
27b73227ed | ||
|
|
9582f8380f | ||
|
|
bfeac7e70b | ||
|
|
2050c0b7a8 | ||
|
|
144f9bed69 | ||
|
|
f2b642d944 | ||
|
|
bcdc75953e | ||
|
|
1fabe5a7cf | ||
|
|
fe9a4b7aa3 | ||
|
|
56735dc1c6 | ||
|
|
42287b92f1 | ||
|
|
7d9e642f24 | ||
|
|
b20e10585f | ||
|
|
8438b211a6 | ||
|
|
f94314afec | ||
|
|
4584d23434 | ||
|
|
2791501781 | ||
|
|
6ea798e55b | ||
|
|
0ab6ac569e | ||
|
|
f91d7352a4 | ||
|
|
d644b8fe01 | ||
|
|
5a8042cc10 | ||
|
|
ae910eb731 | ||
|
|
c1158c3175 | ||
|
|
79562e7ad9 | ||
|
|
e3388bea96 | ||
|
|
a3b4a7ef1d | ||
|
|
947b06cd61 | ||
|
|
a3f3561f02 | ||
|
|
48095d38be | ||
|
|
1c6858653a | ||
|
|
648797325e | ||
|
|
af3fa88d67 | ||
|
|
8123effa65 | ||
|
|
554800c06f | ||
|
|
687ce29366 | ||
|
|
c70301572c | ||
|
|
714f58e2c5 | ||
|
|
9bf9dca88a | ||
|
|
0663f25208 | ||
|
|
275d162b81 | ||
|
|
675956a7c4 | ||
|
|
cf1602c0af | ||
|
|
35979ed332 | ||
|
|
6e65ae5306 | ||
|
|
95e716b8ce | ||
|
|
554284ac67 | ||
|
|
fa6102bbad | ||
|
|
f28d5f19a7 | ||
|
|
34d59c7741 | ||
|
|
21d432a3ca | ||
|
|
d444935140 | ||
|
|
9de9d96e35 | ||
|
|
5932558ca2 | ||
|
|
7b22adb72e | ||
|
|
0db5d062be | ||
|
|
678d510e29 | ||
|
|
1fc3307d22 | ||
|
|
45c17ba949 | ||
|
|
15bd1d9006 | ||
|
|
b4715f0931 | ||
|
|
6f24a2a88c | ||
|
|
fcbecf895a |
@@ -10,9 +10,10 @@ at the following locations. It will try to read the file from the specified path
|
||||
the following order. The file that is found *last* will override the settings from
|
||||
the files found before.
|
||||
|
||||
1. ``/etc/pretix/pretix.cfg``
|
||||
2. ``~/.pretix.cfg``
|
||||
3. ``pretix.cfg`` in the current working directory
|
||||
1. ``PREFIX_CONFIG_FILE`` environment variable
|
||||
2. ``/etc/pretix/pretix.cfg``
|
||||
3. ``~/.pretix.cfg``
|
||||
4. ``pretix.cfg`` in the current working directory
|
||||
|
||||
The file is expected to be in the INI format as specified in the `Python documentation`_.
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ is_public boolean If ``true``, th
|
||||
presale_start datetime The date at which the ticket shop opens (or ``null``)
|
||||
presale_end datetime The date at which the ticket shop closes (or ``null``)
|
||||
location multi-lingual string The event location (or ``null``)
|
||||
has_subevents boolean ``True`` if the event series feature is active for this
|
||||
event
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -67,6 +69,7 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -109,6 +112,7 @@ Endpoints
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"has_subevents": false
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
|
||||
@@ -6,6 +6,7 @@ Resources and endpoints
|
||||
|
||||
organizers
|
||||
events
|
||||
subevents
|
||||
categories
|
||||
items
|
||||
questions
|
||||
|
||||
@@ -11,7 +11,7 @@ The invoice resource contains the following public fields:
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
invoice_no string Invoice number (without prefix)
|
||||
number string Invoice number (with prefix)
|
||||
order string Order code of the order this invoice belongs to
|
||||
is_cancellation boolean ``True``, if this invoice is the cancellation of a
|
||||
different invoice.
|
||||
@@ -35,6 +35,13 @@ lines list of objects The actual invo
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 1.6
|
||||
|
||||
The attribute ``invoice_no`` has been dropped in favor of ``number`` which includes the number including the prefix,
|
||||
since the prefix can now vary. Also, invoices now need to be identified by their ``number`` instead of the raw
|
||||
number.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -64,7 +71,7 @@ Endpoints
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"invoice_no": "00001",
|
||||
"number": "SAMPLECONF-00001",
|
||||
"order": "ABC12",
|
||||
"is_cancellation": false,
|
||||
"invoice_from": "Big Events LLC\nDemo street 12\nDemo town",
|
||||
@@ -95,14 +102,14 @@ Endpoints
|
||||
:query string refers: If set, only invoices refering to the given invoice will be returned.
|
||||
:query string locale: If set, only invoices with the given locale will be returned.
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date`` and
|
||||
``invoice_no``. Default: ``invoice_no``
|
||||
``nr`` (equals to ``number``). Default: ``nr``
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/
|
||||
|
||||
Returns information on one invoice, identified by its invoice number.
|
||||
|
||||
@@ -110,7 +117,7 @@ Endpoints
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/ HTTP/1.1
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/SAMPLECONF-00001/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
@@ -123,7 +130,7 @@ Endpoints
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"invoice_no": "00001",
|
||||
"number": "SAMPLECONF-00001",
|
||||
"order": "ABC12",
|
||||
"is_cancellation": false,
|
||||
"invoice_from": "Big Events LLC\nDemo street 12\nDemo town",
|
||||
|
||||
@@ -50,6 +50,13 @@ downloads list of objects List of ticket
|
||||
└ url string Download URL
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 1.6
|
||||
|
||||
The ``invoice_address.country`` attribute contains a two-letter country code for all new orders. For old orders,
|
||||
a custom text might still be returned.
|
||||
|
||||
|
||||
Order position resource
|
||||
-----------------------
|
||||
|
||||
@@ -71,6 +78,7 @@ tax_rate decimal (string) VAT rate applie
|
||||
tax_value money (string) VAT included in this position
|
||||
secret string Secret code printed on the tickets for validation
|
||||
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
|
||||
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
|
||||
checkins list of objects List of check-ins with this ticket
|
||||
└ datetime datetime Time of check-in
|
||||
downloads list of objects List of ticket download options
|
||||
@@ -151,6 +159,7 @@ Order endpoints
|
||||
"tax_value": "0.00",
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"checkins": [
|
||||
{
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
@@ -254,6 +263,7 @@ Order endpoints
|
||||
"tax_value": "0.00",
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"checkins": [
|
||||
{
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
@@ -372,6 +382,7 @@ Order position endpoints
|
||||
"tax_value": "0.00",
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"checkins": [
|
||||
{
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
@@ -407,6 +418,7 @@ Order position endpoints
|
||||
:query string order__status: Only return positions with the given order status.
|
||||
:query bollean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
|
||||
checked in already.
|
||||
:query integer subevent: Only return positions of the sub-event with the given ID
|
||||
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
@@ -448,6 +460,7 @@ Order position endpoints
|
||||
"tax_value": "0.00",
|
||||
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
"addon_to": null,
|
||||
"subevent": null,
|
||||
"checkins": [
|
||||
{
|
||||
"datetime": "2017-12-25T12:45:23Z"
|
||||
|
||||
@@ -22,6 +22,7 @@ type string The expected ty
|
||||
* ``B`` – boolean
|
||||
* ``C`` – choice from a list
|
||||
* ``M`` – multiple choice from a list
|
||||
* ``F`` – file upload
|
||||
required boolean If ``True``, the question needs to be filled out.
|
||||
position integer An integer, used for sorting
|
||||
items list of integers List of item IDs this question is assigned to.
|
||||
|
||||
@@ -17,6 +17,7 @@ name string The internal na
|
||||
size integer The size of the quota or ``null`` for unlimited
|
||||
items list of integers List of item IDs this quota acts on.
|
||||
variations list of integers List of item variation IDs this quota acts on.
|
||||
subevent integer ID of the date inside an event series this quota belongs to (or ``null``).
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -53,7 +54,8 @@ Endpoints
|
||||
"name": "Ticket Quota",
|
||||
"size": 200,
|
||||
"items": [1, 2],
|
||||
"variations": [1, 4, 5, 7]
|
||||
"variations": [1, 4, 5, 7],
|
||||
"subevent": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -61,6 +63,7 @@ Endpoints
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``.
|
||||
Default: ``position``
|
||||
:query integer subevent: Only return quotas of the sub-event with the given ID
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
@@ -92,7 +95,8 @@ Endpoints
|
||||
"name": "Ticket Quota",
|
||||
"size": 200,
|
||||
"items": [1, 2],
|
||||
"variations": [1, 4, 5, 7]
|
||||
"variations": [1, 4, 5, 7],
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
|
||||
137
doc/api/resources/subevents.rst
Normal file
137
doc/api/resources/subevents.rst
Normal file
@@ -0,0 +1,137 @@
|
||||
Event series dates / Sub-events
|
||||
===============================
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
Events can represent whole event series if the ``has_subevents`` property of the event is active.
|
||||
In this case, many other resources are additionally connected to an event date (also called sub-event).
|
||||
The sub-event resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the sub-event
|
||||
name multi-lingual string The sub-event's full name
|
||||
active boolean If ``true``, the sub-event ticket shop is publicly
|
||||
available.
|
||||
date_from datetime The sub-event's start date
|
||||
date_to datetime The sub-event's end date (or ``null``)
|
||||
date_admission datetime The sub-event's admission date (or ``null``)
|
||||
presale_start datetime The sub-date at which the ticket shop opens (or ``null``)
|
||||
presale_end datetime The sub-date at which the ticket shop closes (or ``null``)
|
||||
location multi-lingual string The sub-event location (or ``null``)
|
||||
item_price_overrides list of objects List of items for which this sub-event overrides the
|
||||
default price
|
||||
├ item integer The internal item ID
|
||||
└ price money (string) The price or ``null`` for the default price
|
||||
variation_price_overrides list of objects List of variations for which this sub-event overrides
|
||||
the default price
|
||||
├ variation integer The internal variation ID
|
||||
└ price money (string) The price or ``null`` for the default price
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/
|
||||
|
||||
Returns a list of all sub-events of an event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/subevents/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"active": false,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
"variation_price_overrides": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/
|
||||
|
||||
Returns information on one sub-event, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"active": false,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
"variation_price_overrides": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param id: The ``slug`` field of the sub-event to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
|
||||
@@ -40,6 +40,7 @@ quota integer An ID of a quot
|
||||
for all items without restriction.
|
||||
tag string A string that is used for grouping vouchers
|
||||
comment string An internal comment on the voucher
|
||||
subevent integer ID of the date inside an event series this voucher belongs to (or ``null``).
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -85,7 +86,8 @@ Endpoints
|
||||
"variation": null,
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": ""
|
||||
"comment": "",
|
||||
"subevent": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -107,6 +109,7 @@ Endpoints
|
||||
:query integer variation: If set, only vouchers attached to the variation with the given ID will be shown.
|
||||
:query integer quota: If set, only vouchers attached to the quota with the given ID will be shown.
|
||||
:query string tag: If set, only vouchers with the given tag will be shown.
|
||||
:query integer subevent: Only return vouchers of the sub-event with the given ID
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``code``,
|
||||
``max_usages``, ``valid_until``, and ``value``. Default: ``id``
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -149,7 +152,8 @@ Endpoints
|
||||
"variation": null,
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": ""
|
||||
"comment": "",
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
|
||||
@@ -23,6 +23,7 @@ item integer An ID of an ite
|
||||
variation integer An ID of a variation the user is waiting to be
|
||||
available again (or ``null``)
|
||||
locale string Locale of the waiting user
|
||||
subevent integer ID of the date inside an event series this entry belongs to (or ``null``).
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -61,7 +62,8 @@ Endpoints
|
||||
"voucher": null,
|
||||
"item": 2,
|
||||
"variation": null,
|
||||
"locale": "en"
|
||||
"locale": "en",
|
||||
"subevent": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -73,6 +75,7 @@ Endpoints
|
||||
have not been sent a voucher.
|
||||
:query integer item: If set, only entries of users waiting for the item with the given ID will be shown.
|
||||
:query integer variation: If set, only entries of users waiting for the variation with the given ID will be shown.
|
||||
:query integer subevent: Only return entries of the sub-event with the given ID
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``created``,
|
||||
``email``, ``item``. Default: ``created``
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -108,7 +111,8 @@ Endpoints
|
||||
"voucher": null,
|
||||
"item": 2,
|
||||
"variation": null,
|
||||
"locale": "en"
|
||||
"locale": "en",
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
|
||||
@@ -25,7 +25,7 @@ Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, contact_form_fields, checkout_confirm_messages
|
||||
:members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, contact_form_fields, question_form_fields, checkout_confirm_messages
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
|
||||
@@ -10,5 +10,6 @@ Contents:
|
||||
exporter
|
||||
ticketoutput
|
||||
payment
|
||||
invoice
|
||||
customview
|
||||
general
|
||||
|
||||
95
doc/development/api/invoice.rst
Normal file
95
doc/development/api/invoice.rst
Normal file
@@ -0,0 +1,95 @@
|
||||
.. highlight:: python
|
||||
:linenothreshold: 5
|
||||
|
||||
Writing an invoice renderer plugin
|
||||
==================================
|
||||
|
||||
An invoice renderer controls how invoice files are built.
|
||||
The creation of such a plugin is very similar to creating an export output.
|
||||
|
||||
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
|
||||
|
||||
Output registration
|
||||
-------------------
|
||||
|
||||
The invoice renderer API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available ticket outputs. Your plugin
|
||||
should listen for this signal and return the subclass of ``pretix.base.invoice.BaseInvoiceRenderer``
|
||||
that we'll provide in this plugin::
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import register_invoice_renderers
|
||||
|
||||
|
||||
@receiver(register_invoice_renderers, dispatch_uid="output_custom")
|
||||
def register_infoice_renderers(sender, **kwargs):
|
||||
from .invoice import MyInvoiceRenderer
|
||||
return MyInvoiceRenderer
|
||||
|
||||
|
||||
The renderer class
|
||||
------------------
|
||||
|
||||
.. class:: pretix.base.invoice.BaseInvoiceRenderer
|
||||
|
||||
The central object of each invoice renderer is the subclass of ``BaseInvoiceRenderer``.
|
||||
|
||||
.. py:attribute:: BaseInvoiceRenderer.event
|
||||
|
||||
The default constructor sets this property to the event we are currently
|
||||
working for.
|
||||
|
||||
.. autoattribute:: identifier
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: verbose_name
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. automethod:: generate
|
||||
|
||||
Helper class for reportlab-base renderers
|
||||
-----------------------------------------
|
||||
|
||||
All PDF rendering that ships with pretix is based on reportlab. We recommend to read the
|
||||
`reportlab User Guide`_ to understand all the concepts used here.
|
||||
|
||||
If you want to implement a renderer that also uses report lab, this helper class might be
|
||||
convenient to you:
|
||||
|
||||
|
||||
.. class:: pretix.base.invoice.BaseReportlabInvoiceRenderer
|
||||
|
||||
.. py:attribute:: BaseReportlabInvoiceRenderer.pagesize
|
||||
|
||||
.. py:attribute:: BaseReportlabInvoiceRenderer.left_margin
|
||||
|
||||
.. py:attribute:: BaseReportlabInvoiceRenderer.right_margin
|
||||
|
||||
.. py:attribute:: BaseReportlabInvoiceRenderer.top_margin
|
||||
|
||||
.. py:attribute:: BaseReportlabInvoiceRenderer.bottom_margin
|
||||
|
||||
.. py:attribute:: BaseReportlabInvoiceRenderer.doc_template_class
|
||||
|
||||
.. py:attribute:: BaseReportlabInvoiceRenderer.invoice
|
||||
|
||||
.. automethod:: _init
|
||||
|
||||
.. automethod:: _get_stylesheet
|
||||
|
||||
.. automethod:: _register_fonts
|
||||
|
||||
.. automethod:: _on_first_page
|
||||
|
||||
.. automethod:: _on_other_page
|
||||
|
||||
.. automethod:: _get_first_page_frames
|
||||
|
||||
.. automethod:: _get_other_page_frames
|
||||
|
||||
.. automethod:: _build_doc
|
||||
|
||||
.. _reportlab User Guide: https://www.reportlab.com/docs/reportlab-userguide.pdf
|
||||
@@ -58,6 +58,8 @@ The output class
|
||||
|
||||
.. autoattribute:: is_enabled
|
||||
|
||||
.. autoattribute:: multi_download_enabled
|
||||
|
||||
.. autoattribute:: settings_form_fields
|
||||
|
||||
.. automethod:: settings_content_render
|
||||
|
||||
@@ -2,6 +2,9 @@ Sending Email
|
||||
=============
|
||||
|
||||
pretix allows event organizers to configure how they want to send emails to their users in multiple ways.
|
||||
Therefore, all emails should be sent through the following function:
|
||||
Therefore, all emails should be sent through the following function.
|
||||
|
||||
If the email you send is related to an order, you should also take a look at the
|
||||
:py:meth:`~pretix.base.models.Order.send_mail` of the order model.
|
||||
|
||||
.. autofunction:: pretix.base.services.mail.mail
|
||||
|
||||
@@ -21,7 +21,10 @@ Organizers and events
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.Event
|
||||
:members:
|
||||
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, get_cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, active_subevents, invoice_renderer, settings
|
||||
|
||||
.. autoclass:: pretix.base.models.SubEvent
|
||||
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running
|
||||
|
||||
.. autoclass:: pretix.base.models.Team
|
||||
:members:
|
||||
@@ -42,6 +45,15 @@ Items
|
||||
.. autoclass:: pretix.base.models.ItemVariation
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.SubEventItem
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.SubEventItemVariation
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.ItemAddOn
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.Question
|
||||
:members:
|
||||
|
||||
|
||||
@@ -19,10 +19,12 @@ same team. We update them regularly to make them compatible with the latest
|
||||
pretix releases:
|
||||
|
||||
* `SEPA direct debit`_
|
||||
* `Wirecard payment`_
|
||||
* `Pages`_
|
||||
* `Passbook/Wallet ticket output`_
|
||||
* `Cartshare`_
|
||||
* `Fontpack Free fonts`_
|
||||
* `Mailing list subscription`_
|
||||
|
||||
The following closed-source plugins are available to customers of the hosted pretix.eu platform.
|
||||
Please get in touch with the pretix team if you want to have them for your self-hosted
|
||||
@@ -34,10 +36,12 @@ pretix installation:
|
||||
* Integration with MailChimp
|
||||
|
||||
The following plugins are from independent third-party authors, so we can make
|
||||
no statements about their stability or compatibility:
|
||||
no statements about their functionality, security, stability or compatibility:
|
||||
|
||||
* `esPass ticket output`_
|
||||
* `IcePay integration`_
|
||||
* `Average price chart`_
|
||||
* `Pay in cash upon arrival`_
|
||||
|
||||
.. _SEPA direct debit: https://github.com/pretix/pretix-sepadebit
|
||||
.. _Passbook/Wallet ticket output: https://github.com/pretix/pretix-passbook
|
||||
@@ -46,3 +50,7 @@ no statements about their stability or compatibility:
|
||||
.. _esPass ticket output: https://github.com/esPass/pretix-espass
|
||||
.. _IcePay integration: https://github.com/chotee/pretix-icepay
|
||||
.. _Fontpack Free fonts: https://github.com/pretix/pretix-fontpack-free
|
||||
.. _Wirecard payment: https://github.com/pretix/pretix-wirecard
|
||||
.. _Mailing list subscription: https://github.com/pretix/pretix-newsletter-ml
|
||||
.. _Average price chart: https://github.com/rixx/pretix-avgchart
|
||||
.. _Pay in cash upon arrival: https://github.com/pc-coholic/pretix-cashpayment
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.5.0"
|
||||
__version__ = "1.6.2"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
||||
|
||||
|
||||
class EventSerializer(I18nAwareModelSerializer):
|
||||
@@ -7,4 +9,27 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
model = Event
|
||||
fields = ('name', 'slug', 'live', 'currency', 'date_from',
|
||||
'date_to', 'date_admission', 'is_public', 'presale_start',
|
||||
'presale_end', 'location')
|
||||
'presale_end', 'location', 'has_subevents')
|
||||
|
||||
|
||||
class SubEventItemSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = SubEventItem
|
||||
fields = ('item', 'price')
|
||||
|
||||
|
||||
class SubEventItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = SubEventItemVariation
|
||||
fields = ('variation', 'price')
|
||||
|
||||
|
||||
class SubEventSerializer(I18nAwareModelSerializer):
|
||||
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True)
|
||||
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True)
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
|
||||
'presale_start', 'presale_end', 'location',
|
||||
'item_price_overrides', 'variation_price_overrides')
|
||||
|
||||
@@ -61,4 +61,4 @@ class QuotaSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Quota
|
||||
fields = ('id', 'name', 'size', 'items', 'variations')
|
||||
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent')
|
||||
|
||||
@@ -9,7 +9,17 @@ from pretix.base.models import (
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
|
||||
|
||||
class CompatibleCountryField(serializers.Field):
|
||||
def to_representation(self, instance: InvoiceAddress):
|
||||
if instance.country:
|
||||
return str(instance.country)
|
||||
else:
|
||||
return instance.country_old
|
||||
|
||||
|
||||
class InvoiceAdddressSerializer(I18nAwareModelSerializer):
|
||||
country = CompatibleCountryField(source='*')
|
||||
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('last_modified', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id')
|
||||
@@ -86,7 +96,8 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||
'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'checkins', 'downloads', 'answers')
|
||||
'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'downloads',
|
||||
'answers')
|
||||
|
||||
|
||||
class OrderSerializer(I18nAwareModelSerializer):
|
||||
@@ -114,5 +125,5 @@ class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Invoice
|
||||
fields = ('order', 'invoice_no', 'is_cancellation', 'invoice_from', 'invoice_to', 'date', 'refers', 'locale',
|
||||
fields = ('order', 'number', 'is_cancellation', 'invoice_from', 'invoice_to', 'date', 'refers', 'locale',
|
||||
'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines')
|
||||
|
||||
@@ -7,4 +7,4 @@ class VoucherSerializer(I18nAwareModelSerializer):
|
||||
model = Voucher
|
||||
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
|
||||
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
|
||||
'tag', 'comment')
|
||||
'tag', 'comment', 'subevent')
|
||||
|
||||
@@ -6,4 +6,4 @@ class WaitingListSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = WaitingListEntry
|
||||
fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale')
|
||||
fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale', 'subevent')
|
||||
|
||||
@@ -13,6 +13,7 @@ orga_router = routers.DefaultRouter()
|
||||
orga_router.register(r'events', event.EventViewSet)
|
||||
|
||||
event_router = routers.DefaultRouter()
|
||||
event_router.register(r'subevents', event.SubEventViewSet)
|
||||
event_router.register(r'items', item.ItemViewSet)
|
||||
event_router.register(r'categories', item.ItemCategoryViewSet)
|
||||
event_router.register(r'questions', item.QuestionViewSet)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from rest_framework import viewsets
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import filters, viewsets
|
||||
|
||||
from pretix.api.serializers.event import EventSerializer
|
||||
from pretix.base.models import Event
|
||||
from pretix.api.serializers.event import EventSerializer, SubEventSerializer
|
||||
from pretix.base.models import Event, ItemCategory
|
||||
from pretix.base.models.event import SubEvent
|
||||
|
||||
|
||||
class EventViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@@ -12,3 +14,21 @@ class EventViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.events.all()
|
||||
|
||||
|
||||
class SubEventFilter(FilterSet):
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ['active']
|
||||
|
||||
|
||||
class SubEventViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = SubEventSerializer
|
||||
queryset = ItemCategory.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
|
||||
filter_class = SubEventFilter
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.subevents.prefetch_related(
|
||||
'subeventitem_set', 'subeventitemvariation_set'
|
||||
)
|
||||
|
||||
@@ -58,10 +58,17 @@ class QuestionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return self.request.event.questions.prefetch_related('options').all()
|
||||
|
||||
|
||||
class QuotaFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Quota
|
||||
fields = ['subevent']
|
||||
|
||||
|
||||
class QuotaViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = QuotaSerializer
|
||||
queryset = Quota.objects.none()
|
||||
filter_backends = (OrderingFilter,)
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
filter_class = QuotaFilter
|
||||
ordering_fields = ('id', 'size')
|
||||
ordering = ('id',)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Concat
|
||||
from django.http import FileResponse
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import viewsets
|
||||
@@ -84,7 +85,7 @@ class OrderPositionFilter(FilterSet):
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ['item', 'variation', 'attendee_name', 'secret', 'order', 'order__status', 'has_checkin',
|
||||
'addon_to']
|
||||
'addon_to', 'subevent']
|
||||
|
||||
|
||||
class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@@ -137,12 +138,21 @@ class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
|
||||
class InvoiceFilter(FilterSet):
|
||||
refers = django_filters.CharFilter(name='refers', lookup_expr='invoice_no__iexact')
|
||||
refers = django_filters.CharFilter(method='refers_qs')
|
||||
number = django_filters.CharFilter(method='nr_qs')
|
||||
order = django_filters.CharFilter(name='order', lookup_expr='code__iexact')
|
||||
|
||||
def refers_qs(self, queryset, name, value):
|
||||
return queryset.annotate(
|
||||
refers_nr=Concat('refers__prefix', 'refers__invoice_no')
|
||||
).filter(refers_nr__iexact=value)
|
||||
|
||||
def nr_qs(self, queryset, name, value):
|
||||
return queryset.filter(nr__iexact=value)
|
||||
|
||||
class Meta:
|
||||
model = Invoice
|
||||
fields = ['order', 'invoice_no', 'is_cancellation', 'refers', 'locale']
|
||||
fields = ['order', 'number', 'is_cancellation', 'refers', 'locale']
|
||||
|
||||
|
||||
class RetryException(APIException):
|
||||
@@ -155,15 +165,17 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = InvoiceSerializer
|
||||
queryset = Invoice.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('invoice_no',)
|
||||
ordering_fields = ('invoice_no', 'date')
|
||||
ordering = ('nr',)
|
||||
ordering_fields = ('nr', 'date')
|
||||
filter_class = InvoiceFilter
|
||||
lookup_field = 'invoice_no'
|
||||
lookup_url_kwarg = 'invoice_no'
|
||||
permission = 'can_view_orders'
|
||||
lookup_url_kwarg = 'number'
|
||||
lookup_field = 'nr'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.invoices.prefetch_related('lines').select_related('order')
|
||||
return self.request.event.invoices.prefetch_related('lines').select_related('order', 'refers').annotate(
|
||||
nr=Concat('prefix', 'invoice_no')
|
||||
)
|
||||
|
||||
@detail_route()
|
||||
def download(self, request, **kwargs):
|
||||
|
||||
@@ -16,7 +16,7 @@ class VoucherFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Voucher
|
||||
fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota',
|
||||
'price_mode', 'value', 'item', 'variation', 'quota', 'tag']
|
||||
'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent']
|
||||
|
||||
def filter_active(self, queryset, name, value):
|
||||
if value:
|
||||
|
||||
@@ -15,7 +15,7 @@ class WaitingListFilter(FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = WaitingListEntry
|
||||
fields = ['item', 'variation', 'email', 'locale', 'has_voucher']
|
||||
fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent']
|
||||
|
||||
|
||||
class WaitingListViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class PretixBaseConfig(AppConfig):
|
||||
@@ -9,6 +10,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import exporter # NOQA
|
||||
from . import payment # NOQA
|
||||
from . import exporters # NOQA
|
||||
from . import invoice # NOQA
|
||||
from .services import export, mail, tickets, cart, orders, cleanup, update_check # NOQA
|
||||
|
||||
try:
|
||||
@@ -16,6 +18,10 @@ class PretixBaseConfig(AppConfig):
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if hasattr(settings, 'RAVEN_CONFIG'):
|
||||
from ..sentry import initialize
|
||||
initialize()
|
||||
|
||||
|
||||
default_app_config = 'pretix.base.PretixBaseConfig'
|
||||
try:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .answers import * # noqa
|
||||
from .invoices import * # noqa
|
||||
from .json import * # noqa
|
||||
from .mail import * # noqa
|
||||
|
||||
61
src/pretix/base/exporters/answers.py
Normal file
61
src/pretix/base/exporters/answers.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
import tempfile
|
||||
from collections import OrderedDict
|
||||
from zipfile import ZipFile
|
||||
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import QuestionAnswer
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..signals import register_data_exporters
|
||||
|
||||
|
||||
class AnswerFilesExporter(BaseExporter):
|
||||
identifier = 'answerfiles'
|
||||
verbose_name = _('Answers to file upload questions')
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('questions',
|
||||
forms.ModelMultipleChoiceField(
|
||||
queryset=self.event.questions.filter(type='F'),
|
||||
label=_('Questions'),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
def render(self, form_data: dict):
|
||||
qs = QuestionAnswer.objects.filter(
|
||||
orderposition__order__event=self.event,
|
||||
).select_related('orderposition', 'orderposition__order', 'question')
|
||||
if form_data.get('questions'):
|
||||
qs = qs.filter(question__in=form_data['questions'])
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for i in qs:
|
||||
if i.file:
|
||||
i.file.open('r')
|
||||
fname = '{}-{}-{}-q{}-{}'.format(
|
||||
self.event.slug.upper(),
|
||||
i.orderposition.order.code,
|
||||
i.orderposition.positionid,
|
||||
i.question.pk,
|
||||
os.path.basename(i.file.name).split('.', 1)[1]
|
||||
)
|
||||
zipf.writestr(fname, i.file.read())
|
||||
i.file.close()
|
||||
|
||||
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
|
||||
return 'answers.zip', 'application/zip', zipf.read()
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_answers")
|
||||
def register_anwers_export(sender, **kwargs):
|
||||
return AnswerFilesExporter
|
||||
@@ -21,7 +21,7 @@ class InvoiceExporter(BaseExporter):
|
||||
if not i.file:
|
||||
invoice_pdf_task.apply(args=(i.pk,))
|
||||
i.refresh_from_db()
|
||||
i.file.open('r')
|
||||
i.file.open('rb')
|
||||
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
|
||||
i.file.close()
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import csv
|
||||
import io
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
|
||||
import pytz
|
||||
from defusedcsv import csv
|
||||
from django import forms
|
||||
from django.db.models import Sum
|
||||
from django.dispatch import receiver
|
||||
@@ -100,7 +100,7 @@ class OrderListExporter(BaseExporter):
|
||||
order.invoice_address.street,
|
||||
order.invoice_address.zipcode,
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country,
|
||||
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country_old,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.utils.crypto import get_random_string
|
||||
from hierarkey.forms import HierarkeyForm
|
||||
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||
|
||||
from .validators import PlaceholderValidator # NOQA
|
||||
|
||||
@@ -56,6 +57,9 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
kwargs['locales'] = self.locales
|
||||
kwargs['initial'] = self.obj.settings.freeze()
|
||||
super().__init__(*args, **kwargs)
|
||||
for f in self.fields.values():
|
||||
if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
|
||||
f.set_event(self.obj)
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
nonce = get_random_string(length=8)
|
||||
|
||||
411
src/pretix/base/invoice.py
Normal file
411
src/pretix/base/invoice.py
Normal file
@@ -0,0 +1,411 @@
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from typing import Tuple
|
||||
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import pgettext
|
||||
from reportlab.lib import pagesizes
|
||||
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import (
|
||||
BaseDocTemplate, Frame, NextPageTemplate, PageTemplate, Paragraph, Spacer,
|
||||
Table, TableStyle,
|
||||
)
|
||||
|
||||
from pretix.base.models import Event, Invoice
|
||||
from pretix.base.signals import register_invoice_renderers
|
||||
|
||||
|
||||
class BaseInvoiceRenderer:
|
||||
"""
|
||||
This is the base class for all invoice renderers.
|
||||
"""
|
||||
|
||||
def __init__(self, event: Event):
|
||||
self.event = event
|
||||
|
||||
def __str__(self):
|
||||
return self.identifier
|
||||
|
||||
def generate(self, invoice: Invoice) -> Tuple[str, str, str]:
|
||||
"""
|
||||
This method should generate the invoice file and return a tuple consisting of a
|
||||
filename, a file type and file content. The extension will be taken from the filename
|
||||
which is otherwise ignored.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
A human-readable name for this renderer. This should be short but
|
||||
self-explanatory. Good examples include 'German DIN 5008' or 'Italian invoice'.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""
|
||||
A short and unique identifier for this renderer.
|
||||
This should only contain lowercase letters and in most
|
||||
cases will be the same as your package name.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
|
||||
class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
"""
|
||||
This is a convenience class to avoid duplicate code when implementing invoice renderers
|
||||
that are based on reportlab.
|
||||
"""
|
||||
pagesize = pagesizes.A4
|
||||
left_margin = 25 * mm
|
||||
right_margin = 20 * mm
|
||||
top_margin = 20 * mm
|
||||
bottom_margin = 15 * mm
|
||||
doc_template_class = BaseDocTemplate
|
||||
|
||||
def _init(self):
|
||||
"""
|
||||
Initialize the renderer. By default, this registers fonts and sets ``self.stylesheet``.
|
||||
"""
|
||||
self.stylesheet = self._get_stylesheet()
|
||||
self._register_fonts()
|
||||
|
||||
def _get_stylesheet(self):
|
||||
"""
|
||||
Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles.
|
||||
"""
|
||||
stylesheet = StyleSheet1()
|
||||
stylesheet.add(ParagraphStyle(name='Normal', fontName='OpenSans', fontSize=10, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName='OpenSansBd', fontSize=15, leading=15 * 1.2))
|
||||
return stylesheet
|
||||
|
||||
def _register_fonts(self):
|
||||
"""
|
||||
Register fonts with reportlab. By default, this registers the OpenSans font family
|
||||
"""
|
||||
pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf')))
|
||||
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
|
||||
italic='OpenSansIt', boldItalic='OpenSansBI')
|
||||
|
||||
def _on_other_page(self, canvas: Canvas, doc):
|
||||
"""
|
||||
Called when a new page is rendered that is *not* the first page.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _on_first_page(self, canvas: Canvas, doc):
|
||||
"""
|
||||
Called when a new page is rendered that is the first page.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _get_story(self, doc):
|
||||
"""
|
||||
Called to create the story to be inserted into the main frames.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _get_first_page_frames(self, doc):
|
||||
"""
|
||||
Called to create a list of frames for the first page.
|
||||
"""
|
||||
return [
|
||||
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm,
|
||||
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0,
|
||||
id='normal')
|
||||
]
|
||||
|
||||
def _get_other_page_frames(self, doc):
|
||||
"""
|
||||
Called to create a list of frames for the other pages.
|
||||
"""
|
||||
return [
|
||||
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height,
|
||||
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0,
|
||||
id='normal')
|
||||
]
|
||||
|
||||
def _build_doc(self, fhandle):
|
||||
"""
|
||||
Build a PDF document in a given file handle
|
||||
"""
|
||||
self._init()
|
||||
doc = self.doc_template_class(fhandle, pagesize=self.pagesize,
|
||||
leftMargin=self.left_margin, rightMargin=self.right_margin,
|
||||
topMargin=self.top_margin, bottomMargin=self.bottom_margin)
|
||||
|
||||
doc.addPageTemplates([
|
||||
PageTemplate(
|
||||
id='FirstPage',
|
||||
frames=self._get_first_page_frames(doc),
|
||||
onPage=self._on_first_page,
|
||||
pagesize=self.pagesize
|
||||
),
|
||||
PageTemplate(
|
||||
id='OtherPages',
|
||||
frames=self._get_other_page_frames(doc),
|
||||
onPage=self._on_other_page,
|
||||
pagesize=self.pagesize
|
||||
)
|
||||
])
|
||||
story = self._get_story(doc)
|
||||
doc.build(story)
|
||||
return doc
|
||||
|
||||
def generate(self, invoice: Invoice):
|
||||
self.invoice = invoice
|
||||
buffer = BytesIO()
|
||||
self._build_doc(buffer)
|
||||
buffer.seek(0)
|
||||
return 'invoice.pdf', 'application/pdf', buffer.read()
|
||||
|
||||
|
||||
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
identifier = 'classic'
|
||||
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
|
||||
|
||||
def _on_other_page(self, canvas: Canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
|
||||
|
||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
|
||||
canvas.restoreState()
|
||||
|
||||
def _on_first_page(self, canvas: Canvas, doc):
|
||||
canvas.setCreator('pretix.eu')
|
||||
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
|
||||
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
|
||||
|
||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
p = Paragraph(self.invoice.invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, 70 * mm, 50 * mm)
|
||||
p_size = p.wrap(70 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
p = Paragraph(self.invoice.invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, 85 * mm, 50 * mm)
|
||||
p_size = p.wrap(85 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(self.invoice.order.full_code)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
if self.invoice.is_cancellation:
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(self.invoice.refers.number)
|
||||
else:
|
||||
textobject.textLine(pgettext('invoice', 'Invoice number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
if self.invoice.is_cancellation:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(self.invoice.refers.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
else:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
canvas.drawText(textobject)
|
||||
|
||||
if self.invoice.event.settings.invoice_logo_image:
|
||||
logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True)
|
||||
canvas.drawImage(ImageReader(logo_file),
|
||||
95 * mm, (297 - 38) * mm,
|
||||
width=25 * mm, height=25 * mm,
|
||||
preserveAspectRatio=True, anchor='n',
|
||||
mask='auto')
|
||||
|
||||
if self.invoice.event.settings.show_date_to:
|
||||
p_str = (
|
||||
str(self.invoice.event.name) + '\n' + pgettext('invoice', '{from_date}\nuntil {to_date}').format(
|
||||
from_date=self.invoice.event.get_date_from_display(),
|
||||
to_date=self.invoice.event.get_date_to_display())
|
||||
)
|
||||
else:
|
||||
p_str = (
|
||||
str(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display()
|
||||
)
|
||||
|
||||
p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, 65 * mm, 50 * mm)
|
||||
p_size = p.wrap(65 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Event').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
canvas.restoreState()
|
||||
|
||||
def _get_first_page_frames(self, doc):
|
||||
footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm
|
||||
return [
|
||||
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm,
|
||||
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length,
|
||||
id='normal')
|
||||
]
|
||||
|
||||
def _get_other_page_frames(self, doc):
|
||||
footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm
|
||||
return [
|
||||
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height,
|
||||
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length,
|
||||
id='normal')
|
||||
]
|
||||
|
||||
def _get_story(self, doc):
|
||||
story = [
|
||||
NextPageTemplate('FirstPage'),
|
||||
Paragraph(pgettext('invoice', 'Invoice')
|
||||
if not self.invoice.is_cancellation
|
||||
else pgettext('invoice', 'Cancellation'),
|
||||
self.stylesheet['Heading1']),
|
||||
Spacer(1, 5 * mm),
|
||||
NextPageTemplate('OtherPages'),
|
||||
]
|
||||
|
||||
if self.invoice.introductory_text:
|
||||
story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal']))
|
||||
story.append(Spacer(1, 10 * mm))
|
||||
|
||||
taxvalue_map = defaultdict(Decimal)
|
||||
grossvalue_map = defaultdict(Decimal)
|
||||
|
||||
tstyledata = [
|
||||
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'),
|
||||
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
]
|
||||
tdata = [(
|
||||
pgettext('invoice', 'Description'),
|
||||
pgettext('invoice', 'Tax rate'),
|
||||
pgettext('invoice', 'Net'),
|
||||
pgettext('invoice', 'Gross'),
|
||||
)]
|
||||
total = Decimal('0.00')
|
||||
for line in self.invoice.lines.all():
|
||||
tdata.append((
|
||||
Paragraph(line.description, self.stylesheet['Normal']),
|
||||
localize(line.tax_rate) + " %",
|
||||
localize(line.net_value) + " " + self.invoice.event.currency,
|
||||
localize(line.gross_value) + " " + self.invoice.event.currency,
|
||||
))
|
||||
taxvalue_map[line.tax_rate] += line.tax_value
|
||||
grossvalue_map[line.tax_rate] += line.gross_value
|
||||
total += line.gross_value
|
||||
|
||||
tdata.append(
|
||||
[pgettext('invoice', 'Invoice total'), '', '', localize(total) + " " + self.invoice.event.currency])
|
||||
colwidths = [a * doc.width for a in (.55, .15, .15, .15)]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=1)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
story.append(table)
|
||||
|
||||
story.append(Spacer(1, 15 * mm))
|
||||
|
||||
if self.invoice.payment_provider_text:
|
||||
story.append(Paragraph(self.invoice.payment_provider_text, self.stylesheet['Normal']))
|
||||
|
||||
if self.invoice.additional_text:
|
||||
story.append(Paragraph(self.invoice.additional_text, self.stylesheet['Normal']))
|
||||
story.append(Spacer(1, 15 * mm))
|
||||
|
||||
tstyledata = [
|
||||
('SPAN', (1, 0), (-1, 0)),
|
||||
('ALIGN', (2, 1), (-1, -1), 'RIGHT'),
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||
]
|
||||
tdata = [('', pgettext('invoice', 'Included taxes'), '', '', ''),
|
||||
('', pgettext('invoice', 'Tax rate'),
|
||||
pgettext('invoice', 'Net value'), pgettext('invoice', 'Gross value'), pgettext('invoice', 'Tax'))]
|
||||
|
||||
for rate, gross in grossvalue_map.items():
|
||||
if rate == 0:
|
||||
continue
|
||||
tax = taxvalue_map[rate]
|
||||
tdata.append((
|
||||
'',
|
||||
localize(rate) + " %",
|
||||
localize((gross - tax)) + " " + self.invoice.event.currency,
|
||||
localize(gross) + " " + self.invoice.event.currency,
|
||||
localize(tax) + " " + self.invoice.event.currency,
|
||||
))
|
||||
|
||||
if len(tdata) > 2:
|
||||
colwidths = [a * doc.width for a in (.45, .10, .15, .15, .15)]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=2)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
story.append(table)
|
||||
return story
|
||||
|
||||
|
||||
@receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic")
|
||||
def recv_classic(sender, **kwargs):
|
||||
return ClassicInvoiceRenderer
|
||||
@@ -7,6 +7,7 @@ from django.core.urlresolvers import get_script_prefix
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.translation import LANGUAGE_SESSION_KEY
|
||||
from django.utils.translation.trans_real import (
|
||||
@@ -132,8 +133,8 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
return (
|
||||
get_language_from_user_settings(request)
|
||||
or get_language_from_session_or_cookie(request)
|
||||
or get_language_from_event(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
or get_default_language()
|
||||
)
|
||||
|
||||
@@ -165,6 +166,9 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'/api/v1/docs/',
|
||||
)
|
||||
|
||||
def process_request(self, request):
|
||||
request.csp_nonce = get_random_string(length=32)
|
||||
|
||||
def process_response(self, request, resp):
|
||||
if settings.DEBUG and resp.status_code >= 400:
|
||||
# Don't use CSP on debug error page as it breaks of Django's fancy error
|
||||
@@ -179,9 +183,9 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
# frame-src is deprecated but kept for compatibility with CSP 1.0 browsers, e.g. Safari 9
|
||||
'frame-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'child-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'style-src': ["{static}"],
|
||||
'style-src': ["{static}", "'nonce-{nonce}'"],
|
||||
'connect-src': ["{dynamic}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "data:", "https://*.stripe.com"],
|
||||
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"],
|
||||
# form-action is not only used to match on form actions, but also on URLs
|
||||
# form-actions redirect to. In the context of e.g. payment providers or
|
||||
# single-sign-on this can be nearly anything so we cannot really restrict
|
||||
@@ -193,6 +197,9 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
staticdomain = "'self'"
|
||||
dynamicdomain = "'self'"
|
||||
mediadomain = "'self'"
|
||||
if settings.MEDIA_URL.startswith('http'):
|
||||
mediadomain += " " + settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)]
|
||||
if settings.STATIC_URL.startswith('http'):
|
||||
staticdomain += " " + settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)]
|
||||
if settings.SITE_URL.startswith('http'):
|
||||
@@ -212,5 +219,6 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
dynamicdomain += " " + domain
|
||||
|
||||
if request.path not in self.CSP_EXEMPT:
|
||||
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain)
|
||||
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain,
|
||||
media=mediadomain, nonce=request.csp_nonce)
|
||||
return resp
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.2 on 2017-07-30 17:47
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
import django.db.migrations.operations.special
|
||||
import django.db.models.deletion
|
||||
import django_countries.fields
|
||||
import i18nfield.fields
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.event
|
||||
import pretix.base.models.invoices
|
||||
import pretix.base.models.orders
|
||||
import pretix.base.models.organizer
|
||||
import pretix.base.validators
|
||||
|
||||
|
||||
def create_teams(apps, schema_editor):
|
||||
Organizer = apps.get_model('pretixbase', 'Organizer')
|
||||
Team = apps.get_model('pretixbase', 'Team')
|
||||
|
||||
for o in Organizer.objects.prefetch_related('events'):
|
||||
for e in o.events.all():
|
||||
teams = {}
|
||||
|
||||
for p in e.user_perms.all():
|
||||
pkey = (p.can_change_settings, p.can_change_items, p.can_view_orders,
|
||||
p.can_change_permissions, p.can_change_orders, p.can_view_vouchers,
|
||||
p.can_change_vouchers)
|
||||
if pkey not in teams:
|
||||
team = Team()
|
||||
team.can_change_event_settings = p.can_change_settings
|
||||
team.can_change_items = p.can_change_items
|
||||
team.can_view_orders = p.can_view_orders
|
||||
team.can_change_orders = p.can_change_orders
|
||||
team.can_view_vouchers = p.can_view_vouchers
|
||||
team.can_change_vouchers = p.can_change_vouchers
|
||||
team.organizer = o
|
||||
team.name = '{} Team {}'.format(
|
||||
str(e.name), len(teams) + 1
|
||||
)
|
||||
team.save()
|
||||
team.limit_events.add(e)
|
||||
|
||||
teams[pkey] = team
|
||||
|
||||
if p.user:
|
||||
teams[pkey].members.add(p.user)
|
||||
else:
|
||||
teams[pkey].invites.create(email=p.invite_email, token=p.invite_token)
|
||||
|
||||
teams = {}
|
||||
for p in o.user_perms.all():
|
||||
pkey = (p.can_create_events, p.can_change_permissions)
|
||||
if pkey not in teams:
|
||||
team = Team()
|
||||
team.can_change_organizer_settings = True
|
||||
team.can_create_events = p.can_create_events
|
||||
team.can_change_teams = p.can_change_permissions
|
||||
team.organizer = o
|
||||
team.name = '{} Team {}'.format(
|
||||
str(o.name), len(teams) + 1
|
||||
)
|
||||
team.save()
|
||||
teams[pkey] = team
|
||||
|
||||
if p.user:
|
||||
teams[pkey].members.add(p.user)
|
||||
else:
|
||||
teams[pkey].invites.create(email=p.invite_email, token=p.invite_token)
|
||||
|
||||
|
||||
def rename_placeholder(app, schema_editor):
|
||||
EventSettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
|
||||
|
||||
for setting in EventSettingsStore.objects.all():
|
||||
if setting.key == 'mail_text_order_placed':
|
||||
new_value = setting.value.replace('{paymentinfo}', '{payment_info}')
|
||||
setting.value = new_value
|
||||
cache.delete('hierarkey_{}_{}'.format('event', setting.object_id))
|
||||
setting.save()
|
||||
|
||||
|
||||
def fwd69(app, schema_editor):
|
||||
Event = app.get_model('pretixbase', 'Event')
|
||||
for e in Event.objects.select_related('organizer').all():
|
||||
e.invoices.all().update(prefix=e.slug.upper() + '-', organizer=e.organizer)
|
||||
|
||||
|
||||
def fwd70(app, schema_editor):
|
||||
InvoiceAddress = app.get_model('pretixbase', 'InvoiceAddress')
|
||||
for ia in InvoiceAddress.objects.all():
|
||||
if ia.company or ia.vat_id:
|
||||
ia.is_business = True
|
||||
ia.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
replaces = [('pretixbase', '0052_team_teaminvite'), ('pretixbase', '0058_auto_20170429_1020'),
|
||||
('pretixbase', '0059_checkin_nonce'), ('pretixbase', '0060_auto_20170510_1027'),
|
||||
('pretixbase', '0061_auto_20170521_0942'), ('pretixbase', '0062_auto_20170602_0948'),
|
||||
('pretixbase', '0063_auto_20170702_1711'), ('pretixbase', '0064_auto_20170703_0912'),
|
||||
('pretixbase', '0065_auto_20170707_0920'), ('pretixbase', '0066_auto_20170708_2102'),
|
||||
('pretixbase', '0067_auto_20170712_1610'), ('pretixbase', '0068_subevent_frontpage_text'),
|
||||
('pretixbase', '0069_invoice_prefix'), ('pretixbase', '0070_auto_20170719_0910')]
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0051_auto_20170206_2027_squashed_0057_auto_20170501_2116'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Team',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=190, verbose_name='Team name')),
|
||||
('all_events',
|
||||
models.BooleanField(default=False, verbose_name='All events (including newly created ones)')),
|
||||
('can_create_events', models.BooleanField(default=False, verbose_name='Can create events')),
|
||||
('can_change_teams', models.BooleanField(default=False, verbose_name='Can change permissions')),
|
||||
('can_change_organizer_settings',
|
||||
models.BooleanField(default=False, verbose_name='Can change organizer settings')),
|
||||
('can_change_event_settings',
|
||||
models.BooleanField(default=False, verbose_name='Can change event settings')),
|
||||
('can_change_items', models.BooleanField(default=False, verbose_name='Can change product settings')),
|
||||
('can_view_orders', models.BooleanField(default=False, verbose_name='Can view orders')),
|
||||
('can_change_orders', models.BooleanField(default=False, verbose_name='Can change orders')),
|
||||
('can_view_vouchers', models.BooleanField(default=False, verbose_name='Can view vouchers')),
|
||||
('can_change_vouchers', models.BooleanField(default=False, verbose_name='Can change vouchers')),
|
||||
('limit_events', models.ManyToManyField(to='pretixbase.Event', verbose_name='Limit to events')),
|
||||
('members', models.ManyToManyField(related_name='teams', to=settings.AUTH_USER_MODEL,
|
||||
verbose_name='Team members')),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams',
|
||||
to='pretixbase.Organizer')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Teams',
|
||||
'verbose_name': 'Team',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamInvite',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True)),
|
||||
('token',
|
||||
models.CharField(blank=True, default=pretix.base.models.organizer.generate_invite_token, max_length=64,
|
||||
null=True)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites',
|
||||
to='pretixbase.Team')),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=create_teams,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='eventpermission',
|
||||
name='event',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='eventpermission',
|
||||
name='user',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organizerpermission',
|
||||
name='organizer',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organizerpermission',
|
||||
name='user',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='event',
|
||||
name='permitted',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organizer',
|
||||
name='permitted',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='can_change_teams',
|
||||
field=models.BooleanField(default=False, verbose_name='Can change teams and permissions'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='limit_events',
|
||||
field=models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='EventPermission',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='OrganizerPermission',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='nonce',
|
||||
field=models.CharField(blank=True, max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='date_admission',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Admission time'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='location',
|
||||
field=i18nfield.fields.I18nTextField(blank=True, max_length=200, null=True, verbose_name='Location'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=rename_placeholder,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamAPIToken',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=190)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('token', models.CharField(default=pretix.base.models.organizer.generate_api_token, max_length=64)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens',
|
||||
to='pretixbase.Team')),
|
||||
],
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='voucher',
|
||||
options={'ordering': ('code',), 'verbose_name': 'Voucher', 'verbose_name_plural': 'Vouchers'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='meta_info',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Meta information'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='meta_info',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Meta information'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='currency',
|
||||
field=models.CharField(choices=[('AED', 'AED - UAE Dirham'), ('AFN', 'AFN - Afghani'), ('ALL', 'ALL - Lek'),
|
||||
('AMD', 'AMD - Armenian Dram'),
|
||||
('ANG', 'ANG - Netherlands Antillean Guilder'),
|
||||
('AOA', 'AOA - Kwanza'), ('ARS', 'ARS - Argentine Peso'),
|
||||
('AUD', 'AUD - Australian Dollar'), ('AWG', 'AWG - Aruban Florin'),
|
||||
('AZN', 'AZN - Azerbaijanian Manat'), ('BAM', 'BAM - Convertible Mark'),
|
||||
('BBD', 'BBD - Barbados Dollar'), ('BDT', 'BDT - Taka'),
|
||||
('BGN', 'BGN - Bulgarian Lev'), ('BHD', 'BHD - Bahraini Dinar'),
|
||||
('BIF', 'BIF - Burundi Franc'), ('BMD', 'BMD - Bermudian Dollar'),
|
||||
('BND', 'BND - Brunei Dollar'), ('BOB', 'BOB - Boliviano'),
|
||||
('BRL', 'BRL - Brazilian Real'), ('BSD', 'BSD - Bahamian Dollar'),
|
||||
('BTN', 'BTN - Ngultrum'), ('BWP', 'BWP - Pula'),
|
||||
('BYN', 'BYN - Belarusian Ruble'), ('BZD', 'BZD - Belize Dollar'),
|
||||
('CAD', 'CAD - Canadian Dollar'), ('CDF', 'CDF - Congolese Franc'),
|
||||
('CHF', 'CHF - Swiss Franc'), ('CLP', 'CLP - Chilean Peso'),
|
||||
('CNY', 'CNY - Yuan Renminbi'), ('COP', 'COP - Colombian Peso'),
|
||||
('CRC', 'CRC - Costa Rican Colon'), ('CUC', 'CUC - Peso Convertible'),
|
||||
('CUP', 'CUP - Cuban Peso'), ('CVE', 'CVE - Cabo Verde Escudo'),
|
||||
('CZK', 'CZK - Czech Koruna'), ('DJF', 'DJF - Djibouti Franc'),
|
||||
('DKK', 'DKK - Danish Krone'), ('DOP', 'DOP - Dominican Peso'),
|
||||
('DZD', 'DZD - Algerian Dinar'), ('EGP', 'EGP - Egyptian Pound'),
|
||||
('ERN', 'ERN - Nakfa'), ('ETB', 'ETB - Ethiopian Birr'),
|
||||
('EUR', 'EUR - Euro'),
|
||||
('FJD', 'FJD - Fiji Dollar'), ('FKP', 'FKP - Falkland Islands Pound'),
|
||||
('GBP', 'GBP - Pound Sterling'), ('GEL', 'GEL - Lari'),
|
||||
('GHS', 'GHS - Ghana Cedi'), ('GIP', 'GIP - Gibraltar Pound'),
|
||||
('GMD', 'GMD - Dalasi'), ('GNF', 'GNF - Guinea Franc'),
|
||||
('GTQ', 'GTQ - Quetzal'), ('GYD', 'GYD - Guyana Dollar'),
|
||||
('HKD', 'HKD - Hong Kong Dollar'), ('HNL', 'HNL - Lempira'),
|
||||
('HRK', 'HRK - Kuna'), ('HTG', 'HTG - Gourde'), ('HUF', 'HUF - Forint'),
|
||||
('IDR', 'IDR - Rupiah'), ('ILS', 'ILS - New Israeli Sheqel'),
|
||||
('INR', 'INR - Indian Rupee'), ('IQD', 'IQD - Iraqi Dinar'),
|
||||
('IRR', 'IRR - Iranian Rial'), ('ISK', 'ISK - Iceland Krona'),
|
||||
('JMD', 'JMD - Jamaican Dollar'), ('JOD', 'JOD - Jordanian Dinar'),
|
||||
('JPY', 'JPY - Yen'), ('KES', 'KES - Kenyan Shilling'),
|
||||
('KGS', 'KGS - Som'),
|
||||
('KHR', 'KHR - Riel'), ('KMF', 'KMF - Comoro Franc'),
|
||||
('KPW', 'KPW - North Korean Won'), ('KRW', 'KRW - Won'),
|
||||
('KWD', 'KWD - Kuwaiti Dinar'), ('KYD', 'KYD - Cayman Islands Dollar'),
|
||||
('KZT', 'KZT - Tenge'), ('LAK', 'LAK - Kip'),
|
||||
('LBP', 'LBP - Lebanese Pound'),
|
||||
('LKR', 'LKR - Sri Lanka Rupee'), ('LRD', 'LRD - Liberian Dollar'),
|
||||
('LSL', 'LSL - Loti'), ('LYD', 'LYD - Libyan Dinar'),
|
||||
('MAD', 'MAD - Moroccan Dirham'), ('MDL', 'MDL - Moldovan Leu'),
|
||||
('MGA', 'MGA - Malagasy Ariary'), ('MKD', 'MKD - Denar'),
|
||||
('MMK', 'MMK - Kyat'),
|
||||
('MNT', 'MNT - Tugrik'), ('MOP', 'MOP - Pataca'), ('MRO', 'MRO - Ouguiya'),
|
||||
('MUR', 'MUR - Mauritius Rupee'), ('MVR', 'MVR - Rufiyaa'),
|
||||
('MWK', 'MWK - Malawi Kwacha'), ('MXN', 'MXN - Mexican Peso'),
|
||||
('MYR', 'MYR - Malaysian Ringgit'), ('MZN', 'MZN - Mozambique Metical'),
|
||||
('NAD', 'NAD - Namibia Dollar'), ('NGN', 'NGN - Naira'),
|
||||
('NIO', 'NIO - Cordoba Oro'), ('NOK', 'NOK - Norwegian Krone'),
|
||||
('NPR', 'NPR - Nepalese Rupee'), ('NZD', 'NZD - New Zealand Dollar'),
|
||||
('OMR', 'OMR - Rial Omani'), ('PAB', 'PAB - Balboa'), ('PEN', 'PEN - Sol'),
|
||||
('PGK', 'PGK - Kina'), ('PHP', 'PHP - Philippine Peso'),
|
||||
('PKR', 'PKR - Pakistan Rupee'), ('PLN', 'PLN - Zloty'),
|
||||
('PYG', 'PYG - Guarani'), ('QAR', 'QAR - Qatari Rial'),
|
||||
('RON', 'RON - Romanian Leu'), ('RSD', 'RSD - Serbian Dinar'),
|
||||
('RUB', 'RUB - Russian Ruble'), ('RWF', 'RWF - Rwanda Franc'),
|
||||
('SAR', 'SAR - Saudi Riyal'), ('SBD', 'SBD - Solomon Islands Dollar'),
|
||||
('SCR', 'SCR - Seychelles Rupee'), ('SDG', 'SDG - Sudanese Pound'),
|
||||
('SEK', 'SEK - Swedish Krona'), ('SGD', 'SGD - Singapore Dollar'),
|
||||
('SHP', 'SHP - Saint Helena Pound'), ('SLL', 'SLL - Leone'),
|
||||
('SOS', 'SOS - Somali Shilling'), ('SRD', 'SRD - Surinam Dollar'),
|
||||
('SSP', 'SSP - South Sudanese Pound'), ('STD', 'STD - Dobra'),
|
||||
('SVC', 'SVC - El Salvador Colon'), ('SYP', 'SYP - Syrian Pound'),
|
||||
('SZL', 'SZL - Lilangeni'), ('THB', 'THB - Baht'), ('TJS', 'TJS - Somoni'),
|
||||
('TMT', 'TMT - Turkmenistan New Manat'), ('TND', 'TND - Tunisian Dinar'),
|
||||
('TOP', 'TOP - Pa’anga'), ('TRY', 'TRY - Turkish Lira'),
|
||||
('TTD', 'TTD - Trinidad and Tobago Dollar'),
|
||||
('TWD', 'TWD - New Taiwan Dollar'),
|
||||
('TZS', 'TZS - Tanzanian Shilling'), ('UAH', 'UAH - Hryvnia'),
|
||||
('UGX', 'UGX - Uganda Shilling'), ('USD', 'USD - US Dollar'),
|
||||
('UYU', 'UYU - Peso Uruguayo'), ('UZS', 'UZS - Uzbekistan Sum'),
|
||||
('VEF', 'VEF - Bolívar'), ('VND', 'VND - Dong'), ('VUV', 'VUV - Vatu'),
|
||||
('WST', 'WST - Tala'), ('XAF', 'XAF - CFA Franc BEAC'),
|
||||
('XAG', 'XAG - Silver'),
|
||||
('XAU', 'XAU - Gold'),
|
||||
('XBA', 'XBA - Bond Markets Unit European Composite Unit (EURCO)'),
|
||||
('XBB', 'XBB - Bond Markets Unit European Monetary Unit (E.M.U.-6)'),
|
||||
('XBC', 'XBC - Bond Markets Unit European Unit of Account 9 (E.U.A.-9)'),
|
||||
('XBD', 'XBD - Bond Markets Unit European Unit of Account 17 (E.U.A.-17)'),
|
||||
('XCD', 'XCD - East Caribbean Dollar'),
|
||||
('XDR', 'XDR - SDR (Special Drawing Right)'),
|
||||
('XOF', 'XOF - CFA Franc BCEAO'),
|
||||
('XPD', 'XPD - Palladium'), ('XPF', 'XPF - CFP Franc'),
|
||||
('XPT', 'XPT - Platinum'), ('XSU', 'XSU - Sucre'),
|
||||
('XTS', 'XTS - Codes specifically reserved for testing purposes'),
|
||||
('XUA', 'XUA - ADB Unit of Account'), ('XXX',
|
||||
'XXX - The codes assigned for transactions where no currency is involved'),
|
||||
('YER', 'YER - Yemeni Rial'), ('ZAR', 'ZAR - Rand'),
|
||||
('ZMW', 'ZMW - Zambian Kwacha'), ('ZWL', 'ZWL - Zimbabwe Dollar')],
|
||||
default='EUR', max_length=10, verbose_name='Default currency'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(
|
||||
help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily remembered, but you can also choose to use a random value. This will be used in URLs, order codes, invoice numbers, and bank transfer references.',
|
||||
validators=[django.core.validators.RegexValidator(
|
||||
message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'),
|
||||
pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoice',
|
||||
name='date',
|
||||
field=models.DateField(default=pretix.base.models.invoices.today),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='allow_cancel',
|
||||
field=models.BooleanField(default=True,
|
||||
help_text='If this is active and the general event settings allow it, orders containing this product can be canceled by the user until the order is paid for. Users cannot cancel paid orders on their own and you can cancel orders at all times, regardless of this setting',
|
||||
verbose_name='Allow product to be canceled'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='questionanswer',
|
||||
name='file',
|
||||
field=models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.answerfile_name),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='question',
|
||||
name='type',
|
||||
field=models.CharField(
|
||||
choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No'),
|
||||
('C', 'Choose one from a list'), ('M', 'Choose multiple from a list'), ('F', 'File upload')],
|
||||
max_length=5, verbose_name='Question type'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='comment',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Internal comment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='presale_end',
|
||||
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold after this date.',
|
||||
null=True, verbose_name='End of presale'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='presale_start',
|
||||
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold before this date.',
|
||||
null=True, verbose_name='Start of presale'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubEvent',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('active', models.BooleanField(default=False,
|
||||
help_text='Only with this checkbox enabled, this sub-event is visible in the frontend to users.',
|
||||
verbose_name='Active')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')),
|
||||
('date_from', models.DateTimeField(verbose_name='Event start time')),
|
||||
('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')),
|
||||
('date_admission', models.DateTimeField(blank=True, null=True, verbose_name='Admission time')),
|
||||
('presale_end',
|
||||
models.DateTimeField(blank=True, help_text='No products will be sold after this date.', null=True,
|
||||
verbose_name='End of presale')),
|
||||
('presale_start',
|
||||
models.DateTimeField(blank=True, help_text='No products will be sold before this date.', null=True,
|
||||
verbose_name='Start of presale')),
|
||||
(
|
||||
'location',
|
||||
i18nfield.fields.I18nTextField(blank=True, max_length=200, null=True, verbose_name='Location')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Sub-Event',
|
||||
'verbose_name_plural': 'Sub-Events',
|
||||
'ordering': ('date_from', 'name'),
|
||||
},
|
||||
bases=(pretix.base.models.event.EventMixin, models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubEventItem',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Item')),
|
||||
('subevent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubEventItemVariation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
|
||||
('subevent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent')),
|
||||
(
|
||||
'variation',
|
||||
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.ItemVariation')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='has_subevents',
|
||||
field=models.BooleanField(default=False, verbose_name='Event series'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='event',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subevents',
|
||||
to='pretixbase.Event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='items',
|
||||
field=models.ManyToManyField(through='pretixbase.SubEventItem', to='pretixbase.Item'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='variations',
|
||||
field=models.ManyToManyField(through='pretixbase.SubEventItemVariation', to='pretixbase.ItemVariation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||
to='pretixbase.SubEvent', verbose_name='Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||
to='pretixbase.SubEvent', verbose_name='Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='quota',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='quotas', to='pretixbase.SubEvent', verbose_name='Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||
to='pretixbase.SubEvent', verbose_name='Date'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='waitinglistentry',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
|
||||
to='pretixbase.SubEvent', verbose_name='Date'),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='subevent',
|
||||
options={'ordering': ('date_from', 'name'), 'verbose_name': 'Date in event series',
|
||||
'verbose_name_plural': 'Dates in event series'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='price_included',
|
||||
field=models.BooleanField(default=False,
|
||||
help_text='If selected, adding add-ons to this ticket is free, even if the add-ons would normally cost money individually.',
|
||||
verbose_name='Add-Ons are included in the price'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subevent',
|
||||
name='active',
|
||||
field=models.BooleanField(default=False,
|
||||
help_text='Only with this checkbox enabled, this date is visible in the frontend to users.',
|
||||
verbose_name='Active'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subevent',
|
||||
name='presale_end',
|
||||
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold after this date.',
|
||||
null=True, verbose_name='End of presale'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subevent',
|
||||
name='presale_start',
|
||||
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold before this date.',
|
||||
null=True, verbose_name='Start of presale'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='frontpage_text',
|
||||
field=i18nfield.fields.I18nTextField(blank=True, null=True, verbose_name='Frontpage text'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='prefix',
|
||||
field=models.CharField(db_index=True, default='', max_length=160),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='organizer',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='invoices',
|
||||
to='pretixbase.Organizer'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=fwd69, reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='invoice',
|
||||
unique_together=set([('organizer', 'prefix', 'invoice_no')]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoice',
|
||||
name='organizer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices',
|
||||
to='pretixbase.Organizer'),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='invoiceaddress',
|
||||
old_name='country',
|
||||
new_name='country_old',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='country',
|
||||
field=django_countries.fields.CountryField(default='', max_length=2, verbose_name='Country'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='is_business',
|
||||
field=models.BooleanField(default=False, verbose_name='Business customer'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=fwd70, reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
]
|
||||
53
src/pretix/base/migrations/0063_auto_20170702_1711.py
Normal file
53
src/pretix/base/migrations/0063_auto_20170702_1711.py
Normal file
File diff suppressed because one or more lines are too long
27
src/pretix/base/migrations/0064_auto_20170703_0912.py
Normal file
27
src/pretix/base/migrations/0064_auto_20170703_0912.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.2 on 2017-07-03 09:12
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.orders
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0063_auto_20170702_1711'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='questionanswer',
|
||||
name='file',
|
||||
field=models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.answerfile_name),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='question',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No'), ('C', 'Choose one from a list'), ('M', 'Choose multiple from a list'), ('F', 'File upload')], max_length=5, verbose_name='Question type'),
|
||||
),
|
||||
]
|
||||
30
src/pretix/base/migrations/0065_auto_20170707_0920.py
Normal file
30
src/pretix/base/migrations/0065_auto_20170707_0920.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.2 on 2017-07-07 09:20
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0064_auto_20170703_0912'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='comment',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Internal comment'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='presale_end',
|
||||
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold after this date.', null=True, verbose_name='End of presale'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='presale_start',
|
||||
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold before this date.', null=True, verbose_name='Start of presale'),
|
||||
),
|
||||
]
|
||||
103
src/pretix/base/migrations/0066_auto_20170708_2102.py
Normal file
103
src/pretix/base/migrations/0066_auto_20170708_2102.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.2 on 2017-07-08 21:02
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.event
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0065_auto_20170707_0920'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SubEvent',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('active', models.BooleanField(default=False, help_text='Only with this checkbox enabled, this sub-event is visible in the frontend to users.', verbose_name='Active')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')),
|
||||
('date_from', models.DateTimeField(verbose_name='Event start time')),
|
||||
('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')),
|
||||
('date_admission', models.DateTimeField(blank=True, null=True, verbose_name='Admission time')),
|
||||
('presale_end', models.DateTimeField(blank=True, help_text='No products will be sold after this date.', null=True, verbose_name='End of presale')),
|
||||
('presale_start', models.DateTimeField(blank=True, help_text='No products will be sold before this date.', null=True, verbose_name='Start of presale')),
|
||||
('location', i18nfield.fields.I18nTextField(blank=True, max_length=200, null=True, verbose_name='Location')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Sub-Event',
|
||||
'verbose_name_plural': 'Sub-Events',
|
||||
'ordering': ('date_from', 'name'),
|
||||
},
|
||||
bases=(pretix.base.models.event.EventMixin, models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubEventItem',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Item')),
|
||||
('subevent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubEventItemVariation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)),
|
||||
('subevent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent')),
|
||||
('variation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.ItemVariation')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='has_subevents',
|
||||
field=models.BooleanField(default=False, help_text='Only recommended for advanced users. If this feature is enabled, this will not only be a single event but a series of very similar events that are handled within a single shop. The single events inside the series can only differ in prices and quotas, not in other settings, and buying tickets across multiple of these events at the same time is possible.', verbose_name='Event series'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='event',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subevents', to='pretixbase.Event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='items',
|
||||
field=models.ManyToManyField(through='pretixbase.SubEventItem', to='pretixbase.Item'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='variations',
|
||||
field=models.ManyToManyField(through='pretixbase.SubEventItemVariation', to='pretixbase.ItemVariation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='quota',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quotas', to='pretixbase.SubEvent', verbose_name='Sub-event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='waitinglistentry',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'),
|
||||
),
|
||||
]
|
||||
70
src/pretix/base/migrations/0067_auto_20170712_1610.py
Normal file
70
src/pretix/base/migrations/0067_auto_20170712_1610.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.2 on 2017-07-12 16:10
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0066_auto_20170708_2102'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='subevent',
|
||||
options={'ordering': ('date_from', 'name'), 'verbose_name': 'Date in event series', 'verbose_name_plural': 'Dates in event series'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='price_included',
|
||||
field=models.BooleanField(default=False, help_text='If selected, adding add-ons to this ticket is free, even if the add-ons would normally cost money individually.', verbose_name='Add-Ons are included in the price'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='has_subevents',
|
||||
field=models.BooleanField(default=False, verbose_name='Event series'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='quota',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quotas', to='pretixbase.SubEvent', verbose_name='Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subevent',
|
||||
name='active',
|
||||
field=models.BooleanField(default=False, help_text='Only with this checkbox enabled, this date is visible in the frontend to users.', verbose_name='Active'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subevent',
|
||||
name='presale_end',
|
||||
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold after this date.', null=True, verbose_name='End of presale'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subevent',
|
||||
name='presale_start',
|
||||
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold before this date.', null=True, verbose_name='Start of presale'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='waitinglistentry',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Date'),
|
||||
),
|
||||
]
|
||||
21
src/pretix/base/migrations/0068_subevent_frontpage_text.py
Normal file
21
src/pretix/base/migrations/0068_subevent_frontpage_text.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.2 on 2017-07-14 16:11
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import i18nfield.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0067_auto_20170712_1610'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subevent',
|
||||
name='frontpage_text',
|
||||
field=i18nfield.fields.I18nTextField(blank=True, null=True, verbose_name='Frontpage text'),
|
||||
),
|
||||
]
|
||||
47
src/pretix/base/migrations/0069_invoice_prefix.py
Normal file
47
src/pretix/base/migrations/0069_invoice_prefix.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.2 on 2017-07-17 19:29
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import deletion
|
||||
|
||||
|
||||
def fwd(app, schema_editor):
|
||||
Event = app.get_model('pretixbase', 'Event')
|
||||
for e in Event.objects.select_related('organizer').all():
|
||||
e.invoices.all().update(prefix=e.slug.upper() + '-', organizer=e.organizer)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0068_subevent_frontpage_text'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='prefix',
|
||||
field=models.CharField(db_index=True, default='', max_length=160),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='organizer',
|
||||
field=models.ForeignKey(null=True,
|
||||
on_delete=deletion.PROTECT,
|
||||
related_name='invoices', to='pretixbase.Organizer'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(
|
||||
fwd, migrations.RunPython.noop
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='invoice',
|
||||
unique_together=set([('organizer', 'prefix', 'invoice_no')]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoice',
|
||||
name='organizer',
|
||||
field=models.ForeignKey(on_delete=deletion.PROTECT, related_name='invoices', to='pretixbase.Organizer'),
|
||||
),
|
||||
]
|
||||
43
src/pretix/base/migrations/0070_auto_20170719_0910.py
Normal file
43
src/pretix/base/migrations/0070_auto_20170719_0910.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.2 on 2017-07-19 09:10
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django_countries
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def fwd(app, schema_editor):
|
||||
InvoiceAddress = app.get_model('pretixbase', 'InvoiceAddress')
|
||||
for ia in InvoiceAddress.objects.all():
|
||||
if ia.company or ia.vat_id:
|
||||
ia.is_business = True
|
||||
ia.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0069_invoice_prefix'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='invoiceaddress',
|
||||
old_name='country',
|
||||
new_name='country_old',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='country',
|
||||
field=django_countries.fields.CountryField(default='', max_length=2, verbose_name='Country'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='is_business',
|
||||
field=models.BooleanField(default=False, verbose_name='Business customer'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
fwd, migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -3,13 +3,13 @@ from .auth import U2FDevice, User
|
||||
from .base import CachedFile, LoggedModel, cachedfile_name
|
||||
from .checkin import Checkin
|
||||
from .event import (
|
||||
Event, Event_SettingsStore, EventLock, RequiredAction,
|
||||
Event, Event_SettingsStore, EventLock, RequiredAction, SubEvent,
|
||||
generate_invite_token,
|
||||
)
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
|
||||
Quota, itempicture_upload_to,
|
||||
Quota, SubEventItem, SubEventItemVariation, itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .orders import (
|
||||
|
||||
@@ -6,7 +6,8 @@ from django.db import models
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils.crypto import get_random_string
|
||||
from i18nfield.utils import I18nJSONEncoder
|
||||
|
||||
from pretix.helpers.json import CustomJSONEncoder
|
||||
|
||||
|
||||
def cachedfile_name(instance, filename: str) -> str:
|
||||
@@ -16,7 +17,7 @@ def cachedfile_name(instance, filename: str) -> str:
|
||||
|
||||
class CachedFile(models.Model):
|
||||
"""
|
||||
A cached file (e.g. pre-generated ticket PDF)
|
||||
An uploaded file, with an optional expiry date.
|
||||
"""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
|
||||
expires = models.DateTimeField(null=True, blank=True)
|
||||
@@ -54,7 +55,7 @@ class LoggingMixin:
|
||||
event = self.event
|
||||
l = LogEntry(content_object=self, user=user, action_type=action, event=event)
|
||||
if data:
|
||||
l.data = json.dumps(data, cls=I18nJSONEncoder)
|
||||
l.data = json.dumps(data, cls=CustomJSONEncoder)
|
||||
l.save()
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import string
|
||||
import uuid
|
||||
from datetime import date, datetime, time
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, time
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
@@ -9,23 +10,128 @@ from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.email import CustomSMTPBackend
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.validators import EventSlugBlacklistValidator
|
||||
from pretix.helpers.daterange import daterange
|
||||
from pretix.helpers.json import safe_string
|
||||
|
||||
from ..settings import settings_hierarkey
|
||||
from .organizer import Organizer
|
||||
|
||||
|
||||
class EventMixin:
|
||||
|
||||
def clean(self):
|
||||
if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
|
||||
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
|
||||
if self.date_from and self.date_to and self.date_from > self.date_to:
|
||||
raise ValidationError({'date_to': _('The end of the event has to be later than its start.')})
|
||||
super().clean()
|
||||
|
||||
def get_date_from_display(self, tz=None, show_times=True) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start date of the event with respect
|
||||
to the current locale and to the ``show_times`` setting.
|
||||
"""
|
||||
tz = tz or pytz.timezone(self.settings.timezone)
|
||||
return _date(
|
||||
self.date_from.astimezone(tz),
|
||||
"DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT"
|
||||
)
|
||||
|
||||
def get_time_from_display(self, tz=None) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start time of the event, ignoring
|
||||
the ``show_times`` setting.
|
||||
"""
|
||||
tz = tz or pytz.timezone(self.settings.timezone)
|
||||
return _date(
|
||||
self.date_from.astimezone(tz), "TIME_FORMAT"
|
||||
)
|
||||
|
||||
def get_date_to_display(self, tz=None) -> 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
|
||||
if ``show_date_to`` is ``False``.
|
||||
"""
|
||||
tz = tz or pytz.timezone(self.settings.timezone)
|
||||
if not self.settings.show_date_to or not self.date_to:
|
||||
return ""
|
||||
return _date(
|
||||
self.date_to.astimezone(tz),
|
||||
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
|
||||
)
|
||||
|
||||
def get_date_range_display(self, tz=None) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start date and the event date
|
||||
of the event with respect to the current locale and to the ``show_times`` and
|
||||
``show_date_to`` settings.
|
||||
"""
|
||||
tz = tz or pytz.timezone(self.settings.timezone)
|
||||
if not self.settings.show_date_to or not self.date_to:
|
||||
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
|
||||
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
"""
|
||||
Is true, when ``presale_end`` is set and in the past.
|
||||
"""
|
||||
if self.presale_end and now() > self.presale_end:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def presale_is_running(self):
|
||||
"""
|
||||
Is true, when ``presale_end`` is not set or in the future and ``presale_start`` is not
|
||||
set or in the past.
|
||||
"""
|
||||
if self.presale_start and now() < self.presale_start:
|
||||
return False
|
||||
if self.presale_end and now() > self.presale_end:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def event_microdata(self):
|
||||
import json
|
||||
|
||||
eventdict = {
|
||||
"@context": "http://schema.org",
|
||||
"@type": "Event", "location": {
|
||||
"@type": "Place",
|
||||
"address": str(self.location)
|
||||
},
|
||||
"name": str(self.name)
|
||||
}
|
||||
|
||||
if self.settings.show_times:
|
||||
eventdict["startDate"] = self.date_from.isoformat()
|
||||
if self.settings.show_date_to and self.date_to is not None:
|
||||
eventdict["endDate"] = self.date_to.isoformat()
|
||||
else:
|
||||
eventdict["startDate"] = self.date_from.date().isoformat()
|
||||
if self.settings.show_date_to and self.date_to is not None:
|
||||
eventdict["endDate"] = self.date_to.date().isoformat()
|
||||
|
||||
return safe_string(json.dumps(eventdict))
|
||||
|
||||
|
||||
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
||||
class Event(LoggedModel):
|
||||
class Event(EventMixin, LoggedModel):
|
||||
"""
|
||||
This model represents an event. An event is anything you can buy
|
||||
tickets for.
|
||||
@@ -54,6 +160,8 @@ class Event(LoggedModel):
|
||||
:param plugins: A comma-separated list of plugin names that are active for this
|
||||
event.
|
||||
:type plugins: str
|
||||
:param has_subevents: Enable event series functionality
|
||||
:type has_subevents: bool
|
||||
"""
|
||||
|
||||
settings_namespace = 'event'
|
||||
@@ -96,12 +204,12 @@ class Event(LoggedModel):
|
||||
presale_end = models.DateTimeField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_("End of presale"),
|
||||
help_text=_("No products will be sold after this date."),
|
||||
help_text=_("Optional. No products will be sold after this date."),
|
||||
)
|
||||
presale_start = models.DateTimeField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Start of presale"),
|
||||
help_text=_("No products will be sold before this date."),
|
||||
help_text=_("Optional. No products will be sold before this date."),
|
||||
)
|
||||
location = I18nTextField(
|
||||
null=True, blank=True,
|
||||
@@ -112,6 +220,14 @@ class Event(LoggedModel):
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Plugins"),
|
||||
)
|
||||
comment = models.TextField(
|
||||
verbose_name=_("Internal comment"),
|
||||
null=True, blank=True
|
||||
)
|
||||
has_subevents = models.BooleanField(
|
||||
verbose_name=_('Event series'),
|
||||
default=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Event")
|
||||
@@ -126,13 +242,6 @@ class Event(LoggedModel):
|
||||
self.get_cache().clear()
|
||||
return obj
|
||||
|
||||
def clean(self):
|
||||
if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
|
||||
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
|
||||
if self.date_from and self.date_to and self.date_from > self.date_to:
|
||||
raise ValidationError({'date_to': _('The end of the event has to be later than its start.')})
|
||||
super().clean()
|
||||
|
||||
def get_plugins(self) -> "list[str]":
|
||||
"""
|
||||
Returns the names of the plugins activated for this event as a list.
|
||||
@@ -141,47 +250,6 @@ class Event(LoggedModel):
|
||||
return []
|
||||
return self.plugins.split(",")
|
||||
|
||||
def get_date_from_display(self, tz=None, show_times=True) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start date of the event with respect
|
||||
to the current locale and to the ``show_times`` setting.
|
||||
"""
|
||||
tz = tz or pytz.timezone(self.settings.timezone)
|
||||
return _date(
|
||||
self.date_from.astimezone(tz),
|
||||
"DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT"
|
||||
)
|
||||
|
||||
def get_time_from_display(self, tz=None) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start time of the event, ignoring
|
||||
the ``show_times`` setting.
|
||||
"""
|
||||
tz = tz or pytz.timezone(self.settings.timezone)
|
||||
return _date(
|
||||
self.date_from.astimezone(tz), "TIME_FORMAT"
|
||||
)
|
||||
|
||||
def get_date_to_display(self, tz=None) -> 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
|
||||
if ``show_date_to`` is ``False``.
|
||||
"""
|
||||
tz = tz or pytz.timezone(self.settings.timezone)
|
||||
if not self.settings.show_date_to or not self.date_to:
|
||||
return ""
|
||||
return _date(
|
||||
self.date_to.astimezone(tz),
|
||||
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
|
||||
)
|
||||
|
||||
def get_date_range_display(self, tz=None) -> str:
|
||||
tz = tz or pytz.timezone(self.settings.timezone)
|
||||
if not self.settings.show_date_to or not self.date_to:
|
||||
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
|
||||
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
|
||||
|
||||
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
|
||||
"""
|
||||
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
|
||||
@@ -193,20 +261,6 @@ class Event(LoggedModel):
|
||||
|
||||
return ObjectRelatedCache(self)
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
if self.presale_end and now() > self.presale_end:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def presale_is_running(self):
|
||||
if self.presale_start and now() < self.presale_start:
|
||||
return False
|
||||
if self.presale_end and now() > self.presale_end:
|
||||
return False
|
||||
return True
|
||||
|
||||
def lock(self):
|
||||
"""
|
||||
Returns a contextmanager that can be used to lock an event for bookings.
|
||||
@@ -216,6 +270,10 @@ class Event(LoggedModel):
|
||||
return locking.LockManager(self)
|
||||
|
||||
def get_mail_backend(self, force_custom=False):
|
||||
"""
|
||||
Returns an email server connection, either by using the system-wide connection
|
||||
or by returning a custom one based on the event's settings.
|
||||
"""
|
||||
if self.settings.smtp_use_custom or force_custom:
|
||||
return CustomSMTPBackend(host=self.settings.smtp_host,
|
||||
port=self.settings.smtp_port,
|
||||
@@ -229,9 +287,12 @@ class Event(LoggedModel):
|
||||
|
||||
@property
|
||||
def payment_term_last(self):
|
||||
"""
|
||||
The last datetime of payments for this event.
|
||||
"""
|
||||
tz = pytz.timezone(self.settings.timezone)
|
||||
return make_aware(datetime.combine(
|
||||
self.settings.get('payment_term_last', as_type=date),
|
||||
self.settings.get('payment_term_last', as_type=RelativeDateWrapper).datetime(self).date(),
|
||||
time(hour=23, minute=59, second=59)
|
||||
), tz)
|
||||
|
||||
@@ -240,6 +301,7 @@ class Event(LoggedModel):
|
||||
from ..signals import event_copy_data
|
||||
|
||||
self.plugins = other.plugins
|
||||
self.is_public = other.is_public
|
||||
self.save()
|
||||
|
||||
category_map = {}
|
||||
@@ -273,7 +335,7 @@ class Event(LoggedModel):
|
||||
ia.addon_category = category_map[ia.addon_category.pk]
|
||||
ia.save()
|
||||
|
||||
for q in Quota.objects.filter(event=other).prefetch_related('items', 'variations'):
|
||||
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())
|
||||
q.pk = None
|
||||
@@ -314,6 +376,9 @@ class Event(LoggedModel):
|
||||
event_copy_data.send(sender=self, other=other)
|
||||
|
||||
def get_payment_providers(self) -> dict:
|
||||
"""
|
||||
Returns a dictionary of initialized payment providers mapped by their identifiers.
|
||||
"""
|
||||
from ..signals import register_payment_providers
|
||||
|
||||
responses = register_payment_providers.send(self)
|
||||
@@ -324,7 +389,137 @@ class Event(LoggedModel):
|
||||
for p in response:
|
||||
pp = p(self)
|
||||
providers[pp.identifier] = pp
|
||||
return providers
|
||||
|
||||
return OrderedDict(sorted(providers.items(), key=lambda v: str(v[1].verbose_name)))
|
||||
|
||||
def get_invoice_renderers(self) -> dict:
|
||||
"""
|
||||
Returns a dictionary of initialized invoice renderers mapped by their identifiers.
|
||||
"""
|
||||
from ..signals import register_invoice_renderers
|
||||
|
||||
responses = register_invoice_renderers.send(self)
|
||||
renderers = {}
|
||||
for receiver, response in responses:
|
||||
if not isinstance(response, list):
|
||||
response = [response]
|
||||
for p in response:
|
||||
pp = p(self)
|
||||
renderers[pp.identifier] = pp
|
||||
return renderers
|
||||
|
||||
@property
|
||||
def invoice_renderer(self):
|
||||
"""
|
||||
Returns the currently configured invoice renderer.
|
||||
"""
|
||||
irs = self.get_invoice_renderers()
|
||||
return irs[self.settings.invoice_renderer]
|
||||
|
||||
@property
|
||||
def active_subevents(self):
|
||||
"""
|
||||
Returns a queryset of active subevents.
|
||||
"""
|
||||
return self.subevents.filter(active=True).order_by('-date_from', 'name')
|
||||
|
||||
@property
|
||||
def active_future_subevents(self):
|
||||
return self.subevents.filter(
|
||||
Q(active=True) & (
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(date_to__gte=now())
|
||||
)
|
||||
).order_by('date_from', 'name')
|
||||
|
||||
|
||||
class SubEvent(EventMixin, LoggedModel):
|
||||
"""
|
||||
This model represents a date within an event series.
|
||||
|
||||
:param event: The event this belongs to
|
||||
:type event: Event
|
||||
:param active: Whether to show the subevent
|
||||
:type active: bool
|
||||
:param name: This event's full title
|
||||
:type name: str
|
||||
:param date_from: The datetime this event starts
|
||||
:type date_from: datetime
|
||||
:param date_to: The datetime this event ends
|
||||
:type date_to: datetime
|
||||
:param presale_start: No tickets will be sold before this date.
|
||||
:type presale_start: datetime
|
||||
:param presale_end: No tickets will be sold after this date.
|
||||
:type presale_end: datetime
|
||||
:param location: venue
|
||||
:type location: str
|
||||
"""
|
||||
|
||||
event = models.ForeignKey(Event, related_name="subevents", on_delete=models.PROTECT)
|
||||
active = models.BooleanField(default=False, verbose_name=_("Active"),
|
||||
help_text=_("Only with this checkbox enabled, this date is visible in the "
|
||||
"frontend to users."))
|
||||
name = I18nCharField(
|
||||
max_length=200,
|
||||
verbose_name=_("Name"),
|
||||
)
|
||||
date_from = models.DateTimeField(verbose_name=_("Event start time"))
|
||||
date_to = models.DateTimeField(null=True, blank=True,
|
||||
verbose_name=_("Event end time"))
|
||||
date_admission = models.DateTimeField(null=True, blank=True,
|
||||
verbose_name=_("Admission time"))
|
||||
presale_end = models.DateTimeField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_("End of presale"),
|
||||
help_text=_("Optional. No products will be sold after this date."),
|
||||
)
|
||||
presale_start = models.DateTimeField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Start of presale"),
|
||||
help_text=_("Optional. No products will be sold before this date."),
|
||||
)
|
||||
location = I18nTextField(
|
||||
null=True, blank=True,
|
||||
max_length=200,
|
||||
verbose_name=_("Location"),
|
||||
)
|
||||
frontpage_text = I18nTextField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Frontpage text")
|
||||
)
|
||||
|
||||
items = models.ManyToManyField('Item', through='SubEventItem')
|
||||
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Date in event series")
|
||||
verbose_name_plural = _("Dates in event series")
|
||||
ordering = ("date_from", "name")
|
||||
|
||||
def __str__(self):
|
||||
return '{} - {}'.format(self.name, self.get_date_range_display())
|
||||
|
||||
@cached_property
|
||||
def settings(self):
|
||||
return self.event.settings
|
||||
|
||||
@cached_property
|
||||
def item_price_overrides(self):
|
||||
from .items import SubEventItem
|
||||
|
||||
return {
|
||||
si.item_id: si.price
|
||||
for si in SubEventItem.objects.filter(subevent=self, price__isnull=False)
|
||||
}
|
||||
|
||||
@cached_property
|
||||
def var_price_overrides(self):
|
||||
from .items import SubEventItemVariation
|
||||
|
||||
return {
|
||||
si.variation_id: si.price
|
||||
for si in SubEventItemVariation.objects.filter(subevent=self, price__isnull=False)
|
||||
}
|
||||
|
||||
|
||||
def generate_invite_token():
|
||||
|
||||
@@ -9,9 +9,10 @@ from django.utils.functional import cached_property
|
||||
|
||||
def invoice_filename(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
|
||||
return 'invoices/{org}/{ev}/{no}-{code}-{secret}.pdf'.format(
|
||||
return 'invoices/{org}/{ev}/{no}-{code}-{secret}.{ext}'.format(
|
||||
org=instance.event.organizer.slug, ev=instance.event.slug,
|
||||
no=instance.number, code=instance.order.code, secret=secret
|
||||
no=instance.number, code=instance.order.code, secret=secret,
|
||||
ext=filename.split('.')[-1]
|
||||
)
|
||||
|
||||
|
||||
@@ -28,6 +29,8 @@ class Invoice(models.Model):
|
||||
:type order: Order
|
||||
:param event: The event this belongs to (for convenience)
|
||||
:type event: Event
|
||||
:param organizer: The organizer this belongs to (redundant, for enforcing uniqueness)
|
||||
:type organizer: Organizer
|
||||
:param invoice_no: The human-readable, event-unique invoice number
|
||||
:type invoice_no: int
|
||||
:param is_cancellation: Whether or not this is a cancellation instead of an invoice
|
||||
@@ -54,7 +57,9 @@ class Invoice(models.Model):
|
||||
:type file: File
|
||||
"""
|
||||
order = models.ForeignKey('Order', related_name='invoices', db_index=True)
|
||||
organizer = models.ForeignKey('Organizer', related_name='invoices', db_index=True, on_delete=models.PROTECT)
|
||||
event = models.ForeignKey('Event', related_name='invoices', db_index=True)
|
||||
prefix = models.CharField(max_length=160, db_index=True)
|
||||
invoice_no = models.CharField(max_length=19, db_index=True)
|
||||
is_cancellation = models.BooleanField(default=False)
|
||||
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True)
|
||||
@@ -73,7 +78,10 @@ class Invoice(models.Model):
|
||||
return '{:05d}'.format(int(number))
|
||||
|
||||
def _get_numeric_invoice_number(self):
|
||||
numeric_invoices = Invoice.objects.filter(event=self.event).exclude(invoice_no__contains='-')
|
||||
numeric_invoices = Invoice.objects.filter(
|
||||
event__organizer=self.event.organizer,
|
||||
prefix=self.prefix,
|
||||
).exclude(invoice_no__contains='-')
|
||||
return self._to_numeric_invoice_number(numeric_invoices.count() + 1)
|
||||
|
||||
def _get_invoice_number_from_order(self):
|
||||
@@ -87,6 +95,10 @@ class Invoice(models.Model):
|
||||
raise ValueError('Every invoice needs to be connected to an order')
|
||||
if not self.event:
|
||||
self.event = self.order.event
|
||||
if not self.organizer:
|
||||
self.organizer = self.order.event.organizer
|
||||
if not self.prefix:
|
||||
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
|
||||
if not self.invoice_no:
|
||||
for i in range(10):
|
||||
if self.event.settings.get('invoice_numbers_consecutive'):
|
||||
@@ -115,8 +127,8 @@ class Invoice(models.Model):
|
||||
"""
|
||||
Returns the invoice number in a human-readable string with the event slug prepended.
|
||||
"""
|
||||
return '{event}-{code}'.format(
|
||||
event=self.event.slug.upper(),
|
||||
return '{prefix}{code}'.format(
|
||||
prefix=self.prefix,
|
||||
code=self.invoice_no
|
||||
)
|
||||
|
||||
@@ -125,7 +137,7 @@ class Invoice(models.Model):
|
||||
return self.refered.filter(is_cancellation=True).exists()
|
||||
|
||||
class Meta:
|
||||
unique_together = ('event', 'invoice_no')
|
||||
unique_together = ('organizer', 'prefix', 'invoice_no')
|
||||
ordering = ('invoice_no',)
|
||||
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ from django.db import models
|
||||
from django.db.models import F, Func, Q, Sum
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
|
||||
from .event import Event
|
||||
from .event import Event, SubEvent
|
||||
|
||||
|
||||
class ItemCategory(LoggedModel):
|
||||
@@ -88,6 +88,40 @@ def itempicture_upload_to(instance, filename: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
class SubEventItem(models.Model):
|
||||
"""
|
||||
This model can be used to change the price of a product for a single subevent (i.e. a
|
||||
date in an event series).
|
||||
|
||||
:param subevent: The date this belongs to
|
||||
:type subevent: SubEvent
|
||||
:param item: The item to modify the price for
|
||||
:type item: Item
|
||||
:param price: The modified price (or ``None`` for the original price)
|
||||
:type price: Decimal
|
||||
"""
|
||||
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
|
||||
item = models.ForeignKey('Item', on_delete=models.CASCADE)
|
||||
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
|
||||
|
||||
|
||||
class SubEventItemVariation(models.Model):
|
||||
"""
|
||||
This model can be used to change the price of a product variation for a single
|
||||
subevent (i.e. a date in an event series).
|
||||
|
||||
:param subevent: The date this belongs to
|
||||
:type subevent: SubEvent
|
||||
:param variation: The variation to modify the price for
|
||||
:type variation: ItemVariation
|
||||
:param price: The modified price (or ``None`` for the original price)
|
||||
:type price: Decimal
|
||||
"""
|
||||
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
|
||||
variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE)
|
||||
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
|
||||
|
||||
|
||||
class Item(LoggedModel):
|
||||
"""
|
||||
An item is a thing which can be sold. It belongs to an event and may or may not belong to a category.
|
||||
@@ -271,7 +305,7 @@ class Item(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None):
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None):
|
||||
"""
|
||||
This method is used to determine whether this Item is currently available
|
||||
for sale.
|
||||
@@ -285,12 +319,18 @@ class Item(LoggedModel):
|
||||
:raises ValueError: if you call this on an item which has variations associated with it.
|
||||
Please use the method on the ItemVariation object you are interested in.
|
||||
"""
|
||||
check_quotas = set(self.quotas.all())
|
||||
check_quotas = set(getattr(
|
||||
self, '_subevent_quotas', # Utilize cache in product list
|
||||
self.quotas.select_related('subevent').filter(subevent=subevent)
|
||||
if subevent else self.quotas.all()
|
||||
))
|
||||
if not subevent and self.event.has_subevents:
|
||||
raise TypeError('You need to supply a subevent.')
|
||||
if ignored_quotas:
|
||||
check_quotas -= set(ignored_quotas)
|
||||
if not check_quotas:
|
||||
return Quota.AVAILABILITY_OK, sys.maxsize
|
||||
if self.variations.count() > 0: # NOQA
|
||||
if self.has_variations: # NOQA
|
||||
raise ValueError('Do not call this directly on items which have variations '
|
||||
'but call this on their ItemVariation objects')
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
@@ -371,7 +411,7 @@ class ItemVariation(models.Model):
|
||||
if self.item:
|
||||
self.item.event.get_cache().clear()
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]:
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None) -> Tuple[int, int]:
|
||||
"""
|
||||
This method is used to determine whether this ItemVariation is currently
|
||||
available for sale in terms of quotas.
|
||||
@@ -383,9 +423,15 @@ class ItemVariation(models.Model):
|
||||
:param count_waitinglist: If ``False``, waiting list entries will be ignored for quota calculation.
|
||||
:returns: any of the return codes of :py:meth:`Quota.availability()`.
|
||||
"""
|
||||
check_quotas = set(self.quotas.all())
|
||||
check_quotas = set(getattr(
|
||||
self, '_subevent_quotas', # Utilize cache in product list
|
||||
self.quotas.filter(subevent=subevent).select_related('subevent')
|
||||
if subevent else self.quotas.all()
|
||||
))
|
||||
if ignored_quotas:
|
||||
check_quotas -= set(ignored_quotas)
|
||||
if not subevent and self.item.event.has_subevents: # NOQA
|
||||
raise TypeError('You need to supply a subevent.')
|
||||
if not check_quotas:
|
||||
return Quota.AVAILABILITY_OK, sys.maxsize
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
@@ -402,6 +448,17 @@ class ItemAddOn(models.Model):
|
||||
An instance of this model indicates that buying a ticket of the time ``base_item``
|
||||
allows you to add up to ``max_count`` items from the category ``addon_category``
|
||||
to your order that will be associated with the base item.
|
||||
|
||||
:param base_item: The base item the add-ons are attached to
|
||||
:type base_item: Item
|
||||
:param addon_category: The category the add-on can be chosen from
|
||||
:type addon_category: ItemCategory
|
||||
:param min_count: The minimal number of add-ons to be chosen
|
||||
:type min_count: int
|
||||
:param max_count: The maximal number of add-ons to be chosen
|
||||
:type max_count: int
|
||||
:param position: An integer used for sorting
|
||||
:type position: int
|
||||
"""
|
||||
base_item = models.ForeignKey(
|
||||
Item,
|
||||
@@ -420,6 +477,12 @@ class ItemAddOn(models.Model):
|
||||
default=1,
|
||||
verbose_name=_('Maximum number')
|
||||
)
|
||||
price_included = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Add-Ons are included in the price'),
|
||||
help_text=_('If selected, adding add-ons to this ticket is free, even if the add-ons would normally cost '
|
||||
'money individually.')
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
@@ -446,6 +509,7 @@ class Question(LoggedModel):
|
||||
* a multi-line string (``TYPE_TEXT``)
|
||||
* a boolean (``TYPE_BOOLEAN``)
|
||||
* a multiple choice option (``TYPE_CHOICE`` and ``TYPE_CHOICE_MULTIPLE``)
|
||||
* a file upload (``TYPE_FILE``))
|
||||
|
||||
:param event: The event this question belongs to
|
||||
:type event: Event
|
||||
@@ -463,13 +527,15 @@ class Question(LoggedModel):
|
||||
TYPE_BOOLEAN = "B"
|
||||
TYPE_CHOICE = "C"
|
||||
TYPE_CHOICE_MULTIPLE = "M"
|
||||
TYPE_FILE = "F"
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_NUMBER, _("Number")),
|
||||
(TYPE_STRING, _("Text (one line)")),
|
||||
(TYPE_TEXT, _("Multiline text")),
|
||||
(TYPE_BOOLEAN, _("Yes/No")),
|
||||
(TYPE_CHOICE, _("Choose one from a list")),
|
||||
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list"))
|
||||
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list")),
|
||||
(TYPE_FILE, _("File upload")),
|
||||
)
|
||||
|
||||
event = models.ForeignKey(
|
||||
@@ -571,6 +637,8 @@ class Quota(LoggedModel):
|
||||
|
||||
:param event: The event this belongs to
|
||||
:type event: Event
|
||||
:param subevent: The event series date this belongs to, if event series are enabled
|
||||
:type subevent: SubEvent
|
||||
:param name: This quota's name
|
||||
:type name: str
|
||||
:param size: The number of items in this quota
|
||||
@@ -590,6 +658,13 @@ class Quota(LoggedModel):
|
||||
related_name="quotas",
|
||||
verbose_name=_("Event"),
|
||||
)
|
||||
subevent = models.ForeignKey(
|
||||
SubEvent,
|
||||
null=True, blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="quotas",
|
||||
verbose_name=pgettext_lazy('subevent', "Date"),
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=200,
|
||||
verbose_name=_("Name")
|
||||
@@ -684,11 +759,11 @@ class Quota(LoggedModel):
|
||||
now_dt = now_dt or now()
|
||||
if 'sqlite3' in settings.DATABASES['default']['ENGINE']:
|
||||
func = 'MAX'
|
||||
else:
|
||||
else: # NOQA
|
||||
func = 'GREATEST'
|
||||
|
||||
return Voucher.objects.filter(
|
||||
Q(event=self.event) &
|
||||
Q(event=self.event) & Q(subevent=self.subevent) &
|
||||
Q(block_quota=True) &
|
||||
Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) &
|
||||
Q(Q(self._position_lookup) | Q(quota=self))
|
||||
@@ -699,7 +774,7 @@ class Quota(LoggedModel):
|
||||
def count_waiting_list_pending(self) -> int:
|
||||
from pretix.base.models import WaitingListEntry
|
||||
return WaitingListEntry.objects.filter(
|
||||
Q(voucher__isnull=True) &
|
||||
Q(voucher__isnull=True) & Q(subevent=self.subevent) &
|
||||
self._position_lookup
|
||||
).distinct().count()
|
||||
|
||||
@@ -708,7 +783,7 @@ class Quota(LoggedModel):
|
||||
|
||||
now_dt = now_dt or now()
|
||||
return CartPosition.objects.filter(
|
||||
Q(event=self.event) &
|
||||
Q(event=self.event) & Q(subevent=self.subevent) &
|
||||
Q(expires__gte=now_dt) &
|
||||
~Q(
|
||||
Q(voucher__isnull=False) & Q(voucher__block_quota=True)
|
||||
@@ -722,14 +797,14 @@ class Quota(LoggedModel):
|
||||
|
||||
# This query has beeen benchmarked against a Count('id', distinct=True) aggregate and won by a small margin.
|
||||
return OrderPosition.objects.filter(
|
||||
self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event
|
||||
self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event, subevent=self.subevent
|
||||
).values('id').distinct().count()
|
||||
|
||||
def count_paid_orders(self):
|
||||
from pretix.base.models import Order, OrderPosition
|
||||
|
||||
return OrderPosition.objects.filter(
|
||||
self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event
|
||||
self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event, subevent=self.subevent
|
||||
).values('id').distinct().count()
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -5,7 +5,10 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.models.event import SubEvent
|
||||
|
||||
|
||||
class LogEntry(models.Model):
|
||||
@@ -66,7 +69,7 @@ class LogEntry(models.Model):
|
||||
'organizer': self.event.organizer.slug,
|
||||
'code': co.code
|
||||
}),
|
||||
'val': co.code,
|
||||
'val': escape(co.code),
|
||||
}
|
||||
elif isinstance(co, Voucher):
|
||||
a_text = _('Voucher {val}…')
|
||||
@@ -76,7 +79,7 @@ class LogEntry(models.Model):
|
||||
'organizer': self.event.organizer.slug,
|
||||
'voucher': co.id
|
||||
}),
|
||||
'val': co.code[:6],
|
||||
'val': escape(co.code[:6]),
|
||||
}
|
||||
elif isinstance(co, Item):
|
||||
a_text = _('Product {val}')
|
||||
@@ -86,7 +89,17 @@ class LogEntry(models.Model):
|
||||
'organizer': self.event.organizer.slug,
|
||||
'item': co.id
|
||||
}),
|
||||
'val': co.name,
|
||||
'val': escape(co.name),
|
||||
}
|
||||
elif isinstance(co, SubEvent):
|
||||
a_text = pgettext_lazy('subevent', 'Date {val}')
|
||||
a_map = {
|
||||
'href': reverse('control:event.subevent', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
'subevent': co.id
|
||||
}),
|
||||
'val': escape(str(co))
|
||||
}
|
||||
elif isinstance(co, Quota):
|
||||
a_text = _('Quota {val}')
|
||||
@@ -96,7 +109,7 @@ class LogEntry(models.Model):
|
||||
'organizer': self.event.organizer.slug,
|
||||
'quota': co.id
|
||||
}),
|
||||
'val': co.name,
|
||||
'val': escape(co.name),
|
||||
}
|
||||
elif isinstance(co, ItemCategory):
|
||||
a_text = _('Category {val}')
|
||||
@@ -106,7 +119,7 @@ class LogEntry(models.Model):
|
||||
'organizer': self.event.organizer.slug,
|
||||
'category': co.id
|
||||
}),
|
||||
'val': co.name,
|
||||
'val': escape(co.name),
|
||||
}
|
||||
elif isinstance(co, Question):
|
||||
a_text = _('Question {val}')
|
||||
@@ -116,7 +129,7 @@ class LogEntry(models.Model):
|
||||
'organizer': self.event.organizer.slug,
|
||||
'question': co.id
|
||||
}),
|
||||
'val': co.question,
|
||||
'val': escape(co.question),
|
||||
}
|
||||
|
||||
if a_text and a_map:
|
||||
|
||||
@@ -2,23 +2,32 @@ import copy
|
||||
import json
|
||||
import os
|
||||
import string
|
||||
from datetime import datetime
|
||||
from datetime import datetime, time
|
||||
from decimal import Decimal
|
||||
from typing import List, Union
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import F, Sum
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
|
||||
from ..decimal import round_decimal
|
||||
from .base import LoggedModel
|
||||
from .event import Event
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
||||
|
||||
|
||||
@@ -264,7 +273,16 @@ class Order(LoggedModel):
|
||||
"""
|
||||
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED):
|
||||
return False
|
||||
modify_deadline = self.event.settings.get('last_order_modification_date', as_type=datetime)
|
||||
|
||||
modify_deadline = self.event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper)
|
||||
if self.event.has_subevents and modify_deadline:
|
||||
modify_deadline = min([
|
||||
modify_deadline.datetime(se)
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
])
|
||||
elif modify_deadline:
|
||||
modify_deadline = modify_deadline.datetime(self.event)
|
||||
|
||||
if modify_deadline is not None and now() > modify_deadline:
|
||||
return False
|
||||
if self.event.settings.get('invoice_address_asked', as_type=bool):
|
||||
@@ -278,6 +296,9 @@ class Order(LoggedModel):
|
||||
|
||||
@property
|
||||
def can_user_cancel(self) -> bool:
|
||||
"""
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
"""
|
||||
positions = self.positions.all().select_related('item')
|
||||
cancelable = all([op.item.allow_cancel for op in positions])
|
||||
return self.event.settings.cancel_allow_user and cancelable
|
||||
@@ -289,6 +310,41 @@ class Order(LoggedModel):
|
||||
and not self.event.settings.get('payment_term_expire_automatically')
|
||||
)
|
||||
|
||||
@property
|
||||
def ticket_download_date(self):
|
||||
"""
|
||||
Returns the first date the tickets for this order can be downloaded or ``None`` if there is no
|
||||
restriction.
|
||||
"""
|
||||
dl_date = self.event.settings.get('ticket_download_date', as_type=RelativeDateWrapper)
|
||||
if dl_date:
|
||||
if self.event.has_subevents:
|
||||
dl_date = min([
|
||||
dl_date.datetime(se)
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
])
|
||||
else:
|
||||
dl_date = dl_date.datetime(self.event)
|
||||
return dl_date
|
||||
|
||||
@property
|
||||
def payment_term_last(self):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||
if term_last:
|
||||
if self.event.has_subevents:
|
||||
term_last = min([
|
||||
term_last.datetime(se).date()
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
])
|
||||
else:
|
||||
term_last = term_last.datetime(self.event).date()
|
||||
term_last = make_aware(datetime.combine(
|
||||
term_last,
|
||||
time(hour=23, minute=59, second=59)
|
||||
), tz)
|
||||
return term_last
|
||||
|
||||
def _can_be_paid(self) -> Union[bool, str]:
|
||||
error_messages = {
|
||||
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
|
||||
@@ -296,9 +352,9 @@ class Order(LoggedModel):
|
||||
'late': _("The payment can not be accepted as it the order is expired and you configured that no late "
|
||||
"payments should be accepted in the payment settings."),
|
||||
}
|
||||
|
||||
if self.event.settings.get('payment_term_last'):
|
||||
if now() > self.event.payment_term_last:
|
||||
term_last = self.payment_term_last
|
||||
if term_last:
|
||||
if now() > term_last:
|
||||
return error_messages['late_lastdate']
|
||||
|
||||
if self.status == self.STATUS_PENDING:
|
||||
@@ -317,9 +373,11 @@ class Order(LoggedModel):
|
||||
quota_cache = {}
|
||||
try:
|
||||
for i, op in enumerate(positions):
|
||||
quotas = list(op.item.quotas.all()) if op.variation is None else list(op.variation.quotas.all())
|
||||
quotas = list(op.quotas)
|
||||
if len(quotas) == 0:
|
||||
raise Quota.QuotaExceededException(error_messages['unavailable'])
|
||||
raise Quota.QuotaExceededException(error_messages['unavailable'].format(
|
||||
item=str(op.item) + (' - ' + str(op.variation) if op.variation else '')
|
||||
))
|
||||
|
||||
for quota in quotas:
|
||||
if quota.id not in quota_cache:
|
||||
@@ -339,6 +397,59 @@ class Order(LoggedModel):
|
||||
return str(e)
|
||||
return True
|
||||
|
||||
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):
|
||||
"""
|
||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||
|
||||
* Call ``pretix.base.services.mail.mail`` with useful values for the ``event``, ``locale``, ``recipient`` and
|
||||
``order`` parameters.
|
||||
|
||||
* Create a ``LogEntry`` with the email contents.
|
||||
|
||||
:param subject: Subject of the email
|
||||
:param template: LazyI18nString or template filename, see ``pretix.base.services.mail.mail`` for more details
|
||||
:param context: Dictionary to use for rendering the template
|
||||
:param log_entry_type: Key to be used for the log entry
|
||||
:param user: Administrative user who triggered this mail to be sent
|
||||
:param headers: Dictionary with additional mail headers
|
||||
:param sender: Custom email sender.
|
||||
"""
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
|
||||
recipient = self.email
|
||||
email_content = render_mail(template, context)[0]
|
||||
try:
|
||||
with language(self.locale):
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers, sender
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
else:
|
||||
self.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'recipient': recipient
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def answerfile_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
|
||||
event = (instance.cartposition if instance.cartposition else instance.orderposition.order).event
|
||||
return 'cachedfiles/answers/{org}/{ev}/{secret}.{filename}'.format(
|
||||
org=event.organizer.slug,
|
||||
ev=event.slug,
|
||||
secret=secret,
|
||||
filename=escape_uri_path(filename),
|
||||
)
|
||||
|
||||
|
||||
class QuestionAnswer(models.Model):
|
||||
"""
|
||||
@@ -370,12 +481,52 @@ class QuestionAnswer(models.Model):
|
||||
QuestionOption, related_name='answers', blank=True
|
||||
)
|
||||
answer = models.TextField()
|
||||
file = models.FileField(
|
||||
null=True, blank=True, upload_to=answerfile_name
|
||||
)
|
||||
|
||||
@property
|
||||
def backend_file_url(self):
|
||||
if self.file:
|
||||
if self.orderposition:
|
||||
return reverse('control:event.order.download.answer', kwargs={
|
||||
'code': self.orderposition.order.code,
|
||||
'event': self.orderposition.order.event.slug,
|
||||
'organizer': self.orderposition.order.event.organizer.slug,
|
||||
'answer': self.pk,
|
||||
})
|
||||
return ""
|
||||
|
||||
@property
|
||||
def frontend_file_url(self):
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
|
||||
if self.file:
|
||||
if self.orderposition:
|
||||
url = eventreverse(self.orderposition.order.event, 'presale:event.order.download.answer', kwargs={
|
||||
'order': self.orderposition.order.code,
|
||||
'secret': self.orderposition.order.secret,
|
||||
'answer': self.pk,
|
||||
})
|
||||
else:
|
||||
url = eventreverse(self.cartposition.event, 'presale:event.cart.download.answer', kwargs={
|
||||
'answer': self.pk,
|
||||
})
|
||||
|
||||
return url
|
||||
return ""
|
||||
|
||||
@property
|
||||
def file_name(self):
|
||||
return self.file.name.split('.', 1)[-1]
|
||||
|
||||
def __str__(self):
|
||||
if self.question.type == Question.TYPE_BOOLEAN and self.answer == "True":
|
||||
return str(_("Yes"))
|
||||
elif self.question.type == Question.TYPE_BOOLEAN and self.answer == "False":
|
||||
return str(_("No"))
|
||||
elif self.question.type == Question.TYPE_FILE:
|
||||
return str(_("<file>"))
|
||||
else:
|
||||
return self.answer
|
||||
|
||||
@@ -389,6 +540,8 @@ class AbstractPosition(models.Model):
|
||||
"""
|
||||
A position can either be one line of an order or an item placed in a cart.
|
||||
|
||||
:param subevent: The date in the event series, if event series are enabled
|
||||
:type subevent: SubEvent
|
||||
:param item: The selected item
|
||||
:type item: Item
|
||||
:param variation: The selected ItemVariation or null, if the item has no variations
|
||||
@@ -405,7 +558,15 @@ class AbstractPosition(models.Model):
|
||||
:type attendee_email: str
|
||||
:param voucher: A voucher that has been applied to this sale
|
||||
:type voucher: Voucher
|
||||
:param meta_info: Additional meta information on the position, JSON-encoded.
|
||||
:type meta_info: str
|
||||
"""
|
||||
subevent = models.ForeignKey(
|
||||
SubEvent,
|
||||
null=True, blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=pgettext_lazy("subevent", "Date"),
|
||||
)
|
||||
item = models.ForeignKey(
|
||||
Item,
|
||||
verbose_name=_("Item"),
|
||||
@@ -438,10 +599,21 @@ class AbstractPosition(models.Model):
|
||||
addon_to = models.ForeignKey(
|
||||
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
|
||||
)
|
||||
meta_info = models.TextField(
|
||||
verbose_name=_("Meta information"),
|
||||
null=True, blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def meta_info_data(self):
|
||||
if self.meta_info:
|
||||
return json.loads(self.meta_info)
|
||||
else:
|
||||
return {}
|
||||
|
||||
def cache_answers(self):
|
||||
"""
|
||||
Creates two properties on the object.
|
||||
@@ -466,6 +638,12 @@ class AbstractPosition(models.Model):
|
||||
def net_price(self):
|
||||
return self.price - self.tax_value
|
||||
|
||||
@property
|
||||
def quotas(self):
|
||||
return (self.item.quotas.filter(subevent=self.subevent)
|
||||
if self.variation is None
|
||||
else self.variation.quotas.filter(subevent=self.subevent))
|
||||
|
||||
|
||||
class OrderPosition(AbstractPosition):
|
||||
"""
|
||||
@@ -614,14 +792,17 @@ class CartPosition(AbstractPosition):
|
||||
|
||||
class InvoiceAddress(models.Model):
|
||||
last_modified = models.DateTimeField(auto_now=True)
|
||||
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
|
||||
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address')
|
||||
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
|
||||
name = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||
street = models.TextField(verbose_name=_('Address'), blank=False)
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
|
||||
country = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
|
||||
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'))
|
||||
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
|
||||
country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'))
|
||||
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'),
|
||||
help_text=_('Only for business customers within the EU.'))
|
||||
|
||||
|
||||
def cachedticket_name(instance, filename: str) -> str:
|
||||
@@ -671,3 +852,17 @@ def cachedticket_delete(sender, instance, **kwargs):
|
||||
if instance.file:
|
||||
# Pass false so FileField doesn't save the model.
|
||||
instance.file.delete(False)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=CachedCombinedTicket)
|
||||
def cachedcombinedticket_delete(sender, instance, **kwargs):
|
||||
if instance.file:
|
||||
# Pass false so FileField doesn't save the model.
|
||||
instance.file.delete(False)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=QuestionAnswer)
|
||||
def answer_delete(sender, instance, **kwargs):
|
||||
if instance.file:
|
||||
# Pass false so FileField doesn't save the model.
|
||||
instance.file.delete(False)
|
||||
|
||||
@@ -5,11 +5,11 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from ..decimal import round_decimal
|
||||
from .base import LoggedModel
|
||||
from .event import Event
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Quota
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ class Voucher(LoggedModel):
|
||||
|
||||
:param event: The event this voucher is valid for
|
||||
:type event: Event
|
||||
:param subevent: The date in the event series, if event series are enabled
|
||||
:type subevent: SubEvent
|
||||
:param code: The secret voucher code
|
||||
:type code: str
|
||||
:param max_usages: The number of times this voucher can be redeemed
|
||||
@@ -80,6 +82,12 @@ class Voucher(LoggedModel):
|
||||
related_name="vouchers",
|
||||
verbose_name=_("Event"),
|
||||
)
|
||||
subevent = models.ForeignKey(
|
||||
SubEvent,
|
||||
null=True, blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=pgettext_lazy("subevent", "Date"),
|
||||
)
|
||||
code = models.CharField(
|
||||
verbose_name=_("Voucher code"),
|
||||
max_length=255, default=generate_code,
|
||||
@@ -186,6 +194,8 @@ class Voucher(LoggedModel):
|
||||
'Otherwise it might be unclear which quotas to block.'))
|
||||
else:
|
||||
raise ValidationError(_('You need to specify either a quota or a product.'))
|
||||
if self.event.has_subevents and self.block_quota and not self.subevent:
|
||||
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.code = self.code.upper()
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import timedelta
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Voucher
|
||||
@@ -11,7 +11,7 @@ from pretix.base.services.mail import mail
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
from .base import LoggedModel
|
||||
from .event import Event
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ class WaitingListEntry(LoggedModel):
|
||||
related_name="waitinglistentries",
|
||||
verbose_name=_("Event"),
|
||||
)
|
||||
subevent = models.ForeignKey(
|
||||
SubEvent,
|
||||
null=True, blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=pgettext_lazy("subevent", "Date"),
|
||||
)
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_("On waiting list since"),
|
||||
auto_now_add=True
|
||||
@@ -77,9 +83,9 @@ class WaitingListEntry(LoggedModel):
|
||||
|
||||
def send_voucher(self, quota_cache=None, user=None):
|
||||
availability = (
|
||||
self.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
self.variation.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache)
|
||||
if self.variation
|
||||
else self.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
else self.item.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache)
|
||||
)
|
||||
if availability[1] < 1:
|
||||
raise WaitingListException(_('This product is currently not available.'))
|
||||
@@ -98,6 +104,7 @@ class WaitingListEntry(LoggedModel):
|
||||
email=self.email
|
||||
),
|
||||
block_quota=True,
|
||||
subevent=self.subevent,
|
||||
)
|
||||
v.log_action('pretix.voucher.added.waitinglist', {
|
||||
'item': self.item.pk,
|
||||
@@ -107,7 +114,8 @@ class WaitingListEntry(LoggedModel):
|
||||
'valid_until': v.valid_until.isoformat(),
|
||||
'max_usages': 1,
|
||||
'email': self.email,
|
||||
'waitinglistentry': self.pk
|
||||
'waitinglistentry': self.pk,
|
||||
'subevent': self.subevent.pk if self.subevent else None,
|
||||
}, user=user)
|
||||
self.log_action('pretix.waitinglist.voucher', user=user)
|
||||
self.voucher = v
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
@@ -16,11 +16,14 @@ from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import Event, Order, Quota
|
||||
from pretix.base.models import CartPosition, Event, Order, Quota
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.presale.views import get_cart_total
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentProviderForm(Form):
|
||||
def clean(self):
|
||||
@@ -95,7 +98,7 @@ class BasePaymentProvider:
|
||||
"""
|
||||
A short and unique identifier for this payment provider.
|
||||
This should only contain lowercase letters and in most
|
||||
cases will be the same as your packagename.
|
||||
cases will be the same as your package name.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@@ -150,18 +153,17 @@ class BasePaymentProvider:
|
||||
required=False
|
||||
)),
|
||||
('_availability_date',
|
||||
forms.DateField(
|
||||
RelativeDateField(
|
||||
label=_('Available until'),
|
||||
help_text=_('Users will not be able to choose this payment provider after the given date.'),
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'})
|
||||
)),
|
||||
('_fee_reverse_calc',
|
||||
forms.BooleanField(
|
||||
label=_('Calculate the fee from the total value including the fee.'),
|
||||
help_text=_('We recommend you to enable this if you want your users to pay the payment fees of your '
|
||||
help_text=_('We recommend to enable this if you want your users to pay the payment fees of your '
|
||||
'payment provider. <a href="{docs_url}" target="_blank">Click here '
|
||||
'for detailled information on what this does.</a> Don\'t forget to set the correct fees '
|
||||
'for detailed information on what this does.</a> Don\'t forget to set the correct fees '
|
||||
'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'),
|
||||
required=False
|
||||
)),
|
||||
@@ -230,12 +232,36 @@ class BasePaymentProvider:
|
||||
|
||||
return form
|
||||
|
||||
def _is_still_available(self, now_dt=None):
|
||||
def _is_still_available(self, now_dt=None, cart_id=None, order=None):
|
||||
now_dt = now_dt or now()
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
availability_date = self.settings.get('_availability_date', as_type=date)
|
||||
|
||||
availability_date = self.settings.get('_availability_date', as_type=RelativeDateWrapper)
|
||||
if availability_date:
|
||||
if self.event.has_subevents and cart_id:
|
||||
availability_date = min([
|
||||
availability_date.datetime(se).date()
|
||||
for se in self.event.subevents.filter(
|
||||
id__in=CartPosition.objects.filter(
|
||||
cart_id=cart_id, event=self.event
|
||||
).values_list('subevent', flat=True)
|
||||
)
|
||||
])
|
||||
elif self.event.has_subevents and order:
|
||||
availability_date = min([
|
||||
availability_date.datetime(se).date()
|
||||
for se in self.event.subevents.filter(
|
||||
id__in=order.positions.values_list('subevent', flat=True)
|
||||
)
|
||||
])
|
||||
elif self.event.has_subevents:
|
||||
logger.error('Payment provider is not subevent-ready.')
|
||||
return False
|
||||
else:
|
||||
availability_date = availability_date.datetime(self.event).date()
|
||||
|
||||
return availability_date >= now_dt.astimezone(tz).date()
|
||||
|
||||
return True
|
||||
|
||||
def is_allowed(self, request: HttpRequest) -> bool:
|
||||
@@ -247,11 +273,11 @@ class BasePaymentProvider:
|
||||
|
||||
The default implementation checks for the _availability_date setting to be either unset or in the future.
|
||||
"""
|
||||
return self._is_still_available()
|
||||
return self._is_still_available(cart_id=request.session.session_key)
|
||||
|
||||
def payment_form_render(self, request: HttpRequest) -> str:
|
||||
"""
|
||||
When the user selects this provider as his prefered payment method,
|
||||
When the user selects this provider as his preferred payment method,
|
||||
they will be shown the HTML you return from this method.
|
||||
|
||||
The default implementation will call :py:meth:`checkout_form`
|
||||
@@ -385,7 +411,7 @@ class BasePaymentProvider:
|
||||
|
||||
:param order: The order object
|
||||
"""
|
||||
return self._is_still_available()
|
||||
return self._is_still_available(order=order)
|
||||
|
||||
def order_can_retry(self, order: Order) -> bool:
|
||||
"""
|
||||
@@ -458,7 +484,7 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return _('Payment provider: %s' % self.verbose_name)
|
||||
|
||||
def order_control_refund_render(self, order: Order) -> str:
|
||||
def order_control_refund_render(self, order: Order, request: HttpRequest=None) -> str:
|
||||
"""
|
||||
Will be called if the event administrator clicks an order's 'refund' button.
|
||||
This can be used to display information *before* the order is being refunded.
|
||||
@@ -468,6 +494,11 @@ class BasePaymentProvider:
|
||||
automatically.
|
||||
|
||||
:param order: The order object
|
||||
:param request: The HTTP request
|
||||
|
||||
.. versionchanged:: 1.6
|
||||
|
||||
The parameter ``request`` has been added.
|
||||
"""
|
||||
return '<div class="alert alert-warning">%s</div>' % _('The money can not be automatically refunded, '
|
||||
'please transfer the money back manually.')
|
||||
|
||||
279
src/pretix/base/reldate.py
Normal file
279
src/pretix/base/reldate.py
Normal file
@@ -0,0 +1,279 @@
|
||||
import datetime
|
||||
from collections import namedtuple
|
||||
from typing import Union
|
||||
|
||||
import pytz
|
||||
from dateutil import parser
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
BASE_CHOICES = (
|
||||
('date_from', _('Event start')),
|
||||
('date_to', _('Event end')),
|
||||
('date_admission', _('Event admission')),
|
||||
('presale_start', _('Presale start')),
|
||||
('presale_end', _('Presale end')),
|
||||
)
|
||||
|
||||
RelativeDate = namedtuple('RelativeDate', ['days_before', 'time', 'base_date_name'])
|
||||
|
||||
|
||||
class RelativeDateWrapper:
|
||||
"""
|
||||
This contains information on a date that might be relative to an event. This means
|
||||
that the underlying data is either a fixed date or a number of days and a wall clock
|
||||
time to calculate the date based on a base point.
|
||||
|
||||
The base point can be the date_from, date_to, date_admission, presale_start or presale_end
|
||||
attribute of an event or subevent. If the respective attribute is not set, ``date_from``
|
||||
will be used.
|
||||
"""
|
||||
|
||||
def __init__(self, data: Union[datetime.datetime, RelativeDate]):
|
||||
self.data = data
|
||||
|
||||
def date(self, event) -> datetime.datetime:
|
||||
from .models import SubEvent
|
||||
|
||||
if isinstance(self.data, datetime.date):
|
||||
return self.data
|
||||
elif isinstance(self.data, datetime.datetime):
|
||||
return self.data.date()
|
||||
else:
|
||||
tz = pytz.timezone(event.settings.timezone)
|
||||
if isinstance(event, SubEvent):
|
||||
base_date = (
|
||||
getattr(event, self.data.base_date_name)
|
||||
or getattr(event.event, self.data.base_date_name)
|
||||
or event.date_from
|
||||
)
|
||||
else:
|
||||
base_date = getattr(event, self.data.base_date_name) or event.date_from
|
||||
|
||||
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before)
|
||||
return new_date.date()
|
||||
|
||||
def datetime(self, event) -> datetime.datetime:
|
||||
from .models import SubEvent
|
||||
|
||||
if isinstance(self.data, (datetime.datetime, datetime.date)):
|
||||
return self.data
|
||||
else:
|
||||
tz = pytz.timezone(event.settings.timezone)
|
||||
if isinstance(event, SubEvent):
|
||||
base_date = (
|
||||
getattr(event, self.data.base_date_name)
|
||||
or getattr(event.event, self.data.base_date_name)
|
||||
or event.date_from
|
||||
)
|
||||
else:
|
||||
base_date = getattr(event, self.data.base_date_name) or event.date_from
|
||||
|
||||
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before)
|
||||
if self.data.time:
|
||||
new_date = new_date.replace(
|
||||
hour=self.data.time.hour,
|
||||
minute=self.data.time.minute,
|
||||
second=self.data.time.second
|
||||
)
|
||||
return new_date
|
||||
|
||||
def to_string(self) -> str:
|
||||
if isinstance(self.data, (datetime.datetime, datetime.date)):
|
||||
return self.data.isoformat()
|
||||
else:
|
||||
return 'RELDATE/{}/{}/{}/'.format( #
|
||||
self.data.days_before,
|
||||
self.data.time.strftime('%H:%M:%S') if self.data.time else '-',
|
||||
self.data.base_date_name
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, input: str):
|
||||
if input.startswith('RELDATE/'):
|
||||
parts = input.split('/')
|
||||
if parts[2] == '-':
|
||||
time = None
|
||||
else:
|
||||
timeparts = parts[2].split(':')
|
||||
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
|
||||
data = RelativeDate(
|
||||
days_before=int(parts[1]),
|
||||
base_date_name=parts[3],
|
||||
time=time
|
||||
)
|
||||
else:
|
||||
data = parser.parse(input)
|
||||
return RelativeDateWrapper(data)
|
||||
|
||||
|
||||
class RelativeDateTimeWidget(forms.MultiWidget):
|
||||
template_name = 'pretixbase/forms/widgets/reldatetime.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.status_choices = kwargs.pop('status_choices')
|
||||
widgets = (
|
||||
forms.RadioSelect(choices=self.status_choices),
|
||||
forms.DateTimeInput(
|
||||
attrs={'class': 'datetimepicker'}
|
||||
),
|
||||
forms.NumberInput(),
|
||||
forms.Select(choices=kwargs.pop('base_choices')),
|
||||
forms.TimeInput(attrs={'placeholder': _('Time'), 'class': 'timepickerfield'})
|
||||
)
|
||||
super().__init__(widgets=widgets, *args, **kwargs)
|
||||
|
||||
def decompress(self, value):
|
||||
if isinstance(value, str):
|
||||
value = RelativeDateWrapper.from_string(value)
|
||||
if not value:
|
||||
return ['unset', None, 1, 'date_from', None]
|
||||
elif isinstance(value.data, (datetime.datetime, datetime.date)):
|
||||
return ['absolute', value.data, 1, 'date_from', None]
|
||||
return ['relative', None, value.data.days_before, value.data.base_date_name, value.data.time]
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
ctx = super().get_context(name, value, attrs)
|
||||
ctx['required'] = self.status_choices[0][0] == 'unset'
|
||||
return ctx
|
||||
|
||||
|
||||
class RelativeDateTimeField(forms.MultiValueField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
status_choices = [
|
||||
('absolute', _('Fixed date:')),
|
||||
('relative', _('Relative date:')),
|
||||
]
|
||||
if not kwargs.get('required', True):
|
||||
status_choices.insert(0, ('unset', _('Not set')))
|
||||
fields = (
|
||||
forms.ChoiceField(
|
||||
choices=status_choices,
|
||||
required=True
|
||||
),
|
||||
forms.DateTimeField(
|
||||
required=False
|
||||
),
|
||||
forms.IntegerField(
|
||||
required=False
|
||||
),
|
||||
forms.ChoiceField(
|
||||
choices=BASE_CHOICES,
|
||||
required=False
|
||||
),
|
||||
forms.TimeField(
|
||||
required=False,
|
||||
),
|
||||
)
|
||||
if 'widget' not in kwargs:
|
||||
kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=BASE_CHOICES)
|
||||
super().__init__(
|
||||
fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
|
||||
def set_event(self, event):
|
||||
self.widget.widgets[3].choices = [
|
||||
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
|
||||
]
|
||||
|
||||
def compress(self, data_list):
|
||||
if not data_list:
|
||||
return None
|
||||
if data_list[0] == 'absolute':
|
||||
return RelativeDateWrapper(data_list[1])
|
||||
elif data_list[0] == 'unset':
|
||||
return None
|
||||
else:
|
||||
return RelativeDateWrapper(RelativeDate(
|
||||
days_before=data_list[2],
|
||||
base_date_name=data_list[3],
|
||||
time=data_list[4]
|
||||
))
|
||||
|
||||
def clean(self, value):
|
||||
if value[0] == 'absolute' and not value[1]:
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
elif value[0] == 'relative' and (value[2] is None or not value[3]):
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
|
||||
return super().clean(value)
|
||||
|
||||
|
||||
class RelativeDateWidget(RelativeDateTimeWidget):
|
||||
template_name = 'pretixbase/forms/widgets/reldate.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.status_choices = kwargs.pop('status_choices')
|
||||
widgets = (
|
||||
forms.RadioSelect(choices=self.status_choices),
|
||||
forms.DateInput(
|
||||
attrs={'class': 'datepickerfield'}
|
||||
),
|
||||
forms.NumberInput(),
|
||||
forms.Select(choices=kwargs.pop('base_choices')),
|
||||
)
|
||||
forms.MultiWidget.__init__(self, widgets=widgets, *args, **kwargs)
|
||||
|
||||
def decompress(self, value):
|
||||
if isinstance(value, str):
|
||||
value = RelativeDateWrapper.from_string(value)
|
||||
if not value:
|
||||
return ['unset', None, 1, 'date_from']
|
||||
elif isinstance(value.data, (datetime.datetime, datetime.date)):
|
||||
return ['absolute', value.data, 1, 'date_from']
|
||||
return ['relative', None, value.data.days_before, value.data.base_date_name]
|
||||
|
||||
|
||||
class RelativeDateField(RelativeDateTimeField):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
status_choices = [
|
||||
('absolute', _('Fixed date:')),
|
||||
('relative', _('Relative date:')),
|
||||
]
|
||||
if not kwargs.get('required', True):
|
||||
status_choices.insert(0, ('unset', _('Not set')))
|
||||
fields = (
|
||||
forms.ChoiceField(
|
||||
choices=status_choices,
|
||||
required=True
|
||||
),
|
||||
forms.DateField(
|
||||
required=False
|
||||
),
|
||||
forms.IntegerField(
|
||||
required=False
|
||||
),
|
||||
forms.ChoiceField(
|
||||
choices=BASE_CHOICES,
|
||||
required=False
|
||||
),
|
||||
)
|
||||
if 'widget' not in kwargs:
|
||||
kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=BASE_CHOICES)
|
||||
forms.MultiValueField.__init__(
|
||||
self, fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
|
||||
def compress(self, data_list):
|
||||
if not data_list:
|
||||
return None
|
||||
if data_list[0] == 'absolute':
|
||||
return RelativeDateWrapper(data_list[1])
|
||||
elif data_list[0] == 'unset':
|
||||
return None
|
||||
else:
|
||||
return RelativeDateWrapper(RelativeDate(
|
||||
days_before=data_list[2],
|
||||
base_date_name=data_list[3],
|
||||
time=None
|
||||
))
|
||||
|
||||
def clean(self, value):
|
||||
if value[0] == 'absolute' and not value[1]:
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
elif value[0] == 'relative' and (value[2] is None or not value[3]):
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
|
||||
return super().clean(value)
|
||||
@@ -7,15 +7,16 @@ from celery.exceptions import MaxRetriesExceededError
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import pgettext_lazy, ugettext as _
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, Item, ItemVariation, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
@@ -28,6 +29,7 @@ error_messages = {
|
||||
'server was too busy. Please try again.'),
|
||||
'empty': _('You did not select any products.'),
|
||||
'unknown_position': _('Unknown cart position.'),
|
||||
'subevent_required': pgettext_lazy('subevent', 'No date was specified.'),
|
||||
'not_for_sale': _('You selected a product which is not available for sale.'),
|
||||
'unavailable': _('Some of the products you selected are no longer available. '
|
||||
'Please see below for details.'),
|
||||
@@ -39,7 +41,11 @@ error_messages = {
|
||||
'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than "
|
||||
"%(min)s items of it."),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
'ended': _('The presale period has ended.'),
|
||||
'ended': _('The presale period for this event has ended.'),
|
||||
'some_subevent_not_started': _('The presale period for this event has not yet started. The affected positions '
|
||||
'have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'price_too_high': _('The entered price is to high.'),
|
||||
'voucher_invalid': _('This voucher code is not known in our database.'),
|
||||
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
|
||||
@@ -48,7 +54,9 @@ error_messages = {
|
||||
'cart if you want to use it for a different product.'),
|
||||
'voucher_expired': _('This voucher is expired.'),
|
||||
'voucher_invalid_item': _('This voucher is not valid for this product.'),
|
||||
'voucher_invalid_subevent': pgettext_lazy('subevent', 'This voucher is not valid for this event date.'),
|
||||
'voucher_required': _('You need a valid voucher code to order this product.'),
|
||||
'inactive_subevent': pgettext_lazy('subevent', 'The selected event date is not active.'),
|
||||
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
|
||||
@@ -60,10 +68,10 @@ error_messages = {
|
||||
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
|
||||
'addon_to'))
|
||||
'addon_to', 'subevent'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
||||
'quotas'))
|
||||
'quotas', 'subevent'))
|
||||
order = {
|
||||
RemoveOperation: 10,
|
||||
ExtendOperation: 20,
|
||||
@@ -78,6 +86,7 @@ class CartManager:
|
||||
self._quota_diff = Counter()
|
||||
self._voucher_use_diff = Counter()
|
||||
self._items_cache = {}
|
||||
self._subevents_cache = {}
|
||||
self._variations_cache = {}
|
||||
self._expiry = None
|
||||
|
||||
@@ -85,7 +94,7 @@ class CartManager:
|
||||
def positions(self):
|
||||
return CartPosition.objects.filter(
|
||||
Q(cart_id=self.cart_id) & Q(event=self.event)
|
||||
).select_related('item')
|
||||
).select_related('item', 'subevent')
|
||||
|
||||
def _calculate_expiry(self):
|
||||
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
||||
@@ -101,31 +110,41 @@ class CartManager:
|
||||
# We can extend the reservation of items which are not yet expired without risk
|
||||
self.positions.filter(expires__gt=self.now_dt).update(expires=self._expiry)
|
||||
|
||||
def _delete_expired(self, expired: List[CartPosition]):
|
||||
for cp in expired:
|
||||
if cp.expires <= self.now_dt:
|
||||
def _delete_out_of_timeframe(self):
|
||||
err = None
|
||||
for cp in self.positions:
|
||||
if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start:
|
||||
err = error_messages['some_subevent_not_started']
|
||||
cp.delete()
|
||||
|
||||
if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end:
|
||||
err = error_messages['some_subevent_ended']
|
||||
cp.delete()
|
||||
return err
|
||||
|
||||
def _update_subevents_cache(self, se_ids: List[int]):
|
||||
self._subevents_cache.update({
|
||||
i.pk: i
|
||||
for i in self.event.subevents.filter(id__in=[i for i in se_ids if i and i not in self._items_cache])
|
||||
})
|
||||
|
||||
def _update_items_cache(self, item_ids: List[int], variation_ids: List[int]):
|
||||
self._items_cache.update(
|
||||
{
|
||||
i.pk: i
|
||||
for i
|
||||
in self.event.items.select_related('category').prefetch_related(
|
||||
'addons', 'addons__addon_category', 'quotas'
|
||||
).filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)
|
||||
}
|
||||
)
|
||||
self._variations_cache.update(
|
||||
{v.pk: v for v in
|
||||
ItemVariation.objects.filter(item__event=self.event).prefetch_related(
|
||||
'quotas'
|
||||
).select_related('item', 'item__event').filter(
|
||||
id__in=[i for i in variation_ids if i and i not in self._variations_cache]
|
||||
)}
|
||||
)
|
||||
self._items_cache.update({
|
||||
i.pk: i
|
||||
for i in self.event.items.select_related('category').prefetch_related(
|
||||
'addons', 'addons__addon_category', 'quotas'
|
||||
).filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)
|
||||
})
|
||||
self._variations_cache.update({
|
||||
v.pk: v
|
||||
for v in ItemVariation.objects.filter(item__event=self.event).prefetch_related(
|
||||
'quotas'
|
||||
).select_related('item', 'item__event').filter(
|
||||
id__in=[i for i in variation_ids if i and i not in self._variations_cache]
|
||||
)
|
||||
})
|
||||
|
||||
def _check_max_cart_size(self):
|
||||
cartsize = self.positions.filter(addon_to__isnull=True).count()
|
||||
@@ -150,6 +169,18 @@ class CartManager:
|
||||
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
|
||||
if op.voucher and op.voucher.subevent_id and op.voucher.subevent_id != op.subevent.pk:
|
||||
raise CartError(error_messages['voucher_invalid_subevent'])
|
||||
|
||||
if op.subevent and not op.subevent.active:
|
||||
raise CartError(error_messages['inactive_subevent'])
|
||||
|
||||
if op.subevent and op.subevent.presale_start and self.now_dt < op.subevent.presale_start:
|
||||
raise CartError(error_messages['not_started'])
|
||||
|
||||
if op.subevent and op.subevent.presale_end and self.now_dt > op.subevent.presale_end:
|
||||
raise CartError(error_messages['ended'])
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.item.category and op.item.category.is_addon and not op.addon_to:
|
||||
raise CartError(error_messages['addon_only'])
|
||||
@@ -181,34 +212,24 @@ class CartManager:
|
||||
)
|
||||
|
||||
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
||||
voucher: Optional[Voucher], custom_price: Optional[Decimal]):
|
||||
price = item.default_price if variation is None else (
|
||||
variation.default_price if variation.default_price is not None else item.default_price
|
||||
)
|
||||
if voucher:
|
||||
price = voucher.calculate_price(price)
|
||||
|
||||
if item.free_price and custom_price is not None and custom_price != "":
|
||||
if not isinstance(custom_price, Decimal):
|
||||
custom_price = Decimal(custom_price.replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
raise CartError(error_messages['price_too_high'])
|
||||
if self.event.settings.display_net_prices:
|
||||
custom_price = round_decimal(custom_price * (100 + item.tax_rate) / 100)
|
||||
price = max(custom_price, price)
|
||||
|
||||
return price
|
||||
voucher: Optional[Voucher], custom_price: Optional[Decimal],
|
||||
subevent: Optional[SubEvent]):
|
||||
return get_price(item, variation, voucher, custom_price, subevent, self.event.settings.display_net_prices)
|
||||
|
||||
def extend_expired_positions(self):
|
||||
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
|
||||
'item', 'variation', 'voucher'
|
||||
).prefetch_related('item__quotas', 'variation__quotas')
|
||||
err = None
|
||||
for cp in expired:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price)
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent)
|
||||
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
quotas = list(cp.quotas)
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
continue
|
||||
err = error_messages['unavailable']
|
||||
|
||||
if not cp.voucher or (not cp.voucher.allow_ignore_quota and not cp.voucher.block_quota):
|
||||
for quota in quotas:
|
||||
self._quota_diff[quota] += 1
|
||||
@@ -217,7 +238,7 @@ class CartManager:
|
||||
|
||||
op = self.ExtendOperation(
|
||||
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
|
||||
price=price, quotas=quotas
|
||||
price=price, quotas=quotas, subevent=cp.subevent
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
|
||||
@@ -225,10 +246,12 @@ class CartManager:
|
||||
self._voucher_use_diff[cp.voucher] += 1
|
||||
|
||||
self._operations.append(op)
|
||||
return err
|
||||
|
||||
def add_new_items(self, items: List[dict]):
|
||||
# Fetch items from the database
|
||||
self._update_items_cache([i['item'] for i in items], [i['variation'] for i in items])
|
||||
self._update_subevents_cache([i['subevent'] for i in items if i.get('subevent')])
|
||||
quota_diff = Counter()
|
||||
voucher_use_diff = Counter()
|
||||
operations = []
|
||||
@@ -240,6 +263,13 @@ class CartManager:
|
||||
if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
if self.event.has_subevents:
|
||||
if not i.get('subevent'):
|
||||
raise CartError(error_messages['subevent_required'])
|
||||
subevent = self._subevents_cache[int(i.get('subevent'))]
|
||||
else:
|
||||
subevent = None
|
||||
|
||||
item = self._items_cache[i['item']]
|
||||
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
|
||||
voucher = None
|
||||
@@ -253,8 +283,8 @@ class CartManager:
|
||||
voucher_use_diff[voucher] += i['count']
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
|
||||
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
|
||||
quotas = list(item.quotas.filter(subevent=subevent)
|
||||
if variation is None else variation.quotas.filter(subevent=subevent))
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota):
|
||||
@@ -263,10 +293,10 @@ class CartManager:
|
||||
else:
|
||||
quotas = []
|
||||
|
||||
price = self._get_price(item, variation, voucher, i.get('price'))
|
||||
price = self._get_price(item, variation, voucher, i.get('price'), subevent)
|
||||
op = self.AddOperation(
|
||||
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
|
||||
addon_to=False
|
||||
addon_to=False, subevent=subevent
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
@@ -310,6 +340,7 @@ class CartManager:
|
||||
quota_diff = Counter() # Quota -> Number of usages
|
||||
operations = []
|
||||
available_categories = defaultdict(set) # CartPos -> Category IDs to choose from
|
||||
price_included = defaultdict(dict) # CartPos -> CategoryID -> bool(price is included)
|
||||
toplevel_cp = self.positions.filter(
|
||||
addon_to__isnull=True
|
||||
).prefetch_related(
|
||||
@@ -319,6 +350,7 @@ class CartManager:
|
||||
# Prefill some of the cache containers
|
||||
for cp in toplevel_cp:
|
||||
available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()}
|
||||
price_included[cp.pk] = {iao.addon_category_id: iao.price_included for iao in cp.item.addons.all()}
|
||||
cpcache[cp.pk] = cp
|
||||
current_addons[cp] = {
|
||||
(a.item_id, a.variation_id): a
|
||||
@@ -345,7 +377,8 @@ class CartManager:
|
||||
raise CartError(error_messages['addon_invalid_base'])
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
|
||||
quotas = list(item.quotas.filter(subevent=cp.subevent)
|
||||
if variation is None else variation.quotas.filter(subevent=cp.subevent))
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
@@ -361,11 +394,14 @@ class CartManager:
|
||||
for quota in quotas:
|
||||
quota_diff[quota] += 1
|
||||
|
||||
price = self._get_price(item, variation, None, None)
|
||||
if price_included[cp.pk].get(item.category_id):
|
||||
price = Decimal('0.00')
|
||||
else:
|
||||
price = self._get_price(item, variation, None, None, cp.subevent)
|
||||
|
||||
op = self.AddOperation(
|
||||
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
||||
addon_to=cp
|
||||
addon_to=cp, subevent=cp.subevent
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
@@ -403,7 +439,7 @@ class CartManager:
|
||||
for k, v in al.items():
|
||||
if k not in input_addons[cp.id]:
|
||||
if v.expires > self.now_dt:
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
quotas = list(cp.quotas)
|
||||
|
||||
for quota in quotas:
|
||||
quota_diff[quota] -= 1
|
||||
@@ -523,7 +559,8 @@ class CartManager:
|
||||
event=self.event, item=op.item, variation=op.variation,
|
||||
price=op.price, expires=self._expiry,
|
||||
cart_id=self.cart_id, voucher=op.voucher,
|
||||
addon_to=op.addon_to if op.addon_to else None
|
||||
addon_to=op.addon_to if op.addon_to else None,
|
||||
subevent=op.subevent
|
||||
))
|
||||
elif isinstance(op, self.ExtendOperation):
|
||||
if available_count == 1:
|
||||
@@ -547,8 +584,9 @@ class CartManager:
|
||||
with transaction.atomic():
|
||||
self.now_dt = now_dt
|
||||
self._extend_expiry_of_valid_existing_positions()
|
||||
self.extend_expired_positions()
|
||||
err = self._perform_operations()
|
||||
err = self._delete_out_of_timeframe()
|
||||
err = self.extend_expired_positions() or err
|
||||
err = self._perform_operations() or err
|
||||
if err:
|
||||
raise CartError(err)
|
||||
|
||||
@@ -559,8 +597,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
Adds a list of items to a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param items: A list of dicts with the keys item, variation, number, custom_price, voucher
|
||||
:param session: Session ID of a guest
|
||||
:param coupon: A coupon that should also be reeemed
|
||||
:param cart_id: Session ID of a guest
|
||||
:raises CartError: On any error that occured
|
||||
"""
|
||||
with language(locale):
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import copy
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from reportlab.lib import pagesizes
|
||||
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import (
|
||||
BaseDocTemplate, Frame, NextPageTemplate, PageTemplate, Paragraph, Spacer,
|
||||
Table, TableStyle,
|
||||
)
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order
|
||||
@@ -50,8 +37,11 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
{i.name}
|
||||
{i.street}
|
||||
{i.zipcode} {i.city}
|
||||
{i.country}""")
|
||||
invoice.invoice_to = addr_template.format(i=invoice.order.invoice_address).strip()
|
||||
{country}""")
|
||||
invoice.invoice_to = addr_template.format(
|
||||
i=invoice.order.invoice_address,
|
||||
country=invoice.order.invoice_address.country.name if invoice.order.invoice_address.country else invoice.order.invoice_address.country_old
|
||||
).strip()
|
||||
if invoice.order.invoice_address.vat_id:
|
||||
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % invoice.order.invoice_address.vat_id
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
@@ -61,9 +51,16 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.save()
|
||||
invoice.lines.all().delete()
|
||||
|
||||
positions = list(invoice.order.positions.select_related('addon_to', 'item', 'variation'))
|
||||
positions = list(
|
||||
invoice.order.positions.select_related('addon_to', 'item', 'variation').annotate(
|
||||
addon_c=Count('addons')
|
||||
)
|
||||
)
|
||||
positions.sort(key=lambda p: p.sort_key)
|
||||
for p in positions:
|
||||
if not invoice.event.settings.invoice_include_free and p.price == Decimal('0.00') and not p.addon_c:
|
||||
continue
|
||||
|
||||
desc = str(p.item.name)
|
||||
if p.variation:
|
||||
desc += " - " + str(p.variation.value)
|
||||
@@ -77,7 +74,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
|
||||
if invoice.order.payment_fee:
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=_('Payment via {method}').format(method=str(payment_provider.verbose_name)),
|
||||
invoice=invoice,
|
||||
description=_('Payment via {method}').format(method=str(payment_provider.verbose_name)),
|
||||
gross_value=invoice.order.payment_fee, tax_value=invoice.order.payment_fee_tax_value,
|
||||
tax_rate=invoice.order.payment_fee_tax_rate
|
||||
)
|
||||
@@ -101,6 +99,7 @@ def generate_cancellation(invoice: Invoice):
|
||||
cancellation = copy.copy(invoice)
|
||||
cancellation.pk = None
|
||||
cancellation.invoice_no = None
|
||||
cancellation.prefix = None
|
||||
cancellation.refers = invoice
|
||||
cancellation.is_cancellation = True
|
||||
cancellation.date = timezone.now().date()
|
||||
@@ -130,6 +129,7 @@ def generate_invoice(order: Order):
|
||||
invoice = Invoice(
|
||||
order=order,
|
||||
event=order.event,
|
||||
organizer=order.event.organizer,
|
||||
date=timezone.now().date(),
|
||||
locale=locale
|
||||
)
|
||||
@@ -138,265 +138,12 @@ def generate_invoice(order: Order):
|
||||
return invoice
|
||||
|
||||
|
||||
def _invoice_get_stylesheet():
|
||||
stylesheet = StyleSheet1()
|
||||
stylesheet.add(ParagraphStyle(name='Normal', fontName='OpenSans', fontSize=10, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName='OpenSansBd', fontSize=15, leading=15 * 1.2))
|
||||
return stylesheet
|
||||
|
||||
|
||||
def _invoice_register_fonts():
|
||||
pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf')))
|
||||
|
||||
|
||||
def _invoice_generate_german(invoice, f):
|
||||
_invoice_register_fonts()
|
||||
styles = _invoice_get_stylesheet()
|
||||
pagesize = pagesizes.A4
|
||||
|
||||
def on_page(canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(pagesize[0] - 20 * mm, 10 * mm, _("Page %d") % (doc.page,))
|
||||
|
||||
for i, line in enumerate(invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
|
||||
canvas.restoreState()
|
||||
|
||||
def on_first_page(canvas, doc):
|
||||
canvas.setCreator('pretix.eu')
|
||||
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=invoice.number))
|
||||
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(pagesize[0] - 20 * mm, 10 * mm, _("Page %d") % (doc.page,))
|
||||
|
||||
for i, line in enumerate(invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
p = Paragraph(invoice.invoice_from.strip().replace('\n', '<br />\n'), style=styles['Normal'])
|
||||
p.wrapOn(canvas, 70 * mm, 50 * mm)
|
||||
p_size = p.wrap(70 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
p = Paragraph(invoice.invoice_to.strip().replace('\n', '<br />\n'), style=styles['Normal'])
|
||||
p.wrapOn(canvas, 85 * mm, 50 * mm)
|
||||
p_size = p.wrap(85 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.order.full_code)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
if invoice.is_cancellation:
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.refers.number)
|
||||
else:
|
||||
textobject.textLine(pgettext('invoice', 'Invoice number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
if invoice.is_cancellation:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(invoice.refers.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
else:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
canvas.drawText(textobject)
|
||||
|
||||
if invoice.event.settings.invoice_logo_image:
|
||||
logo_file = invoice.event.settings.get('invoice_logo_image', binary_file=True)
|
||||
canvas.drawImage(ImageReader(logo_file),
|
||||
95 * mm, (297 - 38) * mm,
|
||||
width=25 * mm, height=25 * mm,
|
||||
preserveAspectRatio=True, anchor='n',
|
||||
mask='auto')
|
||||
|
||||
if invoice.event.settings.show_date_to:
|
||||
p_str = (
|
||||
str(invoice.event.name) + '\n' + _('{from_date}\nuntil {to_date}').format(
|
||||
from_date=invoice.event.get_date_from_display(),
|
||||
to_date=invoice.event.get_date_to_display())
|
||||
)
|
||||
else:
|
||||
p_str = (
|
||||
str(invoice.event.name) + '\n' + invoice.event.get_date_from_display()
|
||||
)
|
||||
|
||||
p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=styles['Normal'])
|
||||
p.wrapOn(canvas, 65 * mm, 50 * mm)
|
||||
p_size = p.wrap(65 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Event').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
canvas.restoreState()
|
||||
|
||||
doc = BaseDocTemplate(f.name, pagesize=pagesizes.A4,
|
||||
leftMargin=25 * mm, rightMargin=20 * mm,
|
||||
topMargin=20 * mm, bottomMargin=15 * mm)
|
||||
|
||||
footer_length = 3.5 * len(invoice.footer_text.split('\n')) * mm
|
||||
frames_p1 = [
|
||||
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm,
|
||||
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length,
|
||||
id='normal')
|
||||
]
|
||||
frames = [
|
||||
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height,
|
||||
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length,
|
||||
id='normal')
|
||||
]
|
||||
doc.addPageTemplates([
|
||||
PageTemplate(id='FirstPage', frames=frames_p1, onPage=on_first_page, pagesize=pagesize),
|
||||
PageTemplate(id='OtherPages', frames=frames, onPage=on_page, pagesize=pagesize)
|
||||
])
|
||||
story = [
|
||||
NextPageTemplate('FirstPage'),
|
||||
Paragraph(pgettext('invoice', 'Invoice')
|
||||
if not invoice.is_cancellation
|
||||
else pgettext('invoice', 'Cancellation'),
|
||||
styles['Heading1']),
|
||||
Spacer(1, 5 * mm),
|
||||
NextPageTemplate('OtherPages'),
|
||||
]
|
||||
|
||||
if invoice.introductory_text:
|
||||
story.append(Paragraph(invoice.introductory_text, styles['Normal']))
|
||||
story.append(Spacer(1, 10 * mm))
|
||||
|
||||
taxvalue_map = defaultdict(Decimal)
|
||||
grossvalue_map = defaultdict(Decimal)
|
||||
|
||||
tstyledata = [
|
||||
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'),
|
||||
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
]
|
||||
tdata = [(
|
||||
pgettext('invoice', 'Description'),
|
||||
pgettext('invoice', 'Tax rate'),
|
||||
pgettext('invoice', 'Net'),
|
||||
pgettext('invoice', 'Gross'),
|
||||
)]
|
||||
total = Decimal('0.00')
|
||||
for line in invoice.lines.all():
|
||||
tdata.append((
|
||||
Paragraph(line.description, styles['Normal']),
|
||||
localize(line.tax_rate) + " %",
|
||||
localize(line.net_value) + " " + invoice.event.currency,
|
||||
localize(line.gross_value) + " " + invoice.event.currency,
|
||||
))
|
||||
taxvalue_map[line.tax_rate] += line.tax_value
|
||||
grossvalue_map[line.tax_rate] += line.gross_value
|
||||
total += line.gross_value
|
||||
|
||||
tdata.append([pgettext('invoice', 'Invoice total'), '', '', localize(total) + " " + invoice.event.currency])
|
||||
colwidths = [a * doc.width for a in (.55, .15, .15, .15)]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=1)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
story.append(table)
|
||||
|
||||
story.append(Spacer(1, 15 * mm))
|
||||
|
||||
if invoice.payment_provider_text:
|
||||
story.append(Paragraph(invoice.payment_provider_text, styles['Normal']))
|
||||
|
||||
if invoice.additional_text:
|
||||
story.append(Paragraph(invoice.additional_text, styles['Normal']))
|
||||
story.append(Spacer(1, 15 * mm))
|
||||
|
||||
tstyledata = [
|
||||
('SPAN', (1, 0), (-1, 0)),
|
||||
('ALIGN', (2, 1), (-1, -1), 'RIGHT'),
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||
]
|
||||
tdata = [('', pgettext('invoice', 'Included taxes'), '', '', ''),
|
||||
('', pgettext('invoice', 'Tax rate'),
|
||||
pgettext('invoice', 'Net value'), pgettext('invoice', 'Gross value'), pgettext('invoice', 'Tax'))]
|
||||
|
||||
for rate, gross in grossvalue_map.items():
|
||||
if line.tax_rate == 0:
|
||||
continue
|
||||
tax = taxvalue_map[rate]
|
||||
tdata.append((
|
||||
'',
|
||||
localize(rate) + " %",
|
||||
localize((gross - tax)) + " " + invoice.event.currency,
|
||||
localize(gross) + " " + invoice.event.currency,
|
||||
localize(tax) + " " + invoice.event.currency,
|
||||
))
|
||||
|
||||
if len(tdata) > 2:
|
||||
colwidths = [a * doc.width for a in (.45, .10, .15, .15, .15)]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=2)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
story.append(table)
|
||||
|
||||
doc.build(story)
|
||||
return doc
|
||||
|
||||
|
||||
@app.task(base=TransactionAwareTask)
|
||||
def invoice_pdf_task(invoice: int):
|
||||
i = Invoice.objects.get(pk=invoice)
|
||||
with language(i.locale):
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
||||
_invoice_generate_german(i, f)
|
||||
f.seek(0)
|
||||
i.file.save('invoice.pdf', ContentFile(f.read()))
|
||||
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
|
||||
i.file.save(fname, ContentFile(fcontent))
|
||||
i.save()
|
||||
return i.file.name
|
||||
|
||||
@@ -429,7 +176,7 @@ def build_preview_invoice_pdf(event):
|
||||
expires=timezone.now(), code="PREVIEW", total=119)
|
||||
invoice = Invoice(
|
||||
order=order, event=event, invoice_no="PREVIEW",
|
||||
date=timezone.now().date(), locale=locale
|
||||
date=timezone.now().date(), locale=locale, organizer=event.organizer
|
||||
)
|
||||
invoice.invoice_from = event.settings.get('invoice_address_from')
|
||||
|
||||
@@ -452,7 +199,4 @@ def build_preview_invoice_pdf(event):
|
||||
gross_value=119, tax_value=19,
|
||||
tax_rate=19
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
||||
_invoice_generate_german(invoice, f)
|
||||
f.seek(0)
|
||||
return f.read()
|
||||
return event.invoice_renderer.generate(invoice)
|
||||
|
||||
@@ -33,7 +33,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, headers: dict=None):
|
||||
order: Order=None, headers: dict=None, sender: str=None):
|
||||
"""
|
||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||
|
||||
@@ -58,6 +58,9 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
|
||||
:param locale: The locale to be used while evaluating the subject and the template
|
||||
|
||||
:param sender: Set the sender email address. If not set and ``event`` is set, the event's default will be used,
|
||||
otherwise the system default.
|
||||
|
||||
: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.
|
||||
"""
|
||||
@@ -78,19 +81,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
'invoice_name': '',
|
||||
'invoice_company': ''
|
||||
})
|
||||
if isinstance(template, LazyI18nString):
|
||||
body = str(template)
|
||||
if context:
|
||||
body = body.format_map(TolerantDict(context))
|
||||
body_md = bleach.linkify(bleach.clean(markdown.markdown(body), tags=bleach.ALLOWED_TAGS + [
|
||||
'p',
|
||||
]))
|
||||
else:
|
||||
tpl = get_template(template)
|
||||
body = tpl.render(context)
|
||||
body_md = bleach.linkify(markdown.markdown(body))
|
||||
|
||||
sender = event.settings.get('mail_from') if event else settings.MAIL_FROM
|
||||
body, body_md = render_mail(template, context)
|
||||
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
|
||||
|
||||
subject = str(subject)
|
||||
body_plain = body
|
||||
@@ -167,3 +159,18 @@ def mail_send_task(to: List[str], subject: str, body: str, html: str, sender: st
|
||||
|
||||
def mail_send(*args, **kwargs):
|
||||
mail_send_task.apply_async(args=args, kwargs=kwargs)
|
||||
|
||||
|
||||
def render_mail(template, context):
|
||||
if isinstance(template, LazyI18nString):
|
||||
body = str(template)
|
||||
if context:
|
||||
body = body.format_map(TolerantDict(context))
|
||||
body_md = bleach.linkify(bleach.clean(markdown.markdown(body), tags=bleach.ALLOWED_TAGS + [
|
||||
'p',
|
||||
]))
|
||||
else:
|
||||
tpl = get_template(template)
|
||||
body = tpl.render(context)
|
||||
body_md = bleach.linkify(markdown.markdown(body))
|
||||
return body, body_md
|
||||
|
||||
@@ -22,14 +22,17 @@ from pretix.base.models import (
|
||||
CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota,
|
||||
User, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import CachedTicket, InvoiceAddress
|
||||
from pretix.base.payment import BasePaymentProvider
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_qualified,
|
||||
)
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.signals import order_paid, order_placed, periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
@@ -58,6 +61,10 @@ error_messages = {
|
||||
'removed this item from your cart.'),
|
||||
'voucher_required': _('You need a valid voucher code to order one of the products in your cart. We removed this '
|
||||
'item from your cart.'),
|
||||
'some_subevent_not_started': _('The presale period for one of the events in your cart has not yet started. The '
|
||||
'affected positions have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -122,22 +129,27 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
mail(
|
||||
order.email, _('Payment received for your order: %(code)s') % {'code': order.code},
|
||||
order.event.settings.mail_text_order_paid,
|
||||
{
|
||||
'event': order.event.name,
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'downloads': order.event.settings.get('ticket_download', as_type=bool),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
'payment_info': mail_text
|
||||
},
|
||||
order.event, locale=order.locale
|
||||
)
|
||||
email_template = order.event.settings.mail_text_order_paid
|
||||
email_context = {
|
||||
'event': order.event.name,
|
||||
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'downloads': order.event.settings.get('ticket_download', as_type=bool),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
'payment_info': mail_text
|
||||
}
|
||||
email_subject = _('Payment received for your order: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_paid', user
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order paid email could not be sent')
|
||||
|
||||
return order
|
||||
|
||||
|
||||
@@ -165,7 +177,7 @@ def mark_order_refunded(order, user=None):
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _cancel_order(order, user=None):
|
||||
def _cancel_order(order, user=None, send_mail: bool=True):
|
||||
"""
|
||||
Mark this order as canceled
|
||||
:param order: The order to change
|
||||
@@ -190,6 +202,26 @@ def _cancel_order(order, user=None):
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
|
||||
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', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
})
|
||||
}
|
||||
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
|
||||
with language(order.locale):
|
||||
try:
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_canceled', user
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order canceled email could not be sent')
|
||||
|
||||
return order.pk
|
||||
|
||||
|
||||
@@ -215,7 +247,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
err = err or error_messages['unavailable']
|
||||
cp.delete()
|
||||
continue
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
quotas = list(cp.quotas)
|
||||
|
||||
products_seen[cp.item] += 1
|
||||
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
|
||||
@@ -235,9 +267,19 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
cp.delete() # Sorry!
|
||||
continue
|
||||
|
||||
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
|
||||
err = err or error_messages['some_subevent_not_started']
|
||||
cp.delete()
|
||||
break
|
||||
|
||||
if cp.subevent and cp.subevent.presale_end and now_dt > cp.subevent.presale_end:
|
||||
err = err or error_messages['some_subevent_ended']
|
||||
cp.delete()
|
||||
break
|
||||
|
||||
if cp.item.require_voucher and cp.voucher is None:
|
||||
cp.delete()
|
||||
err = error_messages['voucher_required']
|
||||
err = err or error_messages['voucher_required']
|
||||
break
|
||||
|
||||
if cp.item.hide_without_voucher and (cp.voucher is None or cp.voucher.item is None
|
||||
@@ -250,8 +292,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
# Other checks are not necessary
|
||||
continue
|
||||
|
||||
price = cp.item.default_price if cp.variation is None else (
|
||||
cp.variation.default_price if cp.variation.default_price is not None else cp.item.default_price)
|
||||
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
|
||||
addon_to=cp.addon_to)
|
||||
|
||||
if price is False or len(quotas) == 0:
|
||||
err = err or error_messages['unavailable']
|
||||
@@ -263,7 +305,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
err = err or error_messages['voucher_expired']
|
||||
cp.delete()
|
||||
continue
|
||||
price = cp.voucher.calculate_price(price)
|
||||
|
||||
if price != cp.price and not (cp.item.free_price and cp.price > price):
|
||||
positions[i] = cp
|
||||
@@ -302,7 +343,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
payment_provider: BasePaymentProvider, locale: str=None, address: int=None,
|
||||
meta_info: dict=None):
|
||||
from datetime import date, time
|
||||
from datetime import time
|
||||
|
||||
total = sum([c.price for c in positions])
|
||||
payment_fee = payment_provider.calculate_fee(total)
|
||||
@@ -319,13 +360,21 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
|
||||
expires = exp_by_date
|
||||
|
||||
if event.settings.get('payment_term_last'):
|
||||
last_date = make_aware(datetime.combine(
|
||||
event.settings.get('payment_term_last', as_type=date),
|
||||
term_last = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||
if term_last:
|
||||
if event.has_subevents:
|
||||
term_last = min([
|
||||
term_last.datetime(se).date()
|
||||
for se in event.subevents.filter(id__in=[p.subevent_id for p in positions])
|
||||
])
|
||||
else:
|
||||
term_last = term_last.datetime(event).date()
|
||||
term_last = make_aware(datetime.combine(
|
||||
term_last,
|
||||
time(hour=23, minute=59, second=59)
|
||||
), tz)
|
||||
if last_date < expires:
|
||||
expires = last_date
|
||||
if term_last < expires:
|
||||
expires = term_last
|
||||
|
||||
with transaction.atomic():
|
||||
order = Order.objects.create(
|
||||
@@ -370,7 +419,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
|
||||
with event.lock() as now_dt:
|
||||
positions = list(CartPosition.objects.filter(
|
||||
id__in=position_ids).select_related('item', 'variation'))
|
||||
id__in=position_ids).select_related('item', 'variation', 'subevent'))
|
||||
if len(positions) == 0:
|
||||
raise OrderError(error_messages['empty'])
|
||||
if len(position_ids) != len(positions):
|
||||
@@ -384,9 +433,11 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
generate_invoice(order)
|
||||
|
||||
if order.total == Decimal('0.00'):
|
||||
mailtext = event.settings.mail_text_order_free
|
||||
email_template = event.settings.mail_text_order_free
|
||||
log_entry = 'pretix.event.order.email.order_free'
|
||||
else:
|
||||
mailtext = event.settings.mail_text_order_placed
|
||||
email_template = event.settings.mail_text_order_placed
|
||||
log_entry = 'pretix.event.order.email.order_placed'
|
||||
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
@@ -394,25 +445,27 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
|
||||
mail(
|
||||
order.email, _('Your order: %(code)s') % {'code': order.code},
|
||||
mailtext,
|
||||
{
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': event.currency,
|
||||
'date': LazyDate(order.expires),
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'payment_info': str(pprov.order_pending_mail_render(order)),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
event, locale=order.locale
|
||||
)
|
||||
email_context = {
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': event.currency,
|
||||
'date': LazyDate(order.expires),
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'payment_info': str(pprov.order_pending_mail_render(order)),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_subject = _('Your order: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
log_entry
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order received email could not be sent')
|
||||
|
||||
return order.id
|
||||
|
||||
@@ -454,27 +507,25 @@ def send_expiry_warnings(sender, **kwargs):
|
||||
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', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
}),
|
||||
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_subject = _('Your order is about to expire: %(code)s') % {'code': o.code}
|
||||
try:
|
||||
with language(o.locale):
|
||||
mail(
|
||||
o.email, _('Your order is about to expire: %(code)s') % {'code': o.code},
|
||||
eventsettings.mail_text_order_expire_warning,
|
||||
{
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
}),
|
||||
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
o.event, locale=o.locale
|
||||
)
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.expire_warning_sent'
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
else:
|
||||
o.log_action('pretix.event.order.expire_warning_sent')
|
||||
|
||||
|
||||
class OrderChangeManager:
|
||||
@@ -482,6 +533,7 @@ class OrderChangeManager:
|
||||
'free_to_paid': _('You cannot change a free order to a paid order.'),
|
||||
'product_without_variation': _('You need to select a variation of the product.'),
|
||||
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
|
||||
'quota_missing': _('There is no quota defined that allows this operation.'),
|
||||
'product_invalid': _('The selected product is not active or has no price set.'),
|
||||
'complete_cancel': _('This operation would leave the order empty. Please cancel the order itself instead.'),
|
||||
'not_pending_or_paid': _('Only pending or paid orders can be changed.'),
|
||||
@@ -491,11 +543,13 @@ class OrderChangeManager:
|
||||
'price of the order as partial payments or refunds are not yet supported.'),
|
||||
'addon_to_required': _('This is an addon product, please select the base position it should be added to.'),
|
||||
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
|
||||
'subevent_required': _('You need to choose a subevent for the new position.'),
|
||||
}
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation', 'price'))
|
||||
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent', 'price'))
|
||||
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
|
||||
CancelOperation = namedtuple('CancelOperation', ('position',))
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to'))
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent'))
|
||||
|
||||
def __init__(self, order: Order, user):
|
||||
self.order = order
|
||||
@@ -503,30 +557,69 @@ class OrderChangeManager:
|
||||
self._totaldiff = 0
|
||||
self._quotadiff = Counter()
|
||||
self._operations = []
|
||||
self._invoice_dirty = False
|
||||
|
||||
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
|
||||
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
|
||||
raise OrderError(self.error_messages['product_without_variation'])
|
||||
price = item.default_price if variation is None else variation.price
|
||||
if price is None:
|
||||
|
||||
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent)
|
||||
|
||||
if price is None: # NOQA
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
|
||||
new_quotas = (variation.quotas.filter(subevent=position.subevent)
|
||||
if variation else item.quotas.filter(subevent=position.subevent))
|
||||
if not new_quotas:
|
||||
raise OrderError(self.error_messages['quota_missing'])
|
||||
|
||||
if self.order.event.settings.invoice_include_free or price != Decimal('0.00') or position.price != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
|
||||
self._totaldiff = price - position.price
|
||||
self._quotadiff.update(variation.quotas.all() if variation else item.quotas.all())
|
||||
self._quotadiff.subtract(position.variation.quotas.all() if position.variation else position.item.quotas.all())
|
||||
self._quotadiff.update(new_quotas)
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.ItemOperation(position, item, variation, price))
|
||||
|
||||
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
|
||||
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent)
|
||||
|
||||
if price is None: # NOQA
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
|
||||
new_quotas = (position.variation.quotas.filter(subevent=subevent)
|
||||
if position.variation else position.item.quotas.filter(subevent=subevent))
|
||||
if not new_quotas:
|
||||
raise OrderError(self.error_messages['quota_missing'])
|
||||
|
||||
if self.order.event.settings.invoice_include_free or price != Decimal('0.00') or position.price != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
|
||||
self._totaldiff = price - position.price
|
||||
self._quotadiff.update(new_quotas)
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.SubeventOperation(position, subevent, price))
|
||||
|
||||
def change_price(self, position: OrderPosition, price: Decimal):
|
||||
self._totaldiff = price - position.price
|
||||
|
||||
if self.order.event.settings.invoice_include_free or price != Decimal('0.00') or position.price != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
|
||||
self._operations.append(self.PriceOperation(position, price))
|
||||
|
||||
def cancel(self, position: OrderPosition):
|
||||
self._totaldiff = -position.price
|
||||
self._quotadiff.subtract(position.variation.quotas.all() if position.variation else position.item.quotas.all())
|
||||
self._quotadiff.subtract(position.quotas)
|
||||
self._operations.append(self.CancelOperation(position))
|
||||
|
||||
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order):
|
||||
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
|
||||
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
|
||||
subevent: SubEvent = None):
|
||||
if price is None:
|
||||
price = item.default_price if variation is None else variation.price
|
||||
price = get_price(item, variation, subevent=subevent)
|
||||
if price is None:
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
if not addon_to and item.category and item.category.is_addon:
|
||||
@@ -534,10 +627,20 @@ class OrderChangeManager:
|
||||
if addon_to:
|
||||
if not item.category or item.category_id not in addon_to.item.addons.values_list('addon_category', flat=True):
|
||||
raise OrderError(self.error_messages['addon_invalid'])
|
||||
if self.order.event.has_subevents and not subevent:
|
||||
raise OrderError(self.error_messages['subevent_required'])
|
||||
|
||||
new_quotas = (variation.quotas.filter(subevent=subevent)
|
||||
if variation else item.quotas.filter(subevent=subevent))
|
||||
if not new_quotas:
|
||||
raise OrderError(self.error_messages['quota_missing'])
|
||||
|
||||
if self.order.event.settings.invoice_include_free or price != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
|
||||
self._totaldiff = price
|
||||
self._quotadiff.update(variation.quotas.all() if variation else item.quotas.all())
|
||||
self._operations.append(self.AddOperation(item, variation, price, addon_to))
|
||||
self._quotadiff.update(new_quotas)
|
||||
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent))
|
||||
|
||||
def _check_quotas(self):
|
||||
for quota, diff in self._quotadiff.items():
|
||||
@@ -582,6 +685,19 @@ class OrderChangeManager:
|
||||
op.position.price = op.price
|
||||
op.position._calculate_tax()
|
||||
op.position.save()
|
||||
elif isinstance(op, self.SubeventOperation):
|
||||
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, data={
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'old_subevent': op.position.subevent.pk,
|
||||
'new_subevent': op.subevent.pk,
|
||||
'old_price': op.position.price,
|
||||
'new_price': op.price
|
||||
})
|
||||
op.position.subevent = op.subevent
|
||||
op.position.price = op.price
|
||||
op.position._calculate_tax()
|
||||
op.position.save()
|
||||
elif isinstance(op, self.PriceOperation):
|
||||
self.order.log_action('pretix.event.order.changed.price', user=self.user, data={
|
||||
'position': op.position.pk,
|
||||
@@ -616,7 +732,7 @@ class OrderChangeManager:
|
||||
pos = OrderPosition.objects.create(
|
||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||
price=op.price, order=self.order,
|
||||
positionid=nextposid
|
||||
positionid=nextposid, subevent=op.subevent
|
||||
)
|
||||
nextposid += 1
|
||||
self.order.log_action('pretix.event.order.changed.add', user=self.user, data={
|
||||
@@ -625,7 +741,8 @@ class OrderChangeManager:
|
||||
'variation': op.variation.pk if op.variation else None,
|
||||
'addon_to': op.addon_to.pk if op.addon_to else None,
|
||||
'price': op.price,
|
||||
'positionid': pos.positionid
|
||||
'positionid': pos.positionid,
|
||||
'subevent': op.subevent.pk if op.subevent else None,
|
||||
})
|
||||
|
||||
def _recalculate_total_and_payment_fee(self):
|
||||
@@ -642,7 +759,7 @@ class OrderChangeManager:
|
||||
|
||||
def _reissue_invoice(self):
|
||||
i = self.order.invoices.filter(is_cancellation=False).last()
|
||||
if i:
|
||||
if i and self._invoice_dirty:
|
||||
generate_cancellation(i)
|
||||
generate_invoice(self.order)
|
||||
|
||||
@@ -659,20 +776,24 @@ class OrderChangeManager:
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
mail(
|
||||
self.order.email, _('Your order has been changed: %(code)s') % {'code': self.order.code},
|
||||
self.order.event.settings.mail_text_order_changed,
|
||||
{
|
||||
'event': self.order.event.name,
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
|
||||
'order': self.order.code,
|
||||
'secret': self.order.secret
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
self.order.event, locale=self.order.locale
|
||||
)
|
||||
email_template = self.order.event.settings.mail_text_order_changed
|
||||
email_context = {
|
||||
'event': self.order.event.name,
|
||||
'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
|
||||
'order': self.order.code,
|
||||
'secret': self.order.secret
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
email_subject = _('Your order has been changed: %(code)s') % {'code': self.order.code}
|
||||
try:
|
||||
self.order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_changed', self.user
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order changed email could not be sent')
|
||||
|
||||
def commit(self):
|
||||
if not self._operations:
|
||||
@@ -717,10 +838,10 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def cancel_order(self, order: int, user: int=None):
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True):
|
||||
try:
|
||||
try:
|
||||
return _cancel_order(order, user)
|
||||
return _cancel_order(order, user, send_mail)
|
||||
except LockTimeoutException:
|
||||
self.retry(exc=OrderError(error_messages['busy']))
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
|
||||
44
src/pretix/base/services/pricing.py
Normal file
44
src/pretix/base/services/pricing.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import (
|
||||
AbstractPosition, Item, ItemAddOn, ItemVariation, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
|
||||
|
||||
def get_price(item: Item, variation: ItemVariation = None,
|
||||
voucher: Voucher = None, custom_price: Decimal = None,
|
||||
subevent: SubEvent = None, custom_price_is_net: bool = False,
|
||||
addon_to: AbstractPosition = None):
|
||||
if addon_to:
|
||||
try:
|
||||
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
|
||||
if iao.price_included:
|
||||
return Decimal('0.00')
|
||||
except ItemAddOn.DoesNotExist:
|
||||
pass
|
||||
|
||||
price = item.default_price
|
||||
if subevent and item.pk in subevent.item_price_overrides:
|
||||
price = subevent.item_price_overrides[item.pk]
|
||||
|
||||
if variation is not None:
|
||||
if variation.default_price is not None:
|
||||
price = variation.default_price
|
||||
if subevent and variation.pk in subevent.var_price_overrides:
|
||||
price = subevent.var_price_overrides[variation.pk]
|
||||
|
||||
if voucher:
|
||||
price = voucher.calculate_price(price)
|
||||
|
||||
if item.free_price and custom_price is not None and custom_price != "":
|
||||
if not isinstance(custom_price, Decimal):
|
||||
custom_price = Decimal(str(custom_price).replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
raise ValueError('price_too_high')
|
||||
if custom_price_is_net:
|
||||
custom_price = round_decimal(custom_price * (100 + item.tax_rate) / 100)
|
||||
price = max(custom_price, price)
|
||||
|
||||
return price
|
||||
@@ -5,6 +5,7 @@ from django.db.models import Count, Sum
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition
|
||||
from pretix.base.models.event import SubEvent
|
||||
|
||||
|
||||
class DummyObject:
|
||||
@@ -67,14 +68,18 @@ def dictsum(*dicts) -> dict:
|
||||
return res
|
||||
|
||||
|
||||
def order_overview(event: Event) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
|
||||
def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[ItemCategory, List[Item]]],
|
||||
Dict[str, Tuple[Decimal, Decimal]]]:
|
||||
items = event.items.all().select_related(
|
||||
'category', # for re-grouping
|
||||
).prefetch_related(
|
||||
'variations'
|
||||
).order_by('category__position', 'category_id', 'name')
|
||||
).order_by('category__position', 'category_id', 'position', 'name')
|
||||
|
||||
counters = OrderPosition.objects.filter(
|
||||
qs = OrderPosition.objects
|
||||
if subevent:
|
||||
qs = qs.filter(subevent=subevent)
|
||||
counters = qs.filter(
|
||||
order__event=event
|
||||
).values(
|
||||
'item', 'variation', 'order__status'
|
||||
@@ -155,71 +160,72 @@ def order_overview(event: Event) -> Tuple[List[Tuple[ItemCategory, List[Item]]],
|
||||
payment_cat_obj.name = _('Payment method fees')
|
||||
payment_items = []
|
||||
|
||||
counters = event.orders.values('payment_provider', 'status').annotate(
|
||||
cnt=Count('id'), payment_fee=Sum('payment_fee'), tax_value=Sum('payment_fee_tax_value')
|
||||
).order_by()
|
||||
if not subevent:
|
||||
counters = event.orders.values('payment_provider', 'status').annotate(
|
||||
cnt=Count('id'), payment_fee=Sum('payment_fee'), tax_value=Sum('payment_fee_tax_value')
|
||||
).order_by()
|
||||
|
||||
num_canceled = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_CANCELED
|
||||
}
|
||||
num_refunded = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_REFUNDED
|
||||
}
|
||||
num_pending = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_PENDING
|
||||
}
|
||||
num_expired = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_EXPIRED
|
||||
}
|
||||
num_paid = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_PAID
|
||||
}
|
||||
num_total = dictsum(num_pending, num_paid)
|
||||
num_canceled = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_CANCELED
|
||||
}
|
||||
num_refunded = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_REFUNDED
|
||||
}
|
||||
num_pending = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_PENDING
|
||||
}
|
||||
num_expired = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_EXPIRED
|
||||
}
|
||||
num_paid = {
|
||||
o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value'])
|
||||
for o in counters if o['status'] == Order.STATUS_PAID
|
||||
}
|
||||
num_total = dictsum(num_pending, num_paid)
|
||||
|
||||
provider_names = {
|
||||
k: v.verbose_name
|
||||
for k, v in event.get_payment_providers().items()
|
||||
}
|
||||
provider_names = {
|
||||
k: v.verbose_name
|
||||
for k, v in event.get_payment_providers().items()
|
||||
}
|
||||
|
||||
for pprov, total in num_total.items():
|
||||
ppobj = DummyObject()
|
||||
ppobj.name = provider_names.get(pprov, pprov)
|
||||
ppobj.provider = pprov
|
||||
ppobj.has_variations = False
|
||||
ppobj.num_total = total
|
||||
ppobj.num_canceled = num_canceled.get(pprov, (0, 0, 0))
|
||||
ppobj.num_refunded = num_refunded.get(pprov, (0, 0, 0))
|
||||
ppobj.num_expired = num_expired.get(pprov, (0, 0, 0))
|
||||
ppobj.num_pending = num_pending.get(pprov, (0, 0, 0))
|
||||
ppobj.num_paid = num_paid.get(pprov, (0, 0, 0))
|
||||
payment_items.append(ppobj)
|
||||
for pprov, total in num_total.items():
|
||||
ppobj = DummyObject()
|
||||
ppobj.name = provider_names.get(pprov, pprov)
|
||||
ppobj.provider = pprov
|
||||
ppobj.has_variations = False
|
||||
ppobj.num_total = total
|
||||
ppobj.num_canceled = num_canceled.get(pprov, (0, 0, 0))
|
||||
ppobj.num_refunded = num_refunded.get(pprov, (0, 0, 0))
|
||||
ppobj.num_expired = num_expired.get(pprov, (0, 0, 0))
|
||||
ppobj.num_pending = num_pending.get(pprov, (0, 0, 0))
|
||||
ppobj.num_paid = num_paid.get(pprov, (0, 0, 0))
|
||||
payment_items.append(ppobj)
|
||||
|
||||
payment_cat_obj.num_total = (
|
||||
Dontsum(''), sum(i.num_total[1] for i in payment_items), sum(i.num_total[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_canceled = (
|
||||
Dontsum(''), sum(i.num_canceled[1] for i in payment_items), sum(i.num_canceled[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_refunded = (
|
||||
Dontsum(''), sum(i.num_refunded[1] for i in payment_items), sum(i.num_refunded[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_expired = (
|
||||
Dontsum(''), sum(i.num_expired[1] for i in payment_items), sum(i.num_expired[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_pending = (
|
||||
Dontsum(''), sum(i.num_pending[1] for i in payment_items), sum(i.num_pending[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_paid = (
|
||||
Dontsum(''), sum(i.num_paid[1] for i in payment_items), sum(i.num_paid[2] for i in payment_items)
|
||||
)
|
||||
payment_cat = (payment_cat_obj, payment_items)
|
||||
payment_cat_obj.num_total = (
|
||||
Dontsum(''), sum(i.num_total[1] for i in payment_items), sum(i.num_total[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_canceled = (
|
||||
Dontsum(''), sum(i.num_canceled[1] for i in payment_items), sum(i.num_canceled[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_refunded = (
|
||||
Dontsum(''), sum(i.num_refunded[1] for i in payment_items), sum(i.num_refunded[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_expired = (
|
||||
Dontsum(''), sum(i.num_expired[1] for i in payment_items), sum(i.num_expired[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_pending = (
|
||||
Dontsum(''), sum(i.num_pending[1] for i in payment_items), sum(i.num_pending[2] for i in payment_items)
|
||||
)
|
||||
payment_cat_obj.num_paid = (
|
||||
Dontsum(''), sum(i.num_paid[1] for i in payment_items), sum(i.num_paid[2] for i in payment_items)
|
||||
)
|
||||
payment_cat = (payment_cat_obj, payment_items)
|
||||
|
||||
items_by_category.append(payment_cat)
|
||||
items_by_category.append(payment_cat)
|
||||
|
||||
total = {
|
||||
'num_total': tuplesum(c.num_total for c, i in items_by_category),
|
||||
|
||||
@@ -8,7 +8,7 @@ from pretix.celery_app import app
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def assign_automatically(event_id: int, user_id: int=None):
|
||||
def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None):
|
||||
event = Event.objects.get(id=event_id)
|
||||
if user_id:
|
||||
user = User.objects.get(id=user_id)
|
||||
@@ -21,17 +21,24 @@ def assign_automatically(event_id: int, user_id: int=None):
|
||||
qs = WaitingListEntry.objects.filter(
|
||||
event=event, voucher__isnull=True
|
||||
).select_related('item', 'variation').prefetch_related('item__quotas', 'variation__quotas').order_by('created')
|
||||
|
||||
if subevent_id and event.has_subevents:
|
||||
subevent = event.subevents.get(id=subevent_id)
|
||||
qs = qs.filter(subevent=subevent)
|
||||
|
||||
sent = 0
|
||||
|
||||
for wle in qs:
|
||||
if (wle.item, wle.variation) in gone:
|
||||
continue
|
||||
|
||||
quotas = wle.variation.quotas.all() if wle.variation else wle.item.quotas.all()
|
||||
quotas = (wle.variation.quotas.filter(subevent=wle.subevent)
|
||||
if wle.variation
|
||||
else wle.item.quotas.filter(subevent=wle.subevent))
|
||||
availability = (
|
||||
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache, subevent=wle.subevent)
|
||||
if wle.variation
|
||||
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache, subevent=wle.subevent)
|
||||
)
|
||||
if availability[1] > 0:
|
||||
try:
|
||||
|
||||
@@ -10,6 +10,8 @@ from hierarkey.models import GlobalSettingsBase, Hierarkey
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from typing import Any
|
||||
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
|
||||
DEFAULTS = {
|
||||
'max_items_per_order': {
|
||||
'default': '10',
|
||||
@@ -43,6 +45,10 @@ DEFAULTS = {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
},
|
||||
'invoice_name_required': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'invoice_address_required': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
@@ -51,10 +57,22 @@ DEFAULTS = {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'invoice_include_free': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
},
|
||||
'invoice_numbers_consecutive': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
},
|
||||
'invoice_numbers_prefix': {
|
||||
'default': '',
|
||||
'type': str,
|
||||
},
|
||||
'invoice_renderer': {
|
||||
'default': 'classic',
|
||||
'type': str,
|
||||
},
|
||||
'reservation_time': {
|
||||
'default': '30',
|
||||
'type': int
|
||||
@@ -65,7 +83,7 @@ DEFAULTS = {
|
||||
},
|
||||
'payment_term_last': {
|
||||
'default': None,
|
||||
'type': datetime,
|
||||
'type': RelativeDateWrapper,
|
||||
},
|
||||
'payment_term_weekdays': {
|
||||
'default': 'True',
|
||||
@@ -161,7 +179,7 @@ DEFAULTS = {
|
||||
},
|
||||
'ticket_download_date': {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
'type': RelativeDateWrapper
|
||||
},
|
||||
'ticket_download_addons': {
|
||||
'default': 'False',
|
||||
@@ -171,9 +189,13 @@ DEFAULTS = {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'event_list_type': {
|
||||
'default': 'list',
|
||||
'type': str
|
||||
},
|
||||
'last_order_modification_date': {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
'type': RelativeDateWrapper
|
||||
},
|
||||
'cancel_allow_user': {
|
||||
'default': 'True',
|
||||
@@ -316,6 +338,28 @@ Please note that this link is only valid within the next {hours} hours!
|
||||
We will reassign the ticket to the next person on the list if you do not
|
||||
redeem the voucher within that timeframe.
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_text_order_canceled': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||
|
||||
your order {code} for {event} has been canceled.
|
||||
|
||||
You can view the details of your order at
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_text_order_custom_mail': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||
|
||||
You can change your order details and view the status of your order at
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
@@ -371,6 +415,10 @@ Your {event} team"""))
|
||||
'default': '',
|
||||
'type': LazyI18nString
|
||||
},
|
||||
'organizer_info_text': {
|
||||
'default': '',
|
||||
'type': LazyI18nString
|
||||
},
|
||||
'update_check_ack': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
@@ -417,6 +465,9 @@ def i18n_uns(v):
|
||||
settings_hierarkey.add_type(LazyI18nString,
|
||||
serialize=lambda s: json.dumps(s.data),
|
||||
unserialize=i18n_uns)
|
||||
settings_hierarkey.add_type(RelativeDateWrapper,
|
||||
serialize=lambda rdw: rdw.to_string(),
|
||||
unserialize=lambda s: RelativeDateWrapper.from_string(s))
|
||||
|
||||
|
||||
@settings_hierarkey.set_global(cache_namespace='global')
|
||||
@@ -438,6 +489,9 @@ class SettingsSandbox:
|
||||
self._type = typestr
|
||||
self._key = key
|
||||
|
||||
def get_prefix(self):
|
||||
return '%s_%s_' % (self._type, self._key)
|
||||
|
||||
def _convert_key(self, key: str) -> str:
|
||||
return '%s_%s_%s' % (self._type, self._key, key)
|
||||
|
||||
|
||||
@@ -8,6 +8,15 @@ from django.dispatch.dispatcher import NO_RECEIVERS
|
||||
|
||||
from .models import Event
|
||||
|
||||
app_cache = {}
|
||||
|
||||
|
||||
def _populate_app_cache():
|
||||
global app_cache
|
||||
apps.check_apps_ready()
|
||||
for ac in apps.app_configs.values():
|
||||
app_cache[ac.name] = ac
|
||||
|
||||
|
||||
class EventPluginSignal(django.dispatch.Signal):
|
||||
"""
|
||||
@@ -30,23 +39,23 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS:
|
||||
return responses
|
||||
|
||||
if not app_cache:
|
||||
_populate_app_cache()
|
||||
|
||||
for receiver in self._live_receivers(sender):
|
||||
# Find the Django application this belongs to
|
||||
searchpath = receiver.__module__
|
||||
core_module = any([searchpath.startswith(cm) for cm in settings.CORE_MODULES])
|
||||
app = None
|
||||
mod = None
|
||||
while True:
|
||||
try:
|
||||
if apps.is_installed(searchpath):
|
||||
app = apps.get_app_config(searchpath.split(".")[-1])
|
||||
except LookupError:
|
||||
pass
|
||||
if "." not in searchpath:
|
||||
break
|
||||
searchpath, mod = searchpath.rsplit(".", 1)
|
||||
if not core_module:
|
||||
while True:
|
||||
app = app_cache.get(searchpath)
|
||||
if "." not in searchpath or app:
|
||||
break
|
||||
searchpath, _ = searchpath.rsplit(".", 1)
|
||||
|
||||
# Only fire receivers from active plugins and core modules
|
||||
if (searchpath, mod) in settings.CORE_MODULES or (sender and app and app.name in sender.get_plugins()):
|
||||
if core_module or (sender and app and app.name in sender.get_plugins()):
|
||||
if not hasattr(app, 'compatibility_errors') or not app.compatibility_errors:
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
responses.append((receiver, response))
|
||||
@@ -82,6 +91,16 @@ subclass of pretix.base.payment.BasePaymentProvider
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
register_invoice_renderers = EventPluginSignal(
|
||||
providing_args=[]
|
||||
)
|
||||
"""
|
||||
This signal is sent out to get all known invoice renderers. Receivers should return a
|
||||
subclass of pretix.base.invoice.BaseInvoiceRenderer
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
register_ticket_outputs = EventPluginSignal(
|
||||
providing_args=[]
|
||||
)
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
<h1>{% trans "Internal Server Error" %}</h1>
|
||||
<p>{% trans "We had trouble processing your request." %}</p>
|
||||
<p>{% trans "If this problem persists, please contact us." %}</p>
|
||||
{% if request.sentry.id %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
If you contact us, please send us the following code:
|
||||
{% endblocktrans %}
|
||||
<br>
|
||||
{{ request.sentry.id }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{% load i18n %}
|
||||
<div class="reldatetime">
|
||||
{% for group_name, group_choices, group-index in widget.subwidgets.0.optgroups %}
|
||||
{% for selopt in group_choices %}
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="{{ widget.subwidgets.0.name }}" value="{{ selopt.value }}"
|
||||
{% include "django/forms/widgets/attrs.html" with widget=selopt %} />
|
||||
{{ selopt.label }}
|
||||
</label>
|
||||
{% if selopt.value == "absolute" %}
|
||||
{% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %}
|
||||
{% elif selopt.value == "relative" %}
|
||||
{% include widget.subwidgets.2.template_name with widget=widget.subwidgets.2 %}
|
||||
{% trans "days before" %}
|
||||
{% include widget.subwidgets.3.template_name with widget=widget.subwidgets.3 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
{% load i18n %}
|
||||
<div class="reldatetime">
|
||||
{% for group_name, group_choices, group-index in widget.subwidgets.0.optgroups %}
|
||||
{% for selopt in group_choices %}
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="{{ widget.subwidgets.0.name }}" value="{{ selopt.value }}"
|
||||
{% include "django/forms/widgets/attrs.html" with widget=selopt %} />
|
||||
{{ selopt.label }}
|
||||
</label>
|
||||
{% if selopt.value == "absolute" %}
|
||||
{% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %}
|
||||
{% elif selopt.value == "relative" %}
|
||||
{% include widget.subwidgets.2.template_name with widget=widget.subwidgets.2 %}
|
||||
{% trans "days before" %}
|
||||
{% include widget.subwidgets.3.template_name with widget=widget.subwidgets.3 %}
|
||||
{% trans "at" %}
|
||||
{% include widget.subwidgets.4.template_name with widget=widget.subwidgets.4 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
13
src/pretix/base/templatetags/escapejson.py
Normal file
13
src/pretix/base/templatetags/escapejson.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django import template
|
||||
from django.template.defaultfilters import stringfilter
|
||||
|
||||
from pretix.helpers.escapejson import escapejson
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter("escapejson")
|
||||
@stringfilter
|
||||
def escapejs_filter(value):
|
||||
"""Hex encodes characters for use in a application/json type script."""
|
||||
return escapejson(value)
|
||||
@@ -1,6 +1,12 @@
|
||||
import urllib.parse
|
||||
|
||||
import bleach
|
||||
import markdown
|
||||
from bleach import DEFAULT_CALLBACKS
|
||||
from django import template
|
||||
from django.core import signing
|
||||
from django.urls import reverse
|
||||
from django.utils.http import is_safe_url
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
@@ -48,6 +54,15 @@ ALLOWED_ATTRIBUTES = {
|
||||
}
|
||||
|
||||
|
||||
def safelink_callback(attrs, new=False):
|
||||
url = attrs.get((None, 'href'), '/')
|
||||
if not is_safe_url(url):
|
||||
signer = signing.Signer(salt='safe-redirect')
|
||||
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
||||
attrs[None, 'target'] = '_blank'
|
||||
return attrs
|
||||
|
||||
|
||||
@register.filter
|
||||
def rich_text(text: str, **kwargs):
|
||||
"""
|
||||
@@ -58,5 +73,5 @@ def rich_text(text: str, **kwargs):
|
||||
markdown.markdown(text),
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
))
|
||||
), callbacks=DEFAULT_CALLBACKS + [safelink_callback])
|
||||
return mark_safe(body_md)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from django import template
|
||||
|
||||
from pretix.helpers.safedownload import get_token
|
||||
|
||||
from ..views.redirect import safelink as sl
|
||||
|
||||
register = template.Library()
|
||||
@@ -8,3 +10,8 @@ register = template.Library()
|
||||
@register.simple_tag
|
||||
def safelink(url):
|
||||
return sl(url)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def answer_token(request, answer):
|
||||
return get_token(request, answer)
|
||||
|
||||
@@ -32,11 +32,26 @@ class BaseTicketOutput:
|
||||
"""
|
||||
return self.settings.get('_enabled', as_type=bool)
|
||||
|
||||
@property
|
||||
def multi_download_enabled(self) -> bool:
|
||||
"""
|
||||
Returns whether or not the ``generate_order`` method may be called. Returns
|
||||
``True`` by default.
|
||||
"""
|
||||
return True
|
||||
|
||||
def generate(self, position: OrderPosition) -> Tuple[str, str, str]:
|
||||
"""
|
||||
This method should generate the download file and return a tuple consisting of a
|
||||
filename, a file type and file content. The extension will be taken from the filename
|
||||
which is otherwise ignored.
|
||||
|
||||
.. note:: If the event uses the event series feature (internally called subevents)
|
||||
and your generated ticket contains information like the event name or date,
|
||||
you probably want to display the properties of the subevent. A common pattern
|
||||
to do this would be a declaration ``ev = position.subevent or position.order.event``
|
||||
and then access properties that are present on both classes like ``ev.name`` or
|
||||
``ev.date_from``.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class EventSlugBlacklistValidator(BlacklistValidator):
|
||||
'_global',
|
||||
'__debug__',
|
||||
'api',
|
||||
'events',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from django.http import HttpResponseForbidden, HttpResponseNotFound
|
||||
from django.http import (
|
||||
HttpResponseForbidden, HttpResponseNotFound, HttpResponseServerError,
|
||||
)
|
||||
from django.middleware.csrf import REASON_NO_CSRF_COOKIE, REASON_NO_REFERER
|
||||
from django.template import TemplateDoesNotExist, loader
|
||||
from django.template.loader import get_template
|
||||
from django.utils.functional import Promise
|
||||
from django.utils.translation import ugettext as _
|
||||
@@ -53,3 +56,14 @@ def page_not_found(request, exception):
|
||||
template = get_template('404.html')
|
||||
body = template.render(context, request)
|
||||
return HttpResponseNotFound(body)
|
||||
|
||||
|
||||
@requires_csrf_token
|
||||
def server_error(request):
|
||||
try:
|
||||
template = loader.get_template('500.html')
|
||||
except TemplateDoesNotExist:
|
||||
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
||||
return HttpResponseServerError(template.render({
|
||||
'request': request
|
||||
}))
|
||||
|
||||
@@ -9,10 +9,3 @@ from django.conf import settings
|
||||
app = Celery('pretix')
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
|
||||
|
||||
|
||||
if hasattr(settings, 'RAVEN_CONFIG'):
|
||||
# Celery signal registration
|
||||
from raven.contrib.celery import register_signal
|
||||
from raven.contrib.django.models import client
|
||||
register_signal(client, ignore_expected=True)
|
||||
|
||||
@@ -74,6 +74,7 @@ def contextprocessor(request):
|
||||
|
||||
ctx['js_datetime_format'] = get_javascript_format('DATETIME_INPUT_FORMATS')
|
||||
ctx['js_date_format'] = get_javascript_format('DATE_INPUT_FORMATS')
|
||||
ctx['js_time_format'] = get_javascript_format('TIME_INPUT_FORMATS')
|
||||
ctx['js_locale'] = get_moment_locale()
|
||||
|
||||
if settings.DEBUG and 'runserver' not in sys.argv:
|
||||
|
||||
@@ -88,3 +88,13 @@ class ExtFileField(forms.FileField):
|
||||
if ext not in self.ext_whitelist:
|
||||
raise forms.ValidationError(_("Filetype not allowed!"))
|
||||
return data
|
||||
|
||||
|
||||
class SlugWidget(forms.TextInput):
|
||||
template_name = 'pretixcontrol/slug_widget.html'
|
||||
prefix = ''
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
ctx = super().get_context(name, value, attrs)
|
||||
ctx['pre'] = self.prefix
|
||||
return ctx
|
||||
|
||||
@@ -10,7 +10,9 @@ from pytz import common_timezones, timezone
|
||||
|
||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
|
||||
from pretix.base.models import Event, Organizer
|
||||
from pretix.control.forms import ExtFileField
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||
from pretix.control.forms import ExtFileField, SlugWidget
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
|
||||
class EventWizardFoundationForm(forms.Form):
|
||||
@@ -20,6 +22,15 @@ class EventWizardFoundationForm(forms.Form):
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
help_text=_('Choose all languages that your event should be available in.')
|
||||
)
|
||||
has_subevents = forms.BooleanField(
|
||||
label=_("This is an event series"),
|
||||
help_text=_('Only recommended for advanced users. If this feature is enabled, this will not only be a '
|
||||
'single event but a series of very similar events that are handled within a single shop. '
|
||||
'The single events inside the series can only differ in date, time, location, prices and '
|
||||
'quotas, but not in other settings, and buying tickets across multiple of these events at '
|
||||
'the same time is possible. You cannot change this setting for this event later.'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user')
|
||||
@@ -67,15 +78,22 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-date-after': '#id_basics-presale_start'}),
|
||||
'slug': SlugWidget
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.organizer = kwargs.pop('organizer')
|
||||
self.locales = kwargs.get('locales')
|
||||
self.has_subevents = kwargs.pop('has_subevents')
|
||||
kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.initial['timezone'] = get_current_timezone_name()
|
||||
self.fields['locale'].choices = [(a, b) for a, b in settings.LANGUAGES if a in self.locales]
|
||||
self.fields['location'].widget.attrs['rows'] = '3'
|
||||
self.fields['slug'].widget.prefix = build_absolute_uri(self.organizer, 'presale:organizer.index')
|
||||
if self.has_subevents:
|
||||
del self.fields['presale_start']
|
||||
del self.fields['presale_end']
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
@@ -125,6 +143,7 @@ class EventWizardCopyForm(forms.Form):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.pop('organizer')
|
||||
kwargs.pop('locales')
|
||||
kwargs.pop('has_subevents')
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['copy_from_event'] = forms.ModelChoiceField(
|
||||
@@ -163,7 +182,8 @@ class EventUpdateForm(I18nModelForm):
|
||||
widgets = {
|
||||
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker', 'data-date-after': '#id_date_from'}),
|
||||
'date_admission': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'date_admission': forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-date-default': '#id_date_from'}),
|
||||
'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-date-after': '#id_presale_start'}),
|
||||
@@ -195,15 +215,15 @@ class EventSettingsForm(SettingsForm):
|
||||
presale_start_show_date = forms.BooleanField(
|
||||
label=_("Show start date"),
|
||||
help_text=_("Show the presale start date before presale has started."),
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_presale_start'}),
|
||||
widget=forms.CheckboxInput,
|
||||
required=False
|
||||
)
|
||||
last_order_modification_date = forms.DateTimeField(
|
||||
last_order_modification_date = RelativeDateTimeField(
|
||||
label=_('Last date of modifications'),
|
||||
help_text=_("The last date users can modify details of their orders, such as attendee names or "
|
||||
"answers to questions."),
|
||||
"answers to questions. If you use the event series feature and an order contains tickest for "
|
||||
"multiple event dates, the earliest date will be used."),
|
||||
required=False,
|
||||
widget=forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
)
|
||||
timezone = forms.ChoiceField(
|
||||
choices=((a, a) for a in common_timezones),
|
||||
@@ -288,7 +308,7 @@ class EventSettingsForm(SettingsForm):
|
||||
reservation_time = forms.IntegerField(
|
||||
min_value=0,
|
||||
label=_("Reservation period"),
|
||||
help_text=_("The number of minutes the items in a user's card are reserved for this user."),
|
||||
help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
|
||||
)
|
||||
imprint_url = forms.URLField(
|
||||
label=_("Imprint URL"),
|
||||
@@ -327,12 +347,12 @@ class PaymentSettingsForm(SettingsForm):
|
||||
label=_('Payment term in days'),
|
||||
help_text=_("The number of days after placing an order the user has to pay to preserve his reservation."),
|
||||
)
|
||||
payment_term_last = forms.DateField(
|
||||
payment_term_last = RelativeDateField(
|
||||
label=_('Last date of payments'),
|
||||
help_text=_("The last date any payments are accepted. This has precedence over the number of "
|
||||
"days configured above."),
|
||||
"days configured above. If you use the event series feature and an order contains tickets for "
|
||||
"multiple dates, the earliest date will be used."),
|
||||
required=False,
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'})
|
||||
)
|
||||
payment_term_weekdays = forms.BooleanField(
|
||||
label=_('Only end payment terms on weekdays'),
|
||||
@@ -365,7 +385,7 @@ class PaymentSettingsForm(SettingsForm):
|
||||
cleaned_data = super().clean()
|
||||
payment_term_last = cleaned_data.get('payment_term_last')
|
||||
if payment_term_last and self.obj.presale_end:
|
||||
if payment_term_last < self.obj.presale_end.date():
|
||||
if payment_term_last.date(self.obj) < self.obj.presale_end.date():
|
||||
self.add_error(
|
||||
'payment_term_last',
|
||||
_('The last payment date cannot be before the end of presale.'),
|
||||
@@ -392,6 +412,8 @@ class ProviderForm(SettingsForm):
|
||||
v._required = v.one_required
|
||||
v.one_required = False
|
||||
v.widget.enabled_locales = self.locales
|
||||
elif isinstance(v, (RelativeDateTimeField, RelativeDateField)):
|
||||
v.set_event(self.obj)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@@ -414,17 +436,39 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||
)
|
||||
invoice_name_required = forms.BooleanField(
|
||||
label=_("Require customer name"),
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(
|
||||
attrs={'data-checkbox-dependency': '#id_invoice_address_asked',
|
||||
'data-inverse-dependency': '#id_invoice_address_required'}
|
||||
),
|
||||
)
|
||||
invoice_address_vatid = forms.BooleanField(
|
||||
label=_("Ask for VAT ID"),
|
||||
help_text=_("Does only work if an invoice address is asked for. VAT ID is not required."),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||
required=False
|
||||
)
|
||||
invoice_include_free = forms.BooleanField(
|
||||
label=_("Show free products on invoices"),
|
||||
help_text=_("Note that invoices will never be generated for orders that contain only free "
|
||||
"products."),
|
||||
required=False
|
||||
)
|
||||
invoice_numbers_consecutive = forms.BooleanField(
|
||||
label=_("Generate invoices with consecutive numbers"),
|
||||
help_text=_("If deactivated, the order code will be used in the invoice number."),
|
||||
required=False
|
||||
)
|
||||
invoice_numbers_prefix = forms.CharField(
|
||||
label=_("Invoice number prefix"),
|
||||
help_text=_("This will be prepended to invoice numbers. If you leave this field empty, your event slug will "
|
||||
"be used followed by a dash. Attention: If multiple events within the same organization use the "
|
||||
"same value in this field, they will share their number range, i.e. every full number will be "
|
||||
"used at most once over all of your events. This setting only affects future invoices."),
|
||||
required=False,
|
||||
)
|
||||
invoice_generate = forms.ChoiceField(
|
||||
label=_("Generate invoices"),
|
||||
required=False,
|
||||
@@ -436,6 +480,11 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
('paid', _('Automatically on payment')),
|
||||
)
|
||||
)
|
||||
invoice_renderer = forms.ChoiceField(
|
||||
label=_("Invoice style"),
|
||||
required=True,
|
||||
choices=[]
|
||||
)
|
||||
invoice_address_from = forms.CharField(
|
||||
widget=forms.Textarea(attrs={'rows': 5}), required=False,
|
||||
label=_("Your address"),
|
||||
@@ -472,12 +521,20 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs.get('obj')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['invoice_renderer'].choices = [
|
||||
(r.identifier, r.verbose_name) for r in event.get_invoice_renderers().values()
|
||||
]
|
||||
self.fields['invoice_numbers_prefix'].widget.attrs['placeholder'] = event.slug.upper() + '-'
|
||||
|
||||
|
||||
class MailSettingsForm(SettingsForm):
|
||||
mail_prefix = forms.CharField(
|
||||
label=_("Subject prefix"),
|
||||
help_text=_("This will be prepended to the subject of all outgoing emails. This could be a short form of "
|
||||
"your event name."),
|
||||
help_text=_("This will be prepended to the subject of all outgoing emails, formatted as [prefix]. "
|
||||
"Choose, for example, a short form of your event name."),
|
||||
required=False
|
||||
)
|
||||
mail_from = forms.EmailField(
|
||||
@@ -558,6 +615,22 @@ class MailSettingsForm(SettingsForm):
|
||||
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}'])]
|
||||
)
|
||||
smtp_use_custom = forms.BooleanField(
|
||||
label=_("Use custom SMTP server"),
|
||||
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
|
||||
@@ -617,7 +690,7 @@ class DisplaySettingsForm(SettingsForm):
|
||||
)
|
||||
logo_image = ExtFileField(
|
||||
label=_('Logo image'),
|
||||
ext_whitelist=(".png", ".jpg", ".svg", ".gif", ".jpeg"),
|
||||
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.')
|
||||
@@ -639,12 +712,12 @@ class TicketSettingsForm(SettingsForm):
|
||||
help_text=_("Use pretix to generate tickets for the user to download and print out."),
|
||||
required=False
|
||||
)
|
||||
ticket_download_date = forms.DateTimeField(
|
||||
ticket_download_date = RelativeDateTimeField(
|
||||
label=_("Download date"),
|
||||
help_text=_("Ticket download will be offered after this date."),
|
||||
required=True,
|
||||
widget=forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-display-dependency': '#id_ticket_download'}),
|
||||
help_text=_("Ticket download will be offered after this date. If you use the event series feature and an order "
|
||||
"contains tickets for multiple event dates, download of all tickets will be available if at least "
|
||||
"one of the event dates allows it."),
|
||||
required=False,
|
||||
)
|
||||
ticket_download_addons = forms.BooleanField(
|
||||
label=_("Offer to download tickets separately for add-on products"),
|
||||
@@ -678,3 +751,15 @@ class TicketSettingsForm(SettingsForm):
|
||||
val = cleaned_data.get(k)
|
||||
if v._required and (val is None or val == ""):
|
||||
self.add_error(k, _('This field is required.'))
|
||||
|
||||
|
||||
class CommentForm(I18nModelForm):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ['comment']
|
||||
widgets = {
|
||||
'comment': forms.Textarea(attrs={
|
||||
'rows': 3,
|
||||
'class': 'helper-width-100',
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Concat
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Item, Order, Organizer
|
||||
from pretix.base.models import Item, Order, Organizer, SubEvent
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.control.utils.i18n import i18ncomp
|
||||
|
||||
@@ -46,11 +47,13 @@ class OrderFilterForm(FilterForm):
|
||||
|
||||
if fdata.get('query'):
|
||||
u = fdata.get('query')
|
||||
|
||||
if "-" in u:
|
||||
code = (Q(event__slug__icontains=u.split("-")[0])
|
||||
& Q(code__icontains=Order.normalize_code(u.split("-")[1])))
|
||||
else:
|
||||
code = Q(code__icontains=Order.normalize_code(u))
|
||||
qs = qs.annotate(inr=Concat('invoices__prefix', 'invoices__invoice_no'))
|
||||
qs = qs.filter(
|
||||
code
|
||||
| Q(email__icontains=u)
|
||||
@@ -58,6 +61,9 @@ class OrderFilterForm(FilterForm):
|
||||
| Q(positions__attendee_email__icontains=u)
|
||||
| Q(invoice_address__name__icontains=u)
|
||||
| Q(invoice_address__company__icontains=u)
|
||||
| Q(invoices__invoice_no=u)
|
||||
| Q(invoices__invoice_no=u.zfill(5))
|
||||
| Q(inr=u)
|
||||
)
|
||||
|
||||
if fdata.get('status'):
|
||||
@@ -86,6 +92,12 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
],
|
||||
required=False,
|
||||
)
|
||||
subevent = forms.ModelChoiceField(
|
||||
label=pgettext_lazy('subevent', 'Date'),
|
||||
queryset=SubEvent.objects.none(),
|
||||
required=False,
|
||||
empty_label=pgettext_lazy('subevent', 'All dates')
|
||||
)
|
||||
|
||||
def get_payment_providers(self):
|
||||
providers = []
|
||||
@@ -105,12 +117,20 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
self.fields['provider'].choices += [(k, v.verbose_name) for k, v
|
||||
in self.event.get_payment_providers().items()]
|
||||
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
elif 'subevent':
|
||||
del self.fields['subevent']
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
qs = super().filter_qs(qs)
|
||||
|
||||
if fdata.get('item'):
|
||||
qs = qs.filter(positions__item_id__in=(fdata.get('item'),))
|
||||
qs = qs.filter(positions__item=fdata.get('item'))
|
||||
|
||||
if fdata.get('subevent'):
|
||||
qs = qs.filter(positions__subevent=fdata.get('subevent'))
|
||||
|
||||
if fdata.get('provider'):
|
||||
qs = qs.filter(payment_provider=fdata.get('provider'))
|
||||
@@ -146,6 +166,57 @@ class OrderSearchFilterForm(OrderFilterForm):
|
||||
return qs
|
||||
|
||||
|
||||
class SubEventFilterForm(FilterForm):
|
||||
status = forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('active', _('Active')),
|
||||
('running', _('Shop live and presale running')),
|
||||
('inactive', _('Inactive')),
|
||||
('future', _('Presale not started')),
|
||||
('past', _('Presale over')),
|
||||
),
|
||||
required=False
|
||||
)
|
||||
query = forms.CharField(
|
||||
label=_('Event name'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('Event name'),
|
||||
'autofocus': 'autofocus'
|
||||
}),
|
||||
required=False
|
||||
)
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
|
||||
if fdata.get('status') == 'active':
|
||||
qs = qs.filter(active=True)
|
||||
elif fdata.get('status') == 'running':
|
||||
qs = qs.filter(
|
||||
active=True
|
||||
).filter(
|
||||
Q(presale_start__isnull=True) | Q(presale_start__lte=now())
|
||||
).filter(
|
||||
Q(presale_end__isnull=True) | Q(presale_end__gte=now())
|
||||
)
|
||||
elif fdata.get('status') == 'inactive':
|
||||
qs = qs.filter(active=False)
|
||||
elif fdata.get('status') == 'future':
|
||||
qs = qs.filter(presale_start__gte=now())
|
||||
elif fdata.get('status') == 'past':
|
||||
qs = qs.filter(presale_end__lte=now())
|
||||
|
||||
if fdata.get('query'):
|
||||
query = fdata.get('query')
|
||||
qs = qs.filter(
|
||||
Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query)
|
||||
)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class EventFilterForm(FilterForm):
|
||||
status = forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
|
||||
@@ -3,7 +3,6 @@ import copy
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Max
|
||||
from django.forms import BooleanField, ModelMultipleChoiceField
|
||||
from django.forms.formsets import DELETION_FIELD_NAME
|
||||
from django.utils.translation import ugettext as __, ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
@@ -52,7 +51,6 @@ class QuestionForm(I18nModelForm):
|
||||
|
||||
|
||||
class QuestionOptionForm(I18nModelForm):
|
||||
|
||||
class Meta:
|
||||
model = QuestionOption
|
||||
localized_fields = '__all__'
|
||||
@@ -62,36 +60,38 @@ class QuestionOptionForm(I18nModelForm):
|
||||
|
||||
|
||||
class QuotaForm(I18nModelForm):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
items = kwargs['items']
|
||||
del kwargs['items']
|
||||
instance = kwargs.get('instance', None)
|
||||
self.original_instance = copy.copy(instance) if instance else None
|
||||
self.instance = kwargs.get('instance', None)
|
||||
self.event = kwargs.get('event')
|
||||
items = kwargs.pop('items', None) or self.event.items.prefetch_related('variations')
|
||||
self.original_instance = copy.copy(self.instance) if self.instance else None
|
||||
initial = kwargs.get('initial', {})
|
||||
if self.instance and self.instance.pk:
|
||||
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()
|
||||
]
|
||||
kwargs['initial'] = initial
|
||||
super().__init__(**kwargs)
|
||||
|
||||
if hasattr(self, 'instance') and self.instance.pk:
|
||||
active_items = set(self.instance.items.all())
|
||||
active_variations = set(self.instance.variations.all())
|
||||
else:
|
||||
active_items = set()
|
||||
active_variations = set()
|
||||
|
||||
choices = []
|
||||
for item in items:
|
||||
if len(item.variations.all()) > 0:
|
||||
self.fields['item_%s' % item.id] = ModelMultipleChoiceField(
|
||||
label=_("Activate for"),
|
||||
required=False,
|
||||
initial=active_variations,
|
||||
queryset=item.variations.all(),
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
for v in item.variations.all():
|
||||
choices.append(('{}-{}'.format(item.pk, v.pk), '{} – {}'.format(item.name, v.value)))
|
||||
else:
|
||||
self.fields['item_%s' % item.id] = BooleanField(
|
||||
label=_("Activate"),
|
||||
required=False,
|
||||
initial=(item in active_items)
|
||||
)
|
||||
choices.append(('{}'.format(item.pk), item.name))
|
||||
|
||||
self.fields['itemvars'] = forms.MultipleChoiceField(
|
||||
label=_('Products'),
|
||||
required=False,
|
||||
choices=choices,
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
else:
|
||||
del self.fields['subevent']
|
||||
|
||||
class Meta:
|
||||
model = Quota
|
||||
@@ -99,10 +99,34 @@ class QuotaForm(I18nModelForm):
|
||||
fields = [
|
||||
'name',
|
||||
'size',
|
||||
'subevent'
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
creating = not self.instance.pk
|
||||
inst = super().save(*args, **kwargs)
|
||||
|
||||
selected_items = set(list(self.event.items.filter(id__in=[
|
||||
i.split('-')[0] for i in self.cleaned_data['itemvars']
|
||||
])))
|
||||
selected_variations = list(ItemVariation.objects.filter(item__event=self.event, id__in=[
|
||||
i.split('-')[1] for i in self.cleaned_data['itemvars'] if '-' in i
|
||||
]))
|
||||
|
||||
current_items = [] if creating else self.instance.items.all()
|
||||
current_variations = [] if creating else self.instance.variations.all()
|
||||
|
||||
self.instance.items.remove(*[i for i in current_items if i not in selected_items])
|
||||
self.instance.items.add(*[i for i in selected_items if i not in current_items])
|
||||
self.instance.variations.remove(*[i for i in current_variations if i not in selected_variations])
|
||||
self.instance.variations.add(*[i for i in selected_variations if i not in current_variations])
|
||||
return inst
|
||||
|
||||
|
||||
class ItemCreateForm(I18nModelForm):
|
||||
NONE = 'none'
|
||||
EXISTING = 'existing'
|
||||
NEW = 'new'
|
||||
has_variations = forms.BooleanField(label=_('The product should exist in multiple variations'),
|
||||
help_text=_('Select this option e.g. for t-shirts that come in multiple sizes. '
|
||||
'You can select the variations in the next step.'),
|
||||
@@ -111,6 +135,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs['event']
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['category'].queryset = self.instance.event.categories.all()
|
||||
self.fields['copy_from'] = forms.ModelChoiceField(
|
||||
label=_("Copy product information"),
|
||||
@@ -120,6 +145,41 @@ class ItemCreateForm(I18nModelForm):
|
||||
required=False
|
||||
)
|
||||
|
||||
if not self.event.has_subevents:
|
||||
self.fields['quota_option'] = forms.ChoiceField(
|
||||
label=_("Quota options"),
|
||||
widget=forms.RadioSelect,
|
||||
choices=(
|
||||
(self.NONE, _("Do not add to a quota now")),
|
||||
(self.EXISTING, _("Add product to an existing quota")),
|
||||
(self.NEW, _("Create a new quota for this product"))
|
||||
),
|
||||
initial=self.NONE,
|
||||
required=False
|
||||
)
|
||||
|
||||
self.fields['quota_add_existing'] = forms.ModelChoiceField(
|
||||
label=_("Add to existing quota"),
|
||||
widget=forms.Select(),
|
||||
queryset=self.instance.event.quotas.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
self.fields['quota_add_new_name'] = forms.CharField(
|
||||
label=_("Name"),
|
||||
max_length=200,
|
||||
widget=forms.TextInput(attrs={'placeholder': _("New quota name")}),
|
||||
required=False
|
||||
)
|
||||
|
||||
self.fields['quota_add_new_size'] = forms.IntegerField(
|
||||
min_value=0,
|
||||
label=_("Size"),
|
||||
widget=forms.TextInput(attrs={'placeholder': _("New quota size")}),
|
||||
help_text=_("Leave empty for an unlimited number of tickets."),
|
||||
required=False
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.cleaned_data.get('copy_from'):
|
||||
self.instance.description = self.cleaned_data['copy_from'].description
|
||||
@@ -135,6 +195,19 @@ class ItemCreateForm(I18nModelForm):
|
||||
|
||||
instance = super().save(*args, **kwargs)
|
||||
|
||||
if not self.event.has_subevents and not self.cleaned_data.get('has_variations'):
|
||||
if self.cleaned_data.get('quota_option') == self.EXISTING and self.cleaned_data.get('quota_add_existing') is not None:
|
||||
quota = self.cleaned_data.get('quota_add_existing')
|
||||
quota.items.add(self.instance)
|
||||
elif self.cleaned_data.get('quota_option') == self.NEW:
|
||||
quota_name = self.cleaned_data.get('quota_add_new_name')
|
||||
quota_size = self.cleaned_data.get('quota_add_new_size')
|
||||
|
||||
quota = Quota.objects.create(
|
||||
event=self.event, name=quota_name, size=quota_size
|
||||
)
|
||||
quota.items.add(self.instance)
|
||||
|
||||
if self.cleaned_data.get('has_variations'):
|
||||
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
|
||||
for variation in self.cleaned_data['copy_from'].variations.all():
|
||||
@@ -151,6 +224,23 @@ class ItemCreateForm(I18nModelForm):
|
||||
|
||||
return instance
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if not self.event.has_subevents:
|
||||
if cleaned_data.get('quota_option') == self.NEW:
|
||||
if not self.cleaned_data.get('quota_add_new_name'):
|
||||
raise forms.ValidationError(
|
||||
{'quota_add_new_name': [_("Quota name is required.")]}
|
||||
)
|
||||
elif cleaned_data.get('quota_option') == self.EXISTING:
|
||||
if not self.cleaned_data.get('quota_add_existing'):
|
||||
raise forms.ValidationError(
|
||||
{'quota_add_existing': [_("Please select a quota.")]}
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
localized_fields = '__all__'
|
||||
@@ -197,7 +287,6 @@ class ItemUpdateForm(I18nModelForm):
|
||||
|
||||
|
||||
class ItemVariationsFormSet(I18nFormSet):
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
for f in self.forms:
|
||||
@@ -282,6 +371,7 @@ class ItemAddOnForm(I18nModelForm):
|
||||
'addon_category',
|
||||
'min_count',
|
||||
'max_count',
|
||||
'price_included'
|
||||
]
|
||||
help_texts = {
|
||||
'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all '
|
||||
|
||||
@@ -4,10 +4,12 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator
|
||||
from pretix.base.models import Item, ItemAddOn, Order, OrderPosition
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.services.pricing import get_price
|
||||
|
||||
|
||||
class ExtendForm(I18nModelForm):
|
||||
@@ -55,6 +57,15 @@ class CommentForm(I18nModelForm):
|
||||
}
|
||||
|
||||
|
||||
class SubEventChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
p = get_price(self.instance.item, self.instance.variation,
|
||||
voucher=self.instance.voucher,
|
||||
subevent=obj)
|
||||
return '{} – {} ({} {})'.format(obj.name, obj.get_date_range_display(),
|
||||
p, self.instance.order.event.currency)
|
||||
|
||||
|
||||
class OrderPositionAddForm(forms.Form):
|
||||
do = forms.BooleanField(
|
||||
label=_('Add a new product to the order'),
|
||||
@@ -74,6 +85,12 @@ class OrderPositionAddForm(forms.Form):
|
||||
label=_('Gross price'),
|
||||
help_text=_("Keep empty for the product's default price")
|
||||
)
|
||||
subevent = forms.ModelChoiceField(
|
||||
SubEvent.objects.none(),
|
||||
label=pgettext_lazy('subevent', 'Date'),
|
||||
required=True,
|
||||
empty_label=None
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
order = kwargs.pop('order')
|
||||
@@ -100,9 +117,20 @@ class OrderPositionAddForm(forms.Form):
|
||||
else:
|
||||
del self.fields['addon_to']
|
||||
|
||||
if order.event.has_subevents:
|
||||
self.fields['subevent'].queryset = order.event.subevents.all()
|
||||
else:
|
||||
del self.fields['subevent']
|
||||
|
||||
|
||||
class OrderPositionChangeForm(forms.Form):
|
||||
itemvar = forms.ChoiceField()
|
||||
subevent = SubEventChoiceField(
|
||||
SubEvent.objects.none(),
|
||||
label=pgettext_lazy('subevent', 'New date'),
|
||||
required=True,
|
||||
empty_label=None
|
||||
)
|
||||
price = forms.DecimalField(
|
||||
required=False,
|
||||
max_digits=10, decimal_places=2,
|
||||
@@ -114,6 +142,7 @@ class OrderPositionChangeForm(forms.Form):
|
||||
choices=(
|
||||
('product', 'Change product'),
|
||||
('price', 'Change price'),
|
||||
('subevent', 'Change event date'),
|
||||
('cancel', 'Remove product')
|
||||
)
|
||||
)
|
||||
@@ -131,9 +160,15 @@ class OrderPositionChangeForm(forms.Form):
|
||||
pass
|
||||
|
||||
initial['price'] = instance.price
|
||||
initial['subevent'] = instance.subevent
|
||||
|
||||
kwargs['initial'] = initial
|
||||
super().__init__(*args, **kwargs)
|
||||
if instance.order.event.has_subevents:
|
||||
self.fields['subevent'].instance = instance
|
||||
self.fields['subevent'].queryset = instance.order.event.subevents.all()
|
||||
else:
|
||||
del self.fields['subevent']
|
||||
choices = []
|
||||
for i in instance.order.event.items.prefetch_related('variations').all():
|
||||
pname = str(i.name)
|
||||
@@ -142,11 +177,13 @@ class OrderPositionChangeForm(forms.Form):
|
||||
variations = list(i.variations.all())
|
||||
if variations:
|
||||
for v in variations:
|
||||
p = get_price(i, v, voucher=instance.voucher, subevent=instance.subevent)
|
||||
choices.append(('%d-%d' % (i.pk, v.pk),
|
||||
'%s – %s (%s %s)' % (pname, v.value, localize(v.price),
|
||||
'%s – %s (%s %s)' % (pname, v.value, localize(p),
|
||||
instance.order.event.currency)))
|
||||
else:
|
||||
choices.append((str(i.pk), '%s (%s %s)' % (pname, localize(i.default_price),
|
||||
p = get_price(i, None, voucher=instance.voucher, subevent=instance.subevent)
|
||||
choices.append((str(i.pk), '%s (%s %s)' % (pname, localize(p),
|
||||
instance.order.event.currency)))
|
||||
self.fields['itemvar'].choices = choices
|
||||
|
||||
@@ -178,3 +215,30 @@ class OrderLocaleForm(forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
locale_names = dict(settings.LANGUAGES)
|
||||
self.fields['locale'].choices = [(a, locale_names[a]) for a in self.instance.event.settings.locales]
|
||||
|
||||
|
||||
class OrderMailForm(forms.Form):
|
||||
subject = forms.CharField(
|
||||
label=_("Subject"),
|
||||
required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
order = kwargs.pop('order')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['sendto'] = forms.EmailField(
|
||||
label=_("Recipient"),
|
||||
required=True,
|
||||
initial=order.email
|
||||
)
|
||||
self.fields['sendto'].widget.attrs['readonly'] = 'readonly'
|
||||
self.fields['message'] = forms.CharField(
|
||||
label=_("Message"),
|
||||
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}'])]
|
||||
)
|
||||
|
||||
@@ -119,10 +119,25 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
help_text=_('This will be displayed on the organizer homepage.')
|
||||
)
|
||||
|
||||
organizer_info_text = I18nFormField(
|
||||
label=_('Info text'),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_('Not displayed anywhere by default, but if you want to, you can use this e.g. in ticket templates.')
|
||||
)
|
||||
|
||||
organizer_logo_image = ExtFileField(
|
||||
label=_('Logo image'),
|
||||
ext_whitelist=(".png", ".jpg", ".svg", ".gif", ".jpeg"),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
help_text=_('If you provide a logo image, we will by default not show your organization name '
|
||||
'in the page header. We will show your logo with a maximal height of 120 pixels.')
|
||||
)
|
||||
|
||||
event_list_type = forms.ChoiceField(
|
||||
label=_('Event overview stile'),
|
||||
choices=(
|
||||
('list', _('List')),
|
||||
('calendar', _('Calendar'))
|
||||
)
|
||||
)
|
||||
|
||||
101
src/pretix/control/forms/subevents.py
Normal file
101
src/pretix/control/forms/subevents.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from django import forms
|
||||
from django.utils.functional import cached_property
|
||||
from i18nfield.forms import I18nInlineFormSet
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import SubEventItem
|
||||
|
||||
|
||||
class SubEventForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs['event']
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['location'].widget.attrs['rows'] = '3'
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'name',
|
||||
'active',
|
||||
'date_from',
|
||||
'date_to',
|
||||
'date_admission',
|
||||
'presale_start',
|
||||
'presale_end',
|
||||
'location',
|
||||
'frontpage_text'
|
||||
]
|
||||
widgets = {
|
||||
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker', 'data-date-after': '#id_date_from'}),
|
||||
'date_admission': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-date-after': '#id_presale_start'}),
|
||||
}
|
||||
|
||||
|
||||
class SubEventItemOrVariationFormMixin:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.item = kwargs.pop('item')
|
||||
self.variation = kwargs.pop('variation', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class SubEventItemForm(SubEventItemOrVariationFormMixin, forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['price'].widget.attrs['placeholder'] = '{} {}'.format(
|
||||
self.item.default_price, self.item.event.currency
|
||||
)
|
||||
self.fields['price'].label = str(self.item.name)
|
||||
|
||||
class Meta:
|
||||
model = SubEventItem
|
||||
fields = ['price']
|
||||
|
||||
|
||||
class SubEventItemVariationForm(SubEventItemOrVariationFormMixin, forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['price'].widget.attrs['placeholder'] = '{} {}'.format(
|
||||
self.variation.price, self.item.event.currency
|
||||
)
|
||||
self.fields['price'].label = '{} – {}'.format(str(self.item.name), self.variation.value)
|
||||
|
||||
class Meta:
|
||||
model = SubEventItem
|
||||
fields = ['price']
|
||||
|
||||
|
||||
class QuotaFormSet(I18nInlineFormSet):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event', None)
|
||||
self.locales = self.event.settings.get('locales')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
return self.event.items.prefetch_related('variations').all()
|
||||
|
||||
def _construct_form(self, i, **kwargs):
|
||||
kwargs['locales'] = self.locales
|
||||
kwargs['event'] = self.event
|
||||
kwargs['items'] = self.items
|
||||
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,
|
||||
locales=self.locales,
|
||||
event=self.event,
|
||||
items=self.items
|
||||
)
|
||||
self.add_fields(form, None)
|
||||
return form
|
||||
@@ -4,7 +4,7 @@ from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
from pretix.base.models import Item, ItemVariation, Quota, Voucher
|
||||
@@ -24,7 +24,7 @@ class VoucherForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
|
||||
'comment', 'max_usages', 'price_mode'
|
||||
'comment', 'max_usages', 'price_mode', 'subevent'
|
||||
]
|
||||
widgets = {
|
||||
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
@@ -47,6 +47,12 @@ class VoucherForm(I18nModelForm):
|
||||
else:
|
||||
self.initial_instance_data = None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if instance.event.has_subevents:
|
||||
self.fields['subevent'].queryset = instance.event.subevents.all()
|
||||
elif 'subevent':
|
||||
del self.fields['subevent']
|
||||
|
||||
choices = []
|
||||
for i in self.instance.event.items.prefetch_related('variations').all():
|
||||
variations = list(i.variations.all())
|
||||
@@ -103,6 +109,12 @@ class VoucherForm(I18nModelForm):
|
||||
else:
|
||||
cnt = data['max_usages']
|
||||
|
||||
if self.instance.event.has_subevents and data['block_quota'] and not data.get('subevent'):
|
||||
raise ValidationError(pgettext_lazy(
|
||||
'subevent',
|
||||
'If you want this voucher to block quota, you need to select a specific date.'
|
||||
))
|
||||
|
||||
if self._clean_quota_needs_checking(data):
|
||||
self._clean_quota_check(data, cnt)
|
||||
|
||||
@@ -136,6 +148,10 @@ class VoucherForm(I18nModelForm):
|
||||
# The voucher has been reassigned to a different item, variation or quota
|
||||
return True
|
||||
|
||||
if data.get('subevent') != self.initial.get('subevent'):
|
||||
# The voucher has been reassigned to a different subevent
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _clean_was_valid(self):
|
||||
@@ -147,9 +163,11 @@ class VoucherForm(I18nModelForm):
|
||||
if self.initial_instance_data.quota:
|
||||
quotas.add(self.initial_instance_data.quota)
|
||||
elif self.initial_instance_data.variation:
|
||||
quotas |= set(self.initial_instance_data.variation.quotas.all())
|
||||
quotas |= set(self.initial_instance_data.variation.quotas.filter(
|
||||
subevent=self.initial_instance_data.subevent))
|
||||
elif self.initial_instance_data.item:
|
||||
quotas |= set(self.initial_instance_data.item.quotas.all())
|
||||
quotas |= set(self.initial_instance_data.item.quotas.filter(
|
||||
subevent=self.initial_instance_data.subevent))
|
||||
return quotas
|
||||
|
||||
def _clean_quota_check(self, data, cnt):
|
||||
@@ -164,9 +182,9 @@ class VoucherForm(I18nModelForm):
|
||||
raise ValidationError(_('You can only block quota if you specify a specific product variation. '
|
||||
'Otherwise it might be unclear which quotas to block.'))
|
||||
elif self.instance.item and self.instance.variation:
|
||||
avail = self.instance.variation.check_quotas(ignored_quotas=old_quotas)
|
||||
avail = self.instance.variation.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent'))
|
||||
elif self.instance.item and not self.instance.item.has_variations:
|
||||
avail = self.instance.item.check_quotas(ignored_quotas=old_quotas)
|
||||
avail = self.instance.item.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent'))
|
||||
else:
|
||||
raise ValidationError(_('You need to specify either a quota or a product.'))
|
||||
|
||||
@@ -195,7 +213,7 @@ class VoucherBulkForm(VoucherForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
|
||||
'max_usages', 'price_mode'
|
||||
'max_usages', 'price_mode', 'subevent'
|
||||
]
|
||||
widgets = {
|
||||
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
|
||||
@@ -3,7 +3,7 @@ from decimal import Decimal
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils import formats
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.models import Event, ItemVariation, LogEntry, OrderPosition
|
||||
@@ -33,6 +33,17 @@ def _display_order_changed(event: Event, logentry: LogEntry):
|
||||
new_price=formats.localize(Decimal(data['new_price'])),
|
||||
currency=event.currency
|
||||
)
|
||||
elif logentry.action_type == 'pretix.event.order.changed.subevent':
|
||||
old_se = str(event.subevents.get(pk=data['old_subevent']))
|
||||
new_se = str(event.subevents.get(pk=data['new_subevent']))
|
||||
return text + ' ' + _('Position #{posid}: Event date "{old_event}" ({old_price} {currency}) changed '
|
||||
'to "{new_event}" ({new_price} {currency}).').format(
|
||||
posid=data.get('positionid', '?'),
|
||||
old_event=old_se, new_event=new_se,
|
||||
old_price=formats.localize(Decimal(data['old_price'])),
|
||||
new_price=formats.localize(Decimal(data['new_price'])),
|
||||
currency=event.currency
|
||||
)
|
||||
elif logentry.action_type == 'pretix.event.order.changed.price':
|
||||
return text + ' ' + _('Price of position #{posid} changed from {old_price} {currency} '
|
||||
'to {new_price} {currency}.').format(
|
||||
@@ -76,9 +87,9 @@ def _display_order_changed(event: Event, logentry: LogEntry):
|
||||
@receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display")
|
||||
def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
plains = {
|
||||
'pretix.event.comment': _('The event\'s internal comment has been updated.'),
|
||||
'pretix.event.order.modified': _('The order details have been modified.'),
|
||||
'pretix.event.order.unpaid': _('The order has been marked as unpaid.'),
|
||||
'pretix.event.order.resend': _('The link to the order detail page has been resent to the user.'),
|
||||
'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'),
|
||||
'pretix.event.order.expirychanged': _('The order\'s expiry date has been changed.'),
|
||||
'pretix.event.order.expired': _('The order has been marked as expired.'),
|
||||
@@ -93,8 +104,16 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.invoice.reissued': _('The invoice has been reissued.'),
|
||||
'pretix.event.order.comment': _('The order\'s internal comment has been updated.'),
|
||||
'pretix.event.order.payment.changed': _('The payment method has been changed.'),
|
||||
'pretix.event.order.expire_warning_sent': _('An email has been sent with a warning that the order is about '
|
||||
'to expire.'),
|
||||
'pretix.event.order.email.sent': _('An unindentified type email has been sent.'),
|
||||
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
|
||||
'pretix.event.order.email.expire_warning_sent': _('An email has been sent with a warning that the order is about '
|
||||
'to expire.'),
|
||||
'pretix.event.order.email.order_canceled': _('An email has been sent to notify user order has been canceled.'),
|
||||
'pretix.event.order.email.order_changed': _('An email has been sent to notify user order has been changed.'),
|
||||
'pretix.event.order.email.order_free': _('An email has been sent to notify user order has been received.'),
|
||||
'pretix.event.order.email.order_paid': _('An email has been sent to notify user payment has been received.'),
|
||||
'pretix.event.order.email.order_placed': _('An email has been sent to notify user order has been received and require payment.'),
|
||||
'pretix.event.order.email.resend': _('An email has been sent with link to the order detail page has been resent to the user.'),
|
||||
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
|
||||
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
|
||||
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
|
||||
@@ -145,6 +164,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.team.created': _('The team has been created.'),
|
||||
'pretix.team.changed': _('The team settings have been modified.'),
|
||||
'pretix.team.deleted': _('The team has been deleted.'),
|
||||
'pretix.subevent.deleted': pgettext_lazy('subevent', 'The event date has been deleted.'),
|
||||
'pretix.subevent.changed': pgettext_lazy('subevent', 'The event date has been modified.'),
|
||||
'pretix.subevent.added': pgettext_lazy('subevent', 'The event date has been created.'),
|
||||
'pretix.subevent.quota.added': pgettext_lazy('subevent', 'A quota has been added to the event date.'),
|
||||
'pretix.subevent.quota.changed': pgettext_lazy('subevent', 'A quota has been modified on the event date.'),
|
||||
'pretix.subevent.quota.deleted': pgettext_lazy('subevent', 'A quota has been removed from the event date.'),
|
||||
}
|
||||
|
||||
data = json.loads(logentry.data)
|
||||
|
||||
@@ -44,8 +44,9 @@
|
||||
{{ html_head|safe }}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
{% block custom_header %}{% endblock %}
|
||||
</head>
|
||||
<body data-datetimeformat="{{ js_datetime_format }}" data-dateformat="{{ js_date_format }}" data-datetimelocale="{{ js_locale }}" data-payment-weekdays-disabled="{{ js_payment_weekdays_disabled }}">
|
||||
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}" data-dateformat="{{ js_date_format }}" data-datetimelocale="{{ js_locale }}" data-payment-weekdays-disabled="{{ js_payment_weekdays_disabled }}">
|
||||
<div id="wrapper">
|
||||
<nav class="navbar navbar-inverse navbar-static-top" role="navigation">
|
||||
<div class="navbar-header">
|
||||
@@ -60,6 +61,22 @@
|
||||
data-toggle="collapse" data-target=".navbar-events-collapse">
|
||||
<i class="fa fa-calendar"></i><span class="caret"></span>
|
||||
</button>
|
||||
{% if request.event %}
|
||||
{% if has_domain and not request.event.live %}
|
||||
<form action="{% eventurl request.event "presale:event.auth" %}" method="post"
|
||||
target="_blank" class="mobile-navbar-view-form visible-xs-block">
|
||||
<input type="hidden" value="{{ new_session }}" name="session">
|
||||
<button type="submit" class="btn btn-link navbar-toggle">
|
||||
<i class="fa fa-eye"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="{% eventurl request.event "presale:event.index" %}" title="{% trans "Go to Shop" %}"
|
||||
target="_blank" class="navbar-toggle mobile-navbar-view-link">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a class="navbar-brand" href="{% url "control:index" %}">
|
||||
<img src="{% static "pretixbase/img/pretix-icon.svg" %}" />
|
||||
{{ settings.PRETIX_INSTANCE_NAME }}
|
||||
|
||||
@@ -21,6 +21,17 @@
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if request.event.has_subevents %}
|
||||
<select name="subevent" class="form-control">
|
||||
<option value="">{% trans "All dates" context "subevent" %}</option>
|
||||
{% for se in request.event.subevents.all %}
|
||||
<option value="{{ se.id }}"
|
||||
{% if request.GET.subevent|add:0 == se.id %}selected="selected"{% endif %}>
|
||||
{{ se.name }} – {{ se.get_date_range_display }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
<input type="text" name="user" class="form-control" placeholder="{% trans "Search user" %}" value="{{ request.GET.user }}">
|
||||
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
</form>
|
||||
@@ -45,6 +56,10 @@
|
||||
<a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th>{% trans "Item" %} <a href="?{% url_replace request 'ordering' '-item'%}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'item'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
{% if request.event.has_subevents %}
|
||||
<th>{% trans "Date" context "subevent" %} <a href="?{% url_replace request 'ordering' '-subevent'%}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'subevent'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
{% endif %}
|
||||
<th>{% trans "Email" %} <a href="?{% url_replace request 'ordering' '-email'%}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'email'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th>{% trans "Name" %} <a href="?{% url_replace request 'ordering' '-name'%}"><i class="fa fa-caret-down"></i></a>
|
||||
@@ -60,10 +75,12 @@
|
||||
{% with e.checkins.first as checkin %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=e.order.code %}"
|
||||
>{{ e.order.code }}</a></strong>
|
||||
<strong><a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=e.order.code %}">{{ e.order.code }}</a></strong>
|
||||
</td>
|
||||
<td>{{ e.item.name }}</td>
|
||||
<td>{{ e.item.name }}{% if e.variation %} – {{ e.variation }}{% endif %}</td>
|
||||
{% if request.event.has_subevents %}
|
||||
<td>{{ e.subevent.name }} – {{ e.subevent.get_date_range_display }}</td>
|
||||
{% endif %}
|
||||
<td>{{ e.order.email }}</td>
|
||||
<td>
|
||||
{% if e.addon_to %}
|
||||
|
||||
@@ -11,13 +11,23 @@
|
||||
{% trans "Dashboard" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if 'can_change_event_settings' in request.eventpermset or 'can_change_permissions' in request.eventpermset %}
|
||||
{% if 'can_change_event_settings' in request.eventpermset %}
|
||||
<li>
|
||||
<a href="{% url 'control:event.settings' organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||
<a href="{% url 'control:event.settings' organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
{% if "event.settings" == url_name or "event.settings." in url_name %}class="active"{% endif %}>
|
||||
<i class="fa fa-wrench fa-fw"></i>
|
||||
{% trans "Settings" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if request.event.has_subevents %}
|
||||
<li>
|
||||
<a href="{% url 'control:event.subevents' organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
{% if "event.subevent" in url_name %}class="active"{% endif %}>
|
||||
<i class="fa fa-calendar fa-fw"></i>
|
||||
{% trans "Dates" context "subevent" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if 'can_change_items' in request.eventpermset %}
|
||||
<li>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load bootstrap3 %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{{ request.event.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{{ request.event.name }}
|
||||
</h1>
|
||||
<h1>
|
||||
{{ request.event.name }}
|
||||
</h1>
|
||||
{% if actions|length > 0 %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading">
|
||||
@@ -19,10 +20,12 @@
|
||||
<li class="list-group-item logentry">
|
||||
<p>
|
||||
<a href="{% url "control:event.requiredaction.discard" event=request.event.slug organizer=request.event.organizer.slug id=action.id %}"
|
||||
class="btn btn-default btn-xs pull-right">
|
||||
class="btn btn-default btn-xs pull-right">
|
||||
{% trans "Hide message" %}
|
||||
</a>
|
||||
<small><span class="fa fa-clock-o"></span> {{ action.datetime|date:"SHORT_DATETIME_FORMAT" }}</small>
|
||||
<small><span
|
||||
class="fa fa-clock-o"></span> {{ action.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
</small>
|
||||
</p>
|
||||
{{ action.display|safe }}
|
||||
</li>
|
||||
@@ -57,6 +60,28 @@
|
||||
</div>
|
||||
|
||||
<p> </p>
|
||||
<div class="panel panel-default items">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Internal comment" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form class="form" method="post"
|
||||
action="{% url "control:event.comment" event=request.event.slug organizer=request.event.organizer.slug %}">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
{% bootstrap_field comment_form.comment layout="horizontal" show_help=True show_label=False horizontal_field_class="col-md-12" %}
|
||||
</div>
|
||||
<p class="text-right">
|
||||
<br>
|
||||
<button class="btn btn-default">
|
||||
{% trans "Update comment" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
@@ -74,8 +99,8 @@
|
||||
{% if log.user %}
|
||||
{% if log.user.is_superuser %}
|
||||
<span class="fa fa-id-card fa-danger fa-fw"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This change was performed by a pretix administrator." %}">
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This change was performed by a pretix administrator." %}">
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
<legend>{% trans "Invoicing" %}</legend>
|
||||
{% bootstrap_field form.invoice_address_asked layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_address_required layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_name_required layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_address_vatid layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_numbers_consecutive layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_numbers_prefix layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_generate layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_renderer layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_language layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_include_free layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_address_from layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_introductory_text layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_additional_text layout="horizontal" %}
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
|
||||
{% 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_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" %}
|
||||
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#{{ pid }}">
|
||||
<a class="collapsed" data-toggle="collapse" href="#{{ pid }}">
|
||||
<strong>{% trans title %}</strong>
|
||||
<i class="fa fa-angle-down collapse-indicator"></i>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
@@ -23,6 +24,14 @@
|
||||
{% with form|getattr:item as field %}
|
||||
<label class="col-md-3 control-label">{{ field.label }}</label>
|
||||
<div class="col-md-9">
|
||||
<ul class="nav nav-tabs">
|
||||
<li role="presentation" class="active">
|
||||
<a data-toggle="tab" type="edit" href="#{{ item }}_edit"><i class="fa fa-pencil-square-o fa-fw"></i> {% trans "Edit" %}</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a data-toggle="tab" type="preview" href="#{{ item }}_preview"><i class="fa fa-tv fa-fw"></i> {% trans "Preview" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div id="{{ item }}_edit" class="tab-pane fade in active">
|
||||
{% bootstrap_field field show_label=False form_group_class="" %}
|
||||
@@ -33,14 +42,6 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<ul class="nav nav-pills pull-right">
|
||||
<li role="presentation" class="active">
|
||||
<a data-toggle="pill" type="edit" href="#{{ item }}_edit"><i class="fa fa-pencil-square-o fa-fw"></i> {% trans "Edit" %}</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a data-toggle="pill" type="preview" href="#{{ item }}_preview"><i class="fa fa-tv fa-fw"></i> {% trans "Preview" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
@@ -22,22 +22,19 @@
|
||||
Please note that EU Directive 2015/2366 bans surcharging payment fees for most common payment
|
||||
methods within the European Union. Depending on the payment method, this might affect
|
||||
selling to consumers only or to business customers as well. Depending on your country, this
|
||||
legislation might already be in place or become relevant from January 2018 the latest. This is not
|
||||
legal advice. If in doubt, consult a lawyer or refrain from charging payment fees.
|
||||
legislation might already be in effect or become relevant from January 2018 at the latest. This
|
||||
is not legal advice. If in doubt, consult a lawyer or refrain from charging payment fees.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% for provider in providers %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="row">
|
||||
<div class="col-sm-10">
|
||||
<h3 class="panel-title">
|
||||
<a data-toggle="collapse" href="#{{ provider.identifier }}">
|
||||
{{ provider.verbose_name }}
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="panel-title">
|
||||
<a class="collapsed" data-toggle="collapse" href="#{{ provider.identifier }}">
|
||||
{{ provider.verbose_name }}
|
||||
<i class="fa fa-angle-down collapse-indicator"></i>
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div id="{{ provider.identifier }}" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{% trans "Your changes have been saved." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<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 %}">
|
||||
|
||||
@@ -5,9 +5,30 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="horizontal" %}
|
||||
{% trans "Random" as rndlabel %}
|
||||
{% url "control:events.add.slugrng" organizer=organizer.slug as rngurl %}
|
||||
{% bootstrap_field form.slug layout="horizontal" addon_after='<button class="btn btn-default" type="button" id="event-slug-random-generate" data-rng-url="'|add:rngurl|add:'">'|add:rndlabel|add:'</button>' addon_after_class='input-group-btn' %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="{{ form.slug.id_for_label }}">{{ form.slug.label }}</label>
|
||||
<div class="col-md-9 form-inline">
|
||||
<button class="btn btn-default pull-right" type="button" id="event-slug-random-generate"
|
||||
data-rng-url="{% url "control:events.add.slugrng" organizer=organizer.slug %}">
|
||||
{% trans "Set to random" %}
|
||||
</button>
|
||||
{% bootstrap_field form.slug form_group_class="helper-display-inline" show_label=False layout="inline" %}
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
This is the address users can buy your tickets at. Should be short, only contain lowercase
|
||||
letters and numbers, and must be unique among your events. We recommend some kind of
|
||||
abbreviation or a date with less than 10 characters that can be easily remembered, but you
|
||||
can also choose to use a random value.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
We will also use this in some places like order codes, invoice numbers or bank transfer
|
||||
references as an abbreviation to reference this event.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% bootstrap_field form.date_from layout="horizontal" %}
|
||||
{% bootstrap_field form.date_to layout="horizontal" %}
|
||||
{% bootstrap_field form.location layout="horizontal" %}
|
||||
@@ -18,9 +39,11 @@
|
||||
{% bootstrap_field form.locale layout="horizontal" %}
|
||||
{% bootstrap_field form.timezone layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Timeline" %}</legend>
|
||||
{% bootstrap_field form.presale_start layout="horizontal" %}
|
||||
{% bootstrap_field form.presale_end layout="horizontal" %}
|
||||
</fieldset>
|
||||
{% if form.presale_start %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Timeline" %}</legend>
|
||||
{% bootstrap_field form.presale_start layout="horizontal" %}
|
||||
{% bootstrap_field form.presale_end layout="horizontal" %}
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
{% block form %}
|
||||
{% bootstrap_field form.organizer layout="horizontal" %}
|
||||
{% bootstrap_field form.locales layout="horizontal" %}
|
||||
{% bootstrap_field form.has_subevents layout="horizontal" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Event name" %}</th>
|
||||
<th>{% trans "Short form" %}</th>
|
||||
<th>{% trans "Organizer" %}</th>
|
||||
<th>{% trans "Start date" %}</th>
|
||||
<th>{% trans "End date" %}</th>
|
||||
@@ -60,6 +61,7 @@
|
||||
<td>
|
||||
<strong><a href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}">{{ e.name }}</a></strong>
|
||||
</td>
|
||||
<td>{{ e.slug }}</td>
|
||||
<td>{{ e.organizer }}</td>
|
||||
<td>{{ e.get_date_from_display }}</td>
|
||||
<td>{{ e.get_date_to_display }}</td>
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
{% bootstrap_field form.addon_category layout='horizontal' %}
|
||||
{% bootstrap_field form.min_count layout='horizontal' %}
|
||||
{% bootstrap_field form.max_count layout='horizontal' %}
|
||||
{% bootstrap_field form.price_included layout='horizontal' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -78,6 +79,7 @@
|
||||
{% bootstrap_field formset.empty_form.addon_category layout='horizontal' %}
|
||||
{% bootstrap_field formset.empty_form.min_count layout='horizontal' %}
|
||||
{% bootstrap_field formset.empty_form.max_count layout='horizontal' %}
|
||||
{% bootstrap_field formset.empty_form.price_included layout='horizontal' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inside %}
|
||||
{% load static %}
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/hidequota.js" %}"></script>
|
||||
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
@@ -12,6 +15,19 @@
|
||||
{% bootstrap_field form.category layout="horizontal" %}
|
||||
{% bootstrap_field form.admission layout="horizontal" %}
|
||||
</fieldset>
|
||||
{% if form.quota_option %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Quota settings" %}</legend>
|
||||
{% bootstrap_field form.quota_option layout="horizontal" %}
|
||||
<div id="existing-quota-group">
|
||||
{% bootstrap_field form.quota_add_existing layout="horizontal" %}
|
||||
</div>
|
||||
<div id="new-quota-group">
|
||||
{% bootstrap_field form.quota_add_new_name layout="horizontal" %}
|
||||
{% bootstrap_field form.quota_add_new_size layout="horizontal" %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Price settings" %}</legend>
|
||||
{% bootstrap_field form.default_price layout="horizontal" %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user