mirror of
https://github.com/pretix/pretix.git
synced 2026-01-08 22:02:28 +00:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38fc826053 | ||
|
|
300578a44b | ||
|
|
dc2bcdcfbc | ||
|
|
7e18e89012 | ||
|
|
24f47722c0 | ||
|
|
04b679a4a7 | ||
|
|
f6713008aa | ||
|
|
15dc62855b | ||
|
|
4ed3df2b08 | ||
|
|
8a3eaae29c | ||
|
|
22edc016dd | ||
|
|
5205daae6d | ||
|
|
7ea79ebe56 | ||
|
|
3bfa8bd81e | ||
|
|
39abf63698 | ||
|
|
f68a6d1119 | ||
|
|
1a1a02d080 | ||
|
|
dacffc5f90 | ||
|
|
f2068b2663 | ||
|
|
989282ffbe | ||
|
|
e469b2e6ad | ||
|
|
8eaada992f | ||
|
|
f5dba45fa0 | ||
|
|
e72b5893c4 | ||
|
|
e78a176e9f | ||
|
|
8143999803 | ||
|
|
219c2c94e8 | ||
|
|
37f612801f | ||
|
|
0b12b7aa89 | ||
|
|
14da25bd9e | ||
|
|
3a713541a2 | ||
|
|
c7a547a875 | ||
|
|
e12caf186c | ||
|
|
1ee6e31538 | ||
|
|
083c94403b | ||
|
|
67121decbf | ||
|
|
fcd6bb1084 | ||
|
|
a81a4b895a | ||
|
|
c50c5177b8 | ||
|
|
30eefe57ef | ||
|
|
ce33cce5a9 | ||
|
|
d0dfde382c | ||
|
|
7fb2d0526e | ||
|
|
fb34467cba | ||
|
|
7e62cddb97 | ||
|
|
78b31149b5 | ||
|
|
817038563f | ||
|
|
56ca2305bd | ||
|
|
fc7bafe3d9 | ||
|
|
d622f38e1d | ||
|
|
139810c8a5 | ||
|
|
f8cc332ed7 | ||
|
|
db24bd4d78 | ||
|
|
d056013296 | ||
|
|
7e647f7085 | ||
|
|
322068b5e0 | ||
|
|
96247d5fa0 | ||
|
|
6b7338aff0 | ||
|
|
59d85cc218 | ||
|
|
7f90fdedf1 | ||
|
|
7723c956bc | ||
|
|
d0c10a8f72 | ||
|
|
c56dd52bd6 | ||
|
|
a7374f5bbd | ||
|
|
251d62f3c4 | ||
|
|
b8c041d0d6 | ||
|
|
dd42037f21 | ||
|
|
50575d45c1 | ||
|
|
7268c7fb70 | ||
|
|
83572960d5 | ||
|
|
39f22fa314 | ||
|
|
69ab5d8c2e | ||
|
|
58111465bc | ||
|
|
697e56962a |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -6,6 +6,7 @@ src/static/datetimepicker/* linguist-vendored
|
||||
src/static/colorpicker/* linguist-vendored
|
||||
src/static/fileupload/* linguist-vendored
|
||||
src/static/vuejs/* linguist-vendored
|
||||
src/static/select2/* linguist-vendored
|
||||
src/static/charts/* linguist-vendored
|
||||
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/fabric.* linguist-vendored
|
||||
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/pdf.* linguist-vendored
|
||||
|
||||
@@ -10,6 +10,8 @@ Resources and endpoints
|
||||
taxrules
|
||||
categories
|
||||
items
|
||||
item_variations
|
||||
item_add-ons
|
||||
questions
|
||||
quotas
|
||||
orders
|
||||
|
||||
246
doc/api/resources/item_add-ons.rst
Normal file
246
doc/api/resources/item_add-ons.rst
Normal file
@@ -0,0 +1,246 @@
|
||||
Item add-ons
|
||||
============
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
With add-ons, you can specify products that can be bought as an addition to this specific product. For example, if you
|
||||
host a conference with a base conference ticket and a number of workshops, you could define the workshops as add-ons to
|
||||
the conference ticket. With this configuration, the workshops cannot be bought on their own but only in combination with
|
||||
a conference ticket. You can here specify categories of products that can be used as add-ons to this product. You can
|
||||
also specify the minimum and maximum number of add-ons of the given category that can or need to be chosen. The user can
|
||||
buy every add-on from the category at most once. If an add-on product has multiple variations, only one of them can be
|
||||
bought.
|
||||
The add-ons resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the add-on
|
||||
addon_category integer Internal ID of the item category the add-on can be
|
||||
chosen from.
|
||||
min_count integer The minimal number of add-ons that need to be chosen.
|
||||
max_count integer The maximal number of add-ons that can be chosen.
|
||||
position integer An integer, used for sorting
|
||||
price_included boolean Adding this add-on to the item is free
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
This resource has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/
|
||||
|
||||
Returns a list of all add-ons for a given item.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/11/addons/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 2,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 3,
|
||||
"addon_category": 1,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 0,
|
||||
"price_included": true
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"addon_category": 2,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"price_included": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param item: The ``id`` field of the item to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/(id)/
|
||||
|
||||
Returns information on one add-on, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/1/addons/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"addon_category": 1,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param item: The ``id`` field of the item to fetch
|
||||
:param id: The ``id`` field of the add-on 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:post:: /api/v1/organizers/bigevents/events/sampleconf/items/1/addons/
|
||||
|
||||
Creates a new add-on
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"addon_category": 1,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"addon_category": 1,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/item to create a add-on for
|
||||
:param event: The ``slug`` field of the event to create a add-on for
|
||||
:param item: The ``id`` field of the item to create a add-on for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The add-on could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/addon/(id)/
|
||||
|
||||
Update an add-on. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/addons/3/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"addon_category": 1,
|
||||
"min_count": 0,
|
||||
"max_count": 10,
|
||||
"position": 1,
|
||||
"price_included": true
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param item: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the add-on to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The add-on could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/addons/(id)/
|
||||
|
||||
Delete an add-on.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/addons/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the add-on to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
258
doc/api/resources/item_variations.rst
Normal file
258
doc/api/resources/item_variations.rst
Normal file
@@ -0,0 +1,258 @@
|
||||
Item variations
|
||||
===============
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
Variations of items can be use for products (items) that are available in different sizes, colors or other variations
|
||||
of the same product.
|
||||
The addons resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the variation
|
||||
default_price money (string) The price set directly for this variation or ``null``
|
||||
price money (string) The price used for this variation. This is either the
|
||||
same as ``default_price`` if that value is set or equal
|
||||
to the item's ``default_price`` (read-only).
|
||||
active boolean If ``False``, this variation will not be sold or shown.
|
||||
description multi-lingual string A public description of the variation. May contain
|
||||
Markdown syntax or can be ``null``.
|
||||
position integer An integer, used for sorting
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
This resource has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/variations/
|
||||
|
||||
Returns a list of all variations for a given item.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/11/variations/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 2,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"value": {
|
||||
"en": "S"
|
||||
},
|
||||
"active": true,
|
||||
"description": {
|
||||
"en": "Test2"
|
||||
},
|
||||
"position": 0,
|
||||
"default_price": "223.00",
|
||||
"price": 223.0
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"value": {
|
||||
"en": "L"
|
||||
},
|
||||
"active": true,
|
||||
"description": {},
|
||||
"position": 1,
|
||||
"default_price": null,
|
||||
"price": 15.0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query boolean active: If set to ``true`` or ``false``, only items with this value for the field ``active`` will be
|
||||
returned.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param item: The ``id`` field of the item to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/variations/(id)/
|
||||
|
||||
Returns information on one variation, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"value": {
|
||||
"en": "Student"
|
||||
},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param item: The ``id`` field of the item to fetch
|
||||
:param id: The ``id`` field of the variation 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:post:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/variations/
|
||||
|
||||
Creates a new variation
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/item to create a variation for
|
||||
:param event: The ``slug`` field of the event to create a variation for
|
||||
:param item: The ``id`` field of the item to create a variation for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The variation could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/variations/(id)/
|
||||
|
||||
Update a variation. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` and the ``price`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"active": false,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": false,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the variation to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The variation could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/variations/(id)/
|
||||
|
||||
Delete a variation.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the variation to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
@@ -33,6 +33,7 @@ admission boolean ``True`` for it
|
||||
(such as add-ons or merchandise).
|
||||
position integer An integer, used for sorting
|
||||
picture string A product picture to be displayed in the shop
|
||||
(read-only).
|
||||
available_from datetime The first date time at which this item can be bought
|
||||
(or ``null``).
|
||||
available_until datetime The last date time at which this item can be bought
|
||||
@@ -53,10 +54,9 @@ max_per_order integer This product ca
|
||||
checkin_attention boolean If ``True``, the check-in app should show a warning
|
||||
that this ticket requires special attention if such
|
||||
a product is being scanned.
|
||||
has_variations boolean Shows whether or not this item has variations
|
||||
(read-only).
|
||||
has_variations boolean Shows whether or not this item has variations.
|
||||
variations list of objects A list with one object for each variation of this item.
|
||||
Can be empty.
|
||||
Can be empty. Only writable on POST.
|
||||
├ id integer Internal ID of the variation
|
||||
├ default_price money (string) The price set directly for this variation or ``null``
|
||||
├ price money (string) The price used for this variation. This is either the
|
||||
@@ -66,12 +66,14 @@ variations list of objects A list with one
|
||||
├ description multi-lingual string A public description of the variation. May contain
|
||||
Markdown syntax or can be ``null``.
|
||||
└ position integer An integer, used for sorting
|
||||
addons list of objects Definition of add-ons that can be chosen for this item
|
||||
addons list of objects Definition of add-ons that can be chosen for this item.
|
||||
Only writable on POST.
|
||||
├ addon_category integer Internal ID of the item category the add-on can be
|
||||
chosen from.
|
||||
├ min_count integer The minimal number of add-ons that need to be chosen.
|
||||
├ max_count integer The maximal number of add-ons that can be chosen.
|
||||
└ position integer An integer, used for sorting
|
||||
└ price_included boolean Adding this add-on to the item is free
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.7
|
||||
@@ -79,6 +81,20 @@ addons list of objects Definition of a
|
||||
The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility. The attribute
|
||||
``checkin_attention`` has been added.
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
|
||||
The attribute ``price_included`` has been added to ``addons``.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Please note that an item either always has variations or never has. Once created with variations the item can never
|
||||
change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least
|
||||
one variation.
|
||||
|
||||
Also note that ``variations`` and ``addons`` are only supported on ``POST``. To update/delete variations and add-ons please
|
||||
use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT`` with nested
|
||||
``variations`` and/or ``addons``.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -239,3 +255,226 @@ Endpoints
|
||||
: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:post:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/
|
||||
|
||||
Creates a new item
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/items/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"default_price": "23.00",
|
||||
"category": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"free_price": false,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"value": {"en": "Regular"},
|
||||
"default_price": null,
|
||||
"price": "23.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"addons": []
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Standard ticket"},
|
||||
"default_price": "23.00",
|
||||
"category": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"free_price": false,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"value": {"en": "Regular"},
|
||||
"default_price": null,
|
||||
"price": "23.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"addons": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create an item for
|
||||
:param event: The ``slug`` field of the event to create an item for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The item could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/
|
||||
|
||||
Update an item. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``has_variations``, ``variations`` and the ``addon`` field. If
|
||||
you need to update/delete variations or add-ons please use the nested dedicated endpoints.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"name": {"en": "Ticket"},
|
||||
"default_price": "25.00"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "Ticket"},
|
||||
"default_price": "25.00",
|
||||
"category": null,
|
||||
"active": true,
|
||||
"description": null,
|
||||
"free_price": false,
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
"variations": [
|
||||
{
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"price": "10.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"value": {"en": "Regular"},
|
||||
"default_price": null,
|
||||
"price": "23.00",
|
||||
"active": true,
|
||||
"description": null,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"addons": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The item could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/
|
||||
|
||||
Delete an item.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
.. spelling:: checkin
|
||||
|
||||
Questions
|
||||
=========
|
||||
|
||||
@@ -23,15 +25,25 @@ type string The expected ty
|
||||
* ``C`` – choice from a list
|
||||
* ``M`` – multiple choice from a list
|
||||
* ``F`` – file upload
|
||||
* ``D`` – date
|
||||
* ``H`` – time
|
||||
* ``W`` – date and time
|
||||
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.
|
||||
ask_during_checkin boolean If ``True``, this question will not be asked while
|
||||
buying the ticket, but will show up when redeeming
|
||||
the ticket instead.
|
||||
options list of objects In case of question type ``C`` or ``M``, this lists the
|
||||
available objects.
|
||||
├ id integer Internal ID of the option
|
||||
└ answer multi-lingual string The displayed value of this option
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
The values ``D``, ``H``, and ``W`` for the field ``type`` are now allowed and the ``ask_during_checkin`` field has
|
||||
been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -68,6 +80,7 @@ Endpoints
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
"position": 1,
|
||||
"ask_during_checkin": false,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -121,6 +134,7 @@ Endpoints
|
||||
"type": "C",
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
"ask_during_checkin": false,
|
||||
"position": 1,
|
||||
"options": [
|
||||
{
|
||||
|
||||
@@ -21,10 +21,10 @@ that we'll provide in this plugin::
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import register_data_exporter
|
||||
from pretix.base.signals import register_data_exporters
|
||||
|
||||
|
||||
@receiver(register_data_exporter, dispatch_uid="exporter_myexporter")
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_myexporter")
|
||||
def register_data_exporter(sender, **kwargs):
|
||||
from .exporter import MyExporter
|
||||
return MyExporter
|
||||
|
||||
@@ -9,6 +9,13 @@ uses to communicate with the pretix server.
|
||||
general-purpose :ref:`rest-api` that not yet provides all features that this API provides, but will do
|
||||
so in the future.
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
Support for check-in-time questions has been added. The new API features are fully backwards-compatible and
|
||||
negotiated live, so clients which do not need this feature can ignore the change. For this reason, the API version
|
||||
has not been increased and is still set to 3.
|
||||
|
||||
|
||||
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
|
||||
|
||||
Redeems a ticket, i.e. checks the user in.
|
||||
@@ -22,18 +29,30 @@ uses to communicate with the pretix server.
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3
|
||||
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3&questions_supported=true
|
||||
|
||||
You can optionally include the additional parameter ``datetime`` in the body containing an ISO8601-encoded
|
||||
datetime of the entry attempt. If you don't, the current date and time will be used.
|
||||
You **must** set the parameter secret.
|
||||
|
||||
You can optionally include the additional parameter ``force`` to indicate that the request should be logged
|
||||
You **must** set the parameter ``questions_supported`` to ``true`` **if** you support asking questions
|
||||
back to the app operator. You **must not** set it if you do not support this feature. In that case, questions
|
||||
will just be ignored.
|
||||
|
||||
You **may** set the additional parameter ``datetime`` in the body containing an ISO8601-encoded
|
||||
datetime of the entry attempt. If you don"t, the current date and time will be used.
|
||||
|
||||
You **may** set the additional parameter ``force`` to indicate that the request should be logged
|
||||
regardless of previous check-ins for the same ticket. This might be useful if you made the entry decision offline.
|
||||
Questions will also always be ignored in this case (i.e. supplied answers will be saved, but no error will be
|
||||
thrown if they are missing or invalid).
|
||||
|
||||
You can optionally include the additional parameter ``nonce`` with a globally unique random value to identify this
|
||||
You **may** set the additional parameter ``nonce`` with a globally unique random value to identify this
|
||||
check-in. This is meant to be used to prevent duplicate check-ins when you are just retrying after a connection
|
||||
failure.
|
||||
|
||||
If questions are supported and required, you will receive a dictionary ``questions`` containing details on the
|
||||
particular questions to ask. To answer them, just re-send your redemption request with additional parameters of
|
||||
the form ``answer_<question>=<answer>``, e.g. ``answer_12=24``.
|
||||
|
||||
**Example successful response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -43,10 +62,66 @@ uses to communicate with the pretix server.
|
||||
|
||||
{
|
||||
"status": "ok"
|
||||
"version": 2
|
||||
"version": 3,
|
||||
"data": {
|
||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
||||
"order": "ABCDE",
|
||||
"item": "Standard ticket",
|
||||
"item_id": 1,
|
||||
"variation": null,
|
||||
"variation_id": null,
|
||||
"attendee_name": "Peter Higgs",
|
||||
"attention": false,
|
||||
"redeemed": true,
|
||||
"paid": true
|
||||
}
|
||||
}
|
||||
|
||||
**Example error response**:
|
||||
**Example response with required questions**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "incomplete"
|
||||
"version": 3
|
||||
"data": {
|
||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
||||
"order": "ABCDE",
|
||||
"item": "Standard ticket",
|
||||
"item_id": 1,
|
||||
"variation": null,
|
||||
"variation_id": null,
|
||||
"attendee_name": "Peter Higgs",
|
||||
"attention": false,
|
||||
"redeemed": true,
|
||||
"paid": true
|
||||
},
|
||||
"questions": [
|
||||
{
|
||||
"id": 12,
|
||||
"type": "C",
|
||||
"question": "Choose a shirt size",
|
||||
"required": true,
|
||||
"position": 2,
|
||||
"items": [1],
|
||||
"options": [
|
||||
{
|
||||
"id": 24,
|
||||
"answer": "M"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"answer": "L"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Example error response with data**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
@@ -56,13 +131,39 @@ uses to communicate with the pretix server.
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "already_redeemed",
|
||||
"version": 2
|
||||
"version": 3,
|
||||
"data": {
|
||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
||||
"order": "ABCDE",
|
||||
"item": "Standard ticket",
|
||||
"item_id": 1,
|
||||
"variation": null,
|
||||
"variation_id": null,
|
||||
"attendee_name": "Peter Higgs",
|
||||
"attention": false,
|
||||
"redeemed": true,
|
||||
"paid": true
|
||||
}
|
||||
}
|
||||
|
||||
**Example error response without data**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "unkown_ticket",
|
||||
"version": 3
|
||||
}
|
||||
|
||||
Possible error reasons:
|
||||
|
||||
* ``unpaid`` - Ticket is not paid for or has been refunded
|
||||
* ``already_redeemed`` - Ticket already has been redeemed
|
||||
* ``product`` - Tickets with this product may not be scanned at this device
|
||||
* ``unknown_ticket`` - Secret does not match a ticket in the database
|
||||
|
||||
:query key: Secret API key
|
||||
@@ -104,7 +205,7 @@ uses to communicate with the pretix server.
|
||||
},
|
||||
...
|
||||
],
|
||||
"version": 2
|
||||
"version": 3
|
||||
}
|
||||
|
||||
:query query: Search query
|
||||
@@ -133,6 +234,7 @@ uses to communicate with the pretix server.
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"version": 3,
|
||||
"results": [
|
||||
{
|
||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
||||
@@ -146,7 +248,26 @@ uses to communicate with the pretix server.
|
||||
},
|
||||
...
|
||||
],
|
||||
"version": 2
|
||||
"questions": [
|
||||
{
|
||||
"id": 12,
|
||||
"type": "C",
|
||||
"question": "Choose a shirt size",
|
||||
"required": true,
|
||||
"position": 2,
|
||||
"items": [1],
|
||||
"options": [
|
||||
{
|
||||
"id": 24,
|
||||
"answer": "M"
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"answer": "L"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query key: Secret API key
|
||||
@@ -177,7 +298,7 @@ uses to communicate with the pretix server.
|
||||
{
|
||||
"checkins": 17,
|
||||
"total": 42,
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"event": {
|
||||
"name": "Demo Converence",
|
||||
"slug": "democon",
|
||||
|
||||
@@ -101,6 +101,7 @@ unprefixed
|
||||
untrusted
|
||||
username
|
||||
url
|
||||
versa
|
||||
viewset
|
||||
viewsets
|
||||
webhook
|
||||
|
||||
@@ -8,7 +8,7 @@ The settings at "Settings" → "Display" allow you to customize the appearance o
|
||||
:class: screenshot
|
||||
|
||||
The upper part of the page contains settings that you always need to set specifically for your event. Those are
|
||||
currently::
|
||||
currently:
|
||||
|
||||
Logo image
|
||||
This logo will be shown as a banner above your shop. If you set it, the event name and date will no longer be
|
||||
|
||||
@@ -15,7 +15,7 @@ E-mail settings
|
||||
---------------
|
||||
|
||||
The upper part of the page contains settings that are relevant for the generation of all e-mails alike. Those are
|
||||
currently::
|
||||
currently:
|
||||
|
||||
Subject prefix
|
||||
This text will be prepended to the subject of all e-mails that are related to your event. For example, if you
|
||||
@@ -126,4 +126,4 @@ With the checkbox "Use custom SMTP server" you can turn using your SMTP server o
|
||||
button "Save and test custom SMTP connection", you can test if the connection and authentication to your SMTP server
|
||||
succeeds, even before turning that checkbox on.
|
||||
|
||||
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
||||
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
||||
|
||||
@@ -24,9 +24,11 @@ received any real orders (i.e. taken the shop public). We won't charge any fees
|
||||
How do I delete an event?
|
||||
-------------------------
|
||||
|
||||
It is currently not possible to delete events, you can just disable the shop by clicking the first square on your event
|
||||
dashboard. Events can't be deleted as they most likely contain information on financial transactions which legally
|
||||
needs to be kept on record for multiple years in most countries.
|
||||
You can find the event deletion button at the bottom of the event settings page. Note however, that it is not possible
|
||||
to delete an event once any order or invoice has been created, as those likely contain information on financial
|
||||
transactions which legally may not be tampered with and needs to be kept on record for multiple years in most
|
||||
countries. In this case, you can just disable the shop by clicking the first square on your event
|
||||
dashboard.
|
||||
|
||||
If you are using the hosted service at pretix.eu and want to get rid of an event that you only used for testing, contact
|
||||
us at support@pretix.eu and we can remove it for you.
|
||||
|
||||
@@ -25,7 +25,7 @@ To set a text in italics, you can put it in asterisks or underscores. For exampl
|
||||
|
||||
will become:
|
||||
|
||||
Please *really* pay your _ticket_.
|
||||
Please *really* pay your *ticket*.
|
||||
|
||||
If you set double asterisks or underscores, the text will be printed in bold. For example,
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ pretix allows you to accept payments using a variety of payment methods to fit t
|
||||
This page gives you a short overview over them and links to more detailed descriptions in some cases.
|
||||
|
||||
Payment methods are built as pretix plugins. For this reason, you might first need to enable a certain plugin at
|
||||
"Settings" → "Plugins" in your event settings. Then, you can configure them in detail at "Settings" -> "Payment".
|
||||
"Settings" → "Plugins" in your event settings. Then, you can configure them in detail at "Settings" → "Payment".
|
||||
|
||||
If you host pretix on your own server, you might need to install a plugin first for some of the payment methods listed
|
||||
on this page as well as for additional ones.
|
||||
@@ -13,4 +13,4 @@ on this page as well as for additional ones.
|
||||
To get an overview of the officially supported payment methods and their pros and cons, head to the `pretix website`_.
|
||||
On these pages, you get more information on how to configure :ref:`stripe`, :ref:`paypal`, and :ref:`banktransfer`.
|
||||
|
||||
.. _pretix website: https://pretix.eu/about/en/payments
|
||||
.. _pretix website: https://pretix.eu/about/en/features/payment
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.11.0"
|
||||
__version__ = "1.12.0"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
@@ -16,11 +19,44 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
||||
'position', 'default_price', 'price')
|
||||
|
||||
|
||||
class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price')
|
||||
|
||||
|
||||
class InlineItemAddOnSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemAddOn
|
||||
fields = ('addon_category', 'min_count', 'max_count',
|
||||
'position')
|
||||
'position', 'price_included')
|
||||
|
||||
|
||||
class ItemAddOnSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemAddOn
|
||||
fields = ('id', 'addon_category', 'min_count', 'max_count',
|
||||
'position', 'price_included')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
ItemAddOn.clean_max_min_count(data.get('max_count'), data.get('min_count'))
|
||||
|
||||
return data
|
||||
|
||||
def validate_min_count(self, value):
|
||||
ItemAddOn.clean_min_count(value)
|
||||
return value
|
||||
|
||||
def validate_max_count(self, value):
|
||||
ItemAddOn.clean_max_count(value)
|
||||
return value
|
||||
|
||||
def validate_addon_category(self, value):
|
||||
ItemAddOn.clean_categories(self.context['event'], self.context['item'], self.instance, value)
|
||||
return value
|
||||
|
||||
|
||||
class ItemTaxRateField(serializers.Field):
|
||||
@@ -32,8 +68,8 @@ class ItemTaxRateField(serializers.Field):
|
||||
|
||||
|
||||
class ItemSerializer(I18nAwareModelSerializer):
|
||||
addons = InlineItemAddOnSerializer(many=True)
|
||||
variations = InlineItemVariationSerializer(many=True)
|
||||
addons = InlineItemAddOnSerializer(many=True, required=False)
|
||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -44,6 +80,55 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
|
||||
'variations', 'addons')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"has_variations": self.kwargs['has_variations']}
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
|
||||
Item.clean_available(data.get('available_from'), data.get('available_until'))
|
||||
|
||||
return data
|
||||
|
||||
def validate_category(self, value):
|
||||
Item.clean_category(value, self.context['event'])
|
||||
return value
|
||||
|
||||
def validate_tax_rule(self, value):
|
||||
Item.clean_tax_rule(value, self.context['event'])
|
||||
return value
|
||||
|
||||
def validate_variations(self, value):
|
||||
if self.instance is not None:
|
||||
raise ValidationError(_('Updating variations via PATCH/PUT is not supported. Please use the dedicated'
|
||||
' nested endpoint.'))
|
||||
return value
|
||||
|
||||
def validate_addons(self, value):
|
||||
if self.instance is not None:
|
||||
raise ValidationError(_('Updating add-ons via PATCH/PUT is not supported. Please use the dedicated'
|
||||
' nested endpoint.'))
|
||||
else:
|
||||
for addon_data in value:
|
||||
ItemAddOn.clean_categories(self.context['event'], None, self.instance, addon_data['addon_category'])
|
||||
ItemAddOn.clean_min_count(addon_data['min_count'])
|
||||
ItemAddOn.clean_max_count(addon_data['max_count'])
|
||||
ItemAddOn.clean_max_min_count(addon_data['max_count'], addon_data['min_count'])
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
|
||||
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
|
||||
item = Item.objects.create(**validated_data)
|
||||
for variation_data in variations_data:
|
||||
ItemVariation.objects.create(item=item, **variation_data)
|
||||
for addon_data in addons_data:
|
||||
ItemAddOn.objects.create(base_item=item, **addon_data)
|
||||
return item
|
||||
|
||||
|
||||
class ItemCategorySerializer(I18nAwareModelSerializer):
|
||||
@@ -65,7 +150,8 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position')
|
||||
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
|
||||
'ask_during_checkin')
|
||||
|
||||
|
||||
class QuotaSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -29,6 +29,10 @@ event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||
checkinlist_router = routers.DefaultRouter()
|
||||
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet)
|
||||
|
||||
item_router = routers.DefaultRouter()
|
||||
item_router.register(r'variations', item.ItemVariationViewSet)
|
||||
item_router.register(r'addons', item.ItemAddOnViewSet)
|
||||
|
||||
# Force import of all plugins to give them a chance to register URLs with the router
|
||||
for app in apps.get_app_configs():
|
||||
if hasattr(app, 'PretixPluginMeta'):
|
||||
@@ -39,6 +43,7 @@ urlpatterns = [
|
||||
url(r'^', include(router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/', include(orga_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/items/(?P<item>[^/]+)/', include(item_router.urls)),
|
||||
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/checkinlists/(?P<list>[^/]+)/',
|
||||
include(checkinlist_router.urls)),
|
||||
]
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.item import (
|
||||
ItemCategorySerializer, ItemSerializer, QuestionSerializer,
|
||||
QuotaSerializer,
|
||||
ItemAddOnSerializer, ItemCategorySerializer, ItemSerializer,
|
||||
ItemVariationSerializer, QuestionSerializer, QuotaSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, Quota,
|
||||
)
|
||||
from pretix.base.models import Item, ItemCategory, Question, Quota
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
class ItemFilter(FilterSet):
|
||||
@@ -28,7 +33,7 @@ class ItemFilter(FilterSet):
|
||||
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
|
||||
|
||||
|
||||
class ItemViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class ItemViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemSerializer
|
||||
queryset = Item.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
@@ -36,10 +41,159 @@ class ItemViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ordering = ('position', 'id')
|
||||
filter_class = ItemFilter
|
||||
permission = 'can_change_items'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons').all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.item.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['has_variations'] = self.request.data.get('has_variations')
|
||||
return ctx
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.item.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied('This item cannot be deleted because it has already been ordered '
|
||||
'by a user or currently is in a users\'s cart. Please set the item as '
|
||||
'"inactive" instead.')
|
||||
|
||||
instance.log_action(
|
||||
'pretix.event.item.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemVariationSerializer
|
||||
queryset = ItemVariation.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('id',)
|
||||
permission = 'can_change_items'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
return item.variations.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
if not item.has_variations:
|
||||
raise PermissionDenied('This variation cannot be created because the item does not have variations. '
|
||||
'Changing a product without variations to a product with variations is not allowed.')
|
||||
serializer.save(item=item)
|
||||
item.log_action(
|
||||
'pretix.event.item.variation.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk},
|
||||
{'value': serializer.instance.value})
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.item.log_action(
|
||||
'pretix.event.item.variation.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk},
|
||||
{'value': serializer.instance.value})
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied('This variation cannot be deleted because it has already been ordered '
|
||||
'by a user or currently is in a users\'s cart. Please set the variation as '
|
||||
'\'inactive\' instead.')
|
||||
if instance.is_only_variation():
|
||||
raise PermissionDenied('This variation cannot be deleted because it is the only variation. Changing a '
|
||||
'product with variations to a product without variations is not allowed.')
|
||||
super().perform_destroy(instance)
|
||||
instance.item.log_action(
|
||||
'pretix.event.item.variation.deleted',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data={
|
||||
'value': instance.value,
|
||||
'id': self.kwargs['pk']
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemAddOnSerializer
|
||||
queryset = ItemAddOn.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('id',)
|
||||
permission = 'can_change_items'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
return item.addons.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category'])
|
||||
serializer.save(base_item=item, addon_category=category)
|
||||
item.log_action(
|
||||
'pretix.event.item.addons.added',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.base_item.log_action(
|
||||
'pretix.event.item.addons.changed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
super().perform_destroy(instance)
|
||||
instance.base_item.log_action(
|
||||
'pretix.event.item.addons.removed',
|
||||
user=self.request.user,
|
||||
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
|
||||
data={'category': instance.addon_category.pk}
|
||||
)
|
||||
|
||||
|
||||
class ItemCategoryFilter(FilterSet):
|
||||
class Meta:
|
||||
|
||||
@@ -21,7 +21,8 @@ from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.services.invoices import invoice_pdf
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.orders import (
|
||||
OrderError, cancel_order, extend_order, mark_order_paid,
|
||||
OrderError, cancel_order, extend_order, mark_order_expired,
|
||||
mark_order_paid,
|
||||
)
|
||||
from pretix.base.services.tickets import (
|
||||
get_cachedticket_for_order, get_cachedticket_for_position,
|
||||
@@ -109,9 +110,9 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
|
||||
order = self.get_object()
|
||||
if order.status != Order.STATUS_PENDING:
|
||||
if not order.cancel_allowed():
|
||||
return Response(
|
||||
{'detail': 'The order is not pending.'},
|
||||
{'detail': 'The order is not allowed to be canceled.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
@@ -153,10 +154,8 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
order.status = Order.STATUS_EXPIRED
|
||||
order.save()
|
||||
order.log_action(
|
||||
'pretix.event.order.expired',
|
||||
mark_order_expired(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
|
||||
)
|
||||
|
||||
236
src/pretix/base/forms/questions.py
Normal file
236
src/pretix/base/forms/questions.py
Normal file
@@ -0,0 +1,236 @@
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
import dateutil.parser
|
||||
import pytz
|
||||
import vat_moss.errors
|
||||
import vat_moss.id
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms.widgets import (
|
||||
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
|
||||
TimePickerWidget, UploadedFileWidget,
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Question
|
||||
from pretix.base.models.tax import EU_COUNTRIES
|
||||
from pretix.helpers.i18n import get_format_without_seconds
|
||||
from pretix.presale.signals import question_form_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseQuestionsForm(forms.Form):
|
||||
"""
|
||||
This form class is responsible for asking order-related questions. This includes
|
||||
the attendee name for admission tickets, if the corresponding setting is enabled,
|
||||
as well as additional questions defined by the organizer.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Takes two additional keyword arguments:
|
||||
|
||||
:param cartpos: The cart position the form should be for
|
||||
:param event: The event this belongs to
|
||||
"""
|
||||
cartpos = self.cartpos = kwargs.pop('cartpos', None)
|
||||
orderpos = self.orderpos = kwargs.pop('orderpos', None)
|
||||
pos = cartpos or orderpos
|
||||
item = pos.item
|
||||
questions = pos.item.questions_to_ask
|
||||
event = kwargs.pop('event')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if item.admission and event.settings.attendee_names_asked:
|
||||
self.fields['attendee_name'] = forms.CharField(
|
||||
max_length=255, required=event.settings.attendee_names_required,
|
||||
label=_('Attendee name'),
|
||||
initial=(cartpos.attendee_name if cartpos else orderpos.attendee_name),
|
||||
)
|
||||
if item.admission and event.settings.attendee_emails_asked:
|
||||
self.fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required,
|
||||
label=_('Attendee email'),
|
||||
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email)
|
||||
)
|
||||
|
||||
for q in questions:
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
answers = [a for a in pos.answerlist if a.question_id == q.id]
|
||||
if answers:
|
||||
initial = answers[0]
|
||||
else:
|
||||
initial = None
|
||||
tz = pytz.timezone(event.settings.timezone)
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if q.required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
# itself.
|
||||
widget = forms.CheckboxInput(attrs={'required': 'required'})
|
||||
else:
|
||||
widget = forms.CheckboxInput()
|
||||
|
||||
if initial:
|
||||
initialbool = (initial.answer == "True")
|
||||
else:
|
||||
initialbool = False
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
min_value=Decimal('0.00'),
|
||||
)
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
widget=forms.Select,
|
||||
empty_label='',
|
||||
initial=initial.options.first() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
initial=initial.options.all() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_FILE:
|
||||
field = forms.FileField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
field = forms.DateField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
|
||||
widget=DatePickerWidget(),
|
||||
)
|
||||
elif q.type == Question.TYPE_TIME:
|
||||
field = forms.TimeField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else None,
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
field = forms.SplitDateTimeField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
|
||||
widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
field.question = q
|
||||
if answers:
|
||||
# Cache the answer object for later use
|
||||
field.answer = answers[0]
|
||||
self.fields['question_%s' % q.id] = field
|
||||
|
||||
responses = question_form_fields.send(sender=event, position=pos)
|
||||
data = pos.meta_info_data
|
||||
for r, response in sorted(responses, key=lambda r: str(r[0])):
|
||||
for key, value in response.items():
|
||||
# We need to be this explicit, since OrderedDict.update does not retain ordering
|
||||
self.fields[key] = value
|
||||
value.initial = data.get('question_form_data', {}).get(key)
|
||||
|
||||
|
||||
class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
vat_warning = False
|
||||
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
'internal_reference')
|
||||
widgets = {
|
||||
'is_business': BusinessBooleanRadio,
|
||||
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
|
||||
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'name': forms.TextInput(attrs={}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'internal_reference': forms.TextInput,
|
||||
}
|
||||
labels = {
|
||||
'is_business': ''
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = event = kwargs.pop('event')
|
||||
self.request = kwargs.pop('request', None)
|
||||
self.validate_vat_id = kwargs.pop('validate_vat_id')
|
||||
super().__init__(*args, **kwargs)
|
||||
if not event.settings.invoice_address_vatid:
|
||||
del self.fields['vat_id']
|
||||
if not event.settings.invoice_address_required:
|
||||
for k, f in self.fields.items():
|
||||
f.required = False
|
||||
f.widget.is_required = False
|
||||
if 'required' in f.widget.attrs:
|
||||
del f.widget.attrs['required']
|
||||
|
||||
if event.settings.invoice_name_required:
|
||||
self.fields['name'].required = True
|
||||
else:
|
||||
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
|
||||
self.fields['name'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
||||
|
||||
def clean(self):
|
||||
data = self.cleaned_data
|
||||
if not data.get('name') and not data.get('company') and self.event.settings.invoice_address_required:
|
||||
raise ValidationError(_('You need to provide either a company name or your name.'))
|
||||
|
||||
if 'vat_id' in self.changed_data or not data.get('vat_id'):
|
||||
self.instance.vat_id_validated = False
|
||||
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
|
||||
if data.get('vat_id')[:2] != str(data.get('country')):
|
||||
raise ValidationError(_('Your VAT ID does not match the selected country.'))
|
||||
try:
|
||||
result = vat_moss.id.validate(data.get('vat_id'))
|
||||
if result:
|
||||
country_code, normalized_id, company_name = result
|
||||
self.instance.vat_id_validated = True
|
||||
self.instance.vat_id = normalized_id
|
||||
except vat_moss.errors.InvalidError:
|
||||
raise ValidationError(_('This VAT ID is not valid. Please re-check your input.'))
|
||||
except vat_moss.errors.WebServiceUnavailableError:
|
||||
logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
|
||||
self.instance.vat_id_validated = False
|
||||
if self.request and self.vat_warning:
|
||||
messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of '
|
||||
'your country is currently not available. We will therefore '
|
||||
'need to charge VAT on your invoice. You can get the tax amount '
|
||||
'back via the VAT reimbursement process.'))
|
||||
else:
|
||||
self.instance.vat_id_validated = False
|
||||
@@ -23,6 +23,12 @@ class PlaceholderValidator(BaseValidator):
|
||||
self.__call__(v)
|
||||
return
|
||||
|
||||
if value.count('{') != value.count('}'):
|
||||
raise ValidationError(
|
||||
_('Invalid placeholder syntax: You used a different number of "{" than of "}".'),
|
||||
code='invalid',
|
||||
)
|
||||
|
||||
data_placeholders = list(re.findall(r'({[\w\s]*})', value, re.X))
|
||||
invalid_placeholders = []
|
||||
for placeholder in data_placeholders:
|
||||
|
||||
135
src/pretix/base/forms/widgets.py
Normal file
135
src/pretix/base/forms/widgets.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import os
|
||||
|
||||
from django import forms
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
|
||||
|
||||
class DatePickerWidget(forms.DateInput):
|
||||
def __init__(self, attrs=None, date_format=None):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
date_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
date_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
|
||||
forms.DateInput.__init__(self, date_attrs, date_format)
|
||||
|
||||
|
||||
class TimePickerWidget(forms.TimeInput):
|
||||
def __init__(self, attrs=None, time_format=None):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
time_attrs = dict(attrs)
|
||||
time_attrs.setdefault('class', 'form-control')
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
time_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
|
||||
forms.TimeInput.__init__(self, time_attrs, time_format)
|
||||
|
||||
|
||||
class UploadedFileWidget(forms.ClearableFileInput):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.position = kwargs.pop('position')
|
||||
self.event = kwargs.pop('event')
|
||||
self.answer = kwargs.pop('answer')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class FakeFile:
|
||||
def __init__(self, file, position, event, answer):
|
||||
self.file = file
|
||||
self.position = position
|
||||
self.event = event
|
||||
self.answer = answer
|
||||
|
||||
def __str__(self):
|
||||
return os.path.basename(self.file.name).split('.', 1)[-1]
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
if isinstance(self.position, OrderPosition):
|
||||
return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={
|
||||
'order': self.position.order.code,
|
||||
'secret': self.position.order.secret,
|
||||
'answer': self.answer.pk,
|
||||
})
|
||||
else:
|
||||
return eventreverse(self.event, 'presale:event.cart.download.answer', kwargs={
|
||||
'answer': self.answer.pk,
|
||||
})
|
||||
|
||||
def format_value(self, value):
|
||||
if self.is_initial(value):
|
||||
return self.FakeFile(value, self.position, self.event, self.answer)
|
||||
|
||||
|
||||
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
template_name = 'pretixbase/forms/widgets/splitdatetime.html'
|
||||
|
||||
def __init__(self, attrs=None, date_format=None, time_format=None):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
date_attrs = dict(attrs)
|
||||
time_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
time_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
date_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
time_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
|
||||
widgets = (
|
||||
forms.DateInput(attrs=date_attrs, format=date_format),
|
||||
forms.TimeInput(attrs=time_attrs, format=time_format),
|
||||
)
|
||||
# Skip one hierarchy level
|
||||
forms.MultiWidget.__init__(self, widgets, attrs)
|
||||
|
||||
|
||||
class BusinessBooleanRadio(forms.RadioSelect):
|
||||
def __init__(self, attrs=None):
|
||||
choices = (
|
||||
('individual', _('Individual customer')),
|
||||
('business', _('Business customer')),
|
||||
)
|
||||
super().__init__(attrs, choices)
|
||||
|
||||
def format_value(self, value):
|
||||
try:
|
||||
return {True: 'business', False: 'individual'}[value]
|
||||
except KeyError:
|
||||
return 'individual'
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
value = data.get(name)
|
||||
return {
|
||||
'business': True,
|
||||
True: True,
|
||||
'True': True,
|
||||
'individual': False,
|
||||
'False': False,
|
||||
False: False,
|
||||
}.get(value)
|
||||
@@ -321,6 +321,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
]
|
||||
|
||||
def _get_story(self, doc):
|
||||
has_taxes = any(il.tax_value for il in self.invoice.lines.all())
|
||||
|
||||
story = [
|
||||
NextPageTemplate('FirstPage'),
|
||||
Paragraph(pgettext('invoice', 'Invoice')
|
||||
@@ -352,28 +354,52 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
('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'),
|
||||
)]
|
||||
if has_taxes:
|
||||
tdata = [(
|
||||
pgettext('invoice', 'Description'),
|
||||
pgettext('invoice', 'Qty'),
|
||||
pgettext('invoice', 'Tax rate'),
|
||||
pgettext('invoice', 'Net'),
|
||||
pgettext('invoice', 'Gross'),
|
||||
)]
|
||||
else:
|
||||
tdata = [(
|
||||
pgettext('invoice', 'Description'),
|
||||
pgettext('invoice', 'Qty'),
|
||||
pgettext('invoice', 'Amount'),
|
||||
)]
|
||||
|
||||
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,
|
||||
))
|
||||
if has_taxes:
|
||||
tdata.append((
|
||||
Paragraph(line.description, self.stylesheet['Normal']),
|
||||
"1",
|
||||
localize(line.tax_rate) + " %",
|
||||
localize(line.net_value) + " " + self.invoice.event.currency,
|
||||
localize(line.gross_value) + " " + self.invoice.event.currency,
|
||||
))
|
||||
else:
|
||||
tdata.append((
|
||||
Paragraph(line.description, self.stylesheet['Normal']),
|
||||
"1",
|
||||
localize(line.gross_value) + " " + self.invoice.event.currency,
|
||||
))
|
||||
taxvalue_map[line.tax_rate, line.tax_name] += line.tax_value
|
||||
grossvalue_map[line.tax_rate, line.tax_name] += 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)]
|
||||
if has_taxes:
|
||||
tdata.append([
|
||||
pgettext('invoice', 'Invoice total'), '', '', '', localize(total) + " " + self.invoice.event.currency
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
|
||||
else:
|
||||
tdata.append([
|
||||
pgettext('invoice', 'Invoice total'), '', localize(total) + " " + self.invoice.event.currency
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.65, .05, .30)]
|
||||
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=1)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
story.append(table)
|
||||
@@ -422,7 +448,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
except ValueError:
|
||||
return localize(val) + ' ' + self.invoice.foreign_currency_display
|
||||
|
||||
if len(tdata) > 1:
|
||||
if len(tdata) > 1 and has_taxes:
|
||||
colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
|
||||
@@ -7,7 +7,6 @@ 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 (
|
||||
@@ -166,9 +165,6 @@ 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
|
||||
@@ -183,7 +179,7 @@ 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}", "{media}", "'nonce-{nonce}'"],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"],
|
||||
'font-src': ["{static}"],
|
||||
@@ -222,10 +218,9 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
if request.path not in self.CSP_EXEMPT and not getattr(resp, '_csp_ignore', False):
|
||||
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain,
|
||||
media=mediadomain, nonce=request.csp_nonce)
|
||||
media=mediadomain)
|
||||
for k, v in h.items():
|
||||
h[k] = ' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain,
|
||||
nonce=request.csp_nonce).split(' ')
|
||||
h[k] = ' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')
|
||||
resp['Content-Security-Policy'] = _render_csp(h)
|
||||
elif 'Content-Security-Policy' in resp:
|
||||
del resp['Content-Security-Policy']
|
||||
|
||||
39
src/pretix/base/migrations/0079_auto_20180115_0855.py
Normal file
39
src/pretix/base/migrations/0079_auto_20180115_0855.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.8 on 2018-01-15 08:55
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F
|
||||
from django.db.models.functions import Concat
|
||||
|
||||
|
||||
def set_full_invoice_no(app, schema_editor):
|
||||
Invoice = app.get_model('pretixbase', 'Invoice')
|
||||
Invoice.objects.all().update(
|
||||
full_invoice_no=Concat(F('prefix'), F('invoice_no'))
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0078_auto_20171206_1603'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='full_invoice_no',
|
||||
field=models.CharField(db_index=True, default='', max_length=190),
|
||||
preserve_default=False,
|
||||
),
|
||||
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'), ('D', 'Date'), ('H', 'Time'), ('W', 'Date and time')], max_length=5, verbose_name='Question type'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
set_full_invoice_no,
|
||||
migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.8 on 2018-01-15 14:26
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0079_auto_20180115_0855'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='ask_during_checkin',
|
||||
field=models.BooleanField(default=False, help_text='Supported by pretixdroid 1.8 and newer or pretixdesk 0.2 and newer.', verbose_name='Ask during check-in instead of during registration'),
|
||||
),
|
||||
]
|
||||
@@ -4,6 +4,7 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser, BaseUserManager, PermissionsMixin,
|
||||
)
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
@@ -85,7 +86,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
timezone = models.CharField(max_length=100,
|
||||
default=settings.TIME_ZONE,
|
||||
verbose_name=_('Timezone'))
|
||||
require_2fa = models.BooleanField(default=False)
|
||||
require_2fa = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Two-factor authentification is required to log in')
|
||||
)
|
||||
notifications_send = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_('Receive notifications according to my settings below'),
|
||||
@@ -158,6 +162,19 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
except SendMailException:
|
||||
pass # Already logged
|
||||
|
||||
def send_password_reset(self):
|
||||
from pretix.base.services.mail import mail
|
||||
|
||||
mail(
|
||||
self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
|
||||
{
|
||||
'user': self,
|
||||
'url': (build_absolute_uri('control:auth.forgot.recover')
|
||||
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
|
||||
},
|
||||
None, locale=self.locale
|
||||
)
|
||||
|
||||
@property
|
||||
def all_logentries(self):
|
||||
from pretix.base.models import LogEntry
|
||||
|
||||
@@ -323,6 +323,10 @@ class Event(EventMixin, LoggedModel):
|
||||
else:
|
||||
return get_connection(fail_silently=False)
|
||||
|
||||
@property
|
||||
def timezone(self):
|
||||
return pytz.timezone(self.settings.timezone)
|
||||
|
||||
@property
|
||||
def payment_term_last(self):
|
||||
"""
|
||||
@@ -544,6 +548,9 @@ class Event(EventMixin, LoggedModel):
|
||||
Q(is_superuser=True) | Q(twp=True)
|
||||
)
|
||||
|
||||
def allow_delete(self):
|
||||
return not self.orders.exists() and not self.invoices.exists()
|
||||
|
||||
|
||||
class SubEvent(EventMixin, LoggedModel):
|
||||
"""
|
||||
@@ -639,6 +646,9 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
|
||||
return data
|
||||
|
||||
def allow_delete(self):
|
||||
return self.event.subevents.count() > 1
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
if self.event:
|
||||
|
||||
@@ -41,6 +41,8 @@ class Invoice(models.Model):
|
||||
:type invoice_from: str
|
||||
:param invoice_to: The receiver address
|
||||
:type invoice_to: str
|
||||
:param full_invoice_no: The full invoice number (for performance reasons only)
|
||||
:type full_invoice_no: str
|
||||
:param date: The invoice date
|
||||
:type date: date
|
||||
:param locale: The locale in which the invoice should be printed
|
||||
@@ -67,6 +69,7 @@ class Invoice(models.Model):
|
||||
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)
|
||||
full_invoice_no = models.CharField(max_length=190, db_index=True)
|
||||
is_cancellation = models.BooleanField(default=False)
|
||||
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True)
|
||||
invoice_from = models.TextField()
|
||||
@@ -122,6 +125,8 @@ class Invoice(models.Model):
|
||||
# Suppress duplicate key errors and try again
|
||||
if i == 9:
|
||||
raise
|
||||
|
||||
self.full_invoice_no = self.prefix + self.invoice_no
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal, DecimalException
|
||||
from typing import Tuple
|
||||
|
||||
import dateutil.parser
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Func, Q, Sum
|
||||
from django.utils import formats
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import is_naive, make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
@@ -369,10 +372,41 @@ class Item(LoggedModel):
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import CartPosition, OrderPosition
|
||||
|
||||
return (
|
||||
not OrderPosition.objects.filter(item=self).exists()
|
||||
and not CartPosition.objects.filter(item=self).exists()
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def has_variations(self):
|
||||
return self.variations.exists()
|
||||
|
||||
@staticmethod
|
||||
def clean_per_order(min_per_order, max_per_order):
|
||||
if min_per_order is not None and max_per_order is not None:
|
||||
if min_per_order > max_per_order:
|
||||
raise ValidationError(_('The maximum number per order can not be lower than the minimum number per '
|
||||
'order.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_category(category, event):
|
||||
if category is not None and category.event is not None and category.event != event:
|
||||
raise ValidationError(_('The item\'s category must belong to the same event as the item.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_tax_rule(tax_rule, event):
|
||||
if tax_rule is not None and tax_rule.event is not None and tax_rule.event != event:
|
||||
raise ValidationError(_('The item\'s tax rule must belong to the same event as the item.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_available(from_date, until_date):
|
||||
if from_date is not None and until_date is not None:
|
||||
if from_date > until_date:
|
||||
raise ValidationError(_('The item\'s availability cannot end before it starts.'))
|
||||
|
||||
|
||||
class ItemVariation(models.Model):
|
||||
"""
|
||||
@@ -476,6 +510,17 @@ class ItemVariation(models.Model):
|
||||
return self.id < other.id
|
||||
return self.position < other.position
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import CartPosition, OrderPosition
|
||||
|
||||
return (
|
||||
not OrderPosition.objects.filter(variation=self).exists()
|
||||
and not CartPosition.objects.filter(variation=self).exists()
|
||||
)
|
||||
|
||||
def is_only_variation(self):
|
||||
return ItemVariation.objects.filter(item=self.item).count() == 1
|
||||
|
||||
|
||||
class ItemAddOn(models.Model):
|
||||
"""
|
||||
@@ -527,8 +572,34 @@ class ItemAddOn(models.Model):
|
||||
ordering = ('position', 'pk')
|
||||
|
||||
def clean(self):
|
||||
if self.max_count < self.min_count:
|
||||
raise ValidationError(_('The minimum number needs to be lower than the maximum number.'))
|
||||
self.clean_min_count(self.min_count)
|
||||
self.clean_max_count(self.max_count)
|
||||
self.clean_max_min_count(self.max_count, self.min_count)
|
||||
|
||||
@staticmethod
|
||||
def clean_categories(event, item, addon, new_category):
|
||||
if event != new_category.event:
|
||||
raise ValidationError(_('The add-on\'s category must belong to the same event as the item.'))
|
||||
if item is not None:
|
||||
if addon is None or addon.addon_category != new_category:
|
||||
for addon in item.addons.all():
|
||||
if addon.addon_category == new_category:
|
||||
raise ValidationError(_('The item already has an add-on of this category.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_min_count(min_count):
|
||||
if min_count < 0:
|
||||
raise ValidationError(_('The minimum count needs to be equal to or greater than zero.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_max_count(max_count):
|
||||
if max_count < 0:
|
||||
raise ValidationError(_('The maximum count needs to be equal to or greater than zero.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_max_min_count(max_count, min_count):
|
||||
if max_count < min_count:
|
||||
raise ValidationError(_('The maximum count needs to be greater than the minimum count.'))
|
||||
|
||||
|
||||
class Question(LoggedModel):
|
||||
@@ -543,7 +614,10 @@ 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``))
|
||||
* a file upload (``TYPE_FILE``)
|
||||
* a date (``TYPE_DATE``)
|
||||
* a time (``TYPE_TIME``)
|
||||
* a date and a time (``TYPE_DATETIME``)
|
||||
|
||||
:param event: The event this question belongs to
|
||||
:type event: Event
|
||||
@@ -554,6 +628,8 @@ class Question(LoggedModel):
|
||||
items associated with this question.
|
||||
:type required: bool
|
||||
:param items: A set of ``Items`` objects that this question should be applied to
|
||||
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
|
||||
:type ask_during_checkin: bool
|
||||
"""
|
||||
TYPE_NUMBER = "N"
|
||||
TYPE_STRING = "S"
|
||||
@@ -562,6 +638,9 @@ class Question(LoggedModel):
|
||||
TYPE_CHOICE = "C"
|
||||
TYPE_CHOICE_MULTIPLE = "M"
|
||||
TYPE_FILE = "F"
|
||||
TYPE_DATE = "D"
|
||||
TYPE_TIME = "H"
|
||||
TYPE_DATETIME = "W"
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_NUMBER, _("Number")),
|
||||
(TYPE_STRING, _("Text (one line)")),
|
||||
@@ -570,6 +649,9 @@ class Question(LoggedModel):
|
||||
(TYPE_CHOICE, _("Choose one from a list")),
|
||||
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list")),
|
||||
(TYPE_FILE, _("File upload")),
|
||||
(TYPE_DATE, _("Date")),
|
||||
(TYPE_TIME, _("Time")),
|
||||
(TYPE_DATETIME, _("Date and time")),
|
||||
)
|
||||
|
||||
event = models.ForeignKey(
|
||||
@@ -603,6 +685,12 @@ class Question(LoggedModel):
|
||||
position = models.IntegerField(
|
||||
default=0
|
||||
)
|
||||
ask_during_checkin = models.BooleanField(
|
||||
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
|
||||
help_text=_('This will only work if you handle your check-in with pretixdroid 1.8 or newer or '
|
||||
'pretixdesk 0.2 or newer.'),
|
||||
default=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Question")
|
||||
@@ -629,6 +717,64 @@ class Question(LoggedModel):
|
||||
def __lt__(self, other) -> bool:
|
||||
return self.sortkey < other.sortkey
|
||||
|
||||
def clean_answer(self, answer):
|
||||
if self.required:
|
||||
if not answer or (self.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)):
|
||||
raise ValidationError(_('An answer to this question is required to proceed.'))
|
||||
if not answer:
|
||||
if self.type == Question.TYPE_BOOLEAN:
|
||||
return False
|
||||
return None
|
||||
|
||||
if self.type == Question.TYPE_CHOICE:
|
||||
try:
|
||||
return self.options.get(pk=answer)
|
||||
except:
|
||||
raise ValidationError(_('Invalid option selected.'))
|
||||
elif self.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
try:
|
||||
if isinstance(answer, str):
|
||||
return list(self.options.filter(pk__in=answer.split(",")))
|
||||
else:
|
||||
return list(self.options.filter(pk__in=answer))
|
||||
except:
|
||||
raise ValidationError(_('Invalid option selected.'))
|
||||
elif self.type == Question.TYPE_BOOLEAN:
|
||||
return answer in ('true', 'True', True)
|
||||
elif self.type == Question.TYPE_NUMBER:
|
||||
answer = formats.sanitize_separators(answer)
|
||||
answer = str(answer).strip()
|
||||
try:
|
||||
return Decimal(answer)
|
||||
except DecimalException:
|
||||
raise ValidationError(_('Invalid number input.'))
|
||||
elif self.type == Question.TYPE_DATE:
|
||||
if isinstance(answer, date):
|
||||
return answer
|
||||
try:
|
||||
return dateutil.parser.parse(answer).date()
|
||||
except:
|
||||
raise ValidationError(_('Invalid date input.'))
|
||||
elif self.type == Question.TYPE_TIME:
|
||||
if isinstance(answer, time):
|
||||
return answer
|
||||
try:
|
||||
return dateutil.parser.parse(answer).time()
|
||||
except:
|
||||
raise ValidationError(_('Invalid time input.'))
|
||||
elif self.type == Question.TYPE_DATETIME and answer:
|
||||
if isinstance(answer, datetime):
|
||||
return answer
|
||||
try:
|
||||
dt = dateutil.parser.parse(answer)
|
||||
if is_naive(dt):
|
||||
dt = make_aware(dt, pytz.timezone(self.event.settings.timezone))
|
||||
return dt
|
||||
except:
|
||||
raise ValidationError(_('Invalid datetime input.'))
|
||||
|
||||
return answer
|
||||
|
||||
|
||||
class QuestionOption(models.Model):
|
||||
question = models.ForeignKey('Question', related_name='options')
|
||||
|
||||
@@ -41,7 +41,7 @@ class LogEntry(models.Model):
|
||||
datetime = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT)
|
||||
api_token = models.ForeignKey('TeamAPIToken', null=True, blank=True, on_delete=models.PROTECT)
|
||||
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.CASCADE)
|
||||
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
action_type = models.CharField(max_length=255)
|
||||
data = models.TextField(default='{}')
|
||||
visible = models.BooleanField(default=True)
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime, time
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
import dateutil
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
@@ -15,6 +16,7 @@ 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.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
@@ -211,6 +213,12 @@ class Order(LoggedModel):
|
||||
def net_total(self):
|
||||
return self.total - self.tax_total
|
||||
|
||||
def cancel_allowed(self):
|
||||
return (
|
||||
self.status == Order.STATUS_PENDING
|
||||
or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00'))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_code(code):
|
||||
tr = str.maketrans({
|
||||
@@ -270,7 +278,7 @@ class Order(LoggedModel):
|
||||
"""
|
||||
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
|
||||
return self.cancel_allowed() and self.event.settings.cancel_allow_user and cancelable
|
||||
|
||||
@property
|
||||
def is_expired_by_time(self):
|
||||
@@ -498,6 +506,27 @@ class QuestionAnswer(models.Model):
|
||||
return str(_("No"))
|
||||
elif self.question.type == Question.TYPE_FILE:
|
||||
return str(_("<file>"))
|
||||
elif self.question.type == Question.TYPE_DATETIME and self.answer:
|
||||
try:
|
||||
d = dateutil.parser.parse(self.answer)
|
||||
if self.orderposition:
|
||||
tz = pytz.timezone(self.orderposition.order.event.settings.timezone)
|
||||
d = d.astimezone(tz)
|
||||
return date_format(d, "SHORT_DATETIME_FORMAT")
|
||||
except ValueError:
|
||||
return self.answer
|
||||
elif self.question.type == Question.TYPE_DATE and self.answer:
|
||||
try:
|
||||
d = dateutil.parser.parse(self.answer)
|
||||
return date_format(d, "SHORT_DATE_FORMAT")
|
||||
except ValueError:
|
||||
return self.answer
|
||||
elif self.question.type == Question.TYPE_TIME and self.answer:
|
||||
try:
|
||||
d = dateutil.parser.parse(self.answer)
|
||||
return date_format(d, "TIME_FORMAT")
|
||||
except ValueError:
|
||||
return self.answer
|
||||
else:
|
||||
return self.answer
|
||||
|
||||
@@ -585,7 +614,7 @@ class AbstractPosition(models.Model):
|
||||
else:
|
||||
return {}
|
||||
|
||||
def cache_answers(self):
|
||||
def cache_answers(self, all=True):
|
||||
"""
|
||||
Creates two properties on the object.
|
||||
(1) answ: a dictionary of question.id → answer string
|
||||
@@ -598,7 +627,13 @@ class AbstractPosition(models.Model):
|
||||
# We need to clone our question objects, otherwise we will override the cached
|
||||
# answers of other items in the same cart if the question objects have been
|
||||
# selected via prefetch_related
|
||||
self.questions = list(copy.copy(q) for q in self.item.questions.all())
|
||||
if not all:
|
||||
if hasattr(self.item, 'questions_to_ask'):
|
||||
self.questions = list(copy.copy(q) for q in self.item.questions_to_ask)
|
||||
else:
|
||||
self.questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False))
|
||||
else:
|
||||
self.questions = list(copy.copy(q) for q in self.item.questions.all())
|
||||
for q in self.questions:
|
||||
if q.id in self.answ:
|
||||
q.answer = self.answ[q.id]
|
||||
@@ -824,7 +859,7 @@ class CartPosition(AbstractPosition):
|
||||
the checkout process. This has all properties of AbstractPosition.
|
||||
|
||||
:param event: The event this belongs to
|
||||
:type event: Evnt
|
||||
:type event: Event
|
||||
:param cart_id: The user session that contains this cart position
|
||||
:type cart_id: str
|
||||
"""
|
||||
|
||||
@@ -87,7 +87,7 @@ class WaitingListEntry(LoggedModel):
|
||||
if self.variation
|
||||
else self.item.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache)
|
||||
)
|
||||
if availability[1] < 1:
|
||||
if availability[1] is None or availability[1] < 1:
|
||||
raise WaitingListException(_('This product is currently not available.'))
|
||||
if self.voucher:
|
||||
raise WaitingListException(_('A voucher has already been sent to this person.'))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from email.utils import formataddr
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
import bleach
|
||||
@@ -90,6 +91,10 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
body, body_md = render_mail(template, context)
|
||||
subject = str(subject).format_map(context)
|
||||
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
|
||||
if event:
|
||||
sender = formataddr((str(event.name), sender))
|
||||
else:
|
||||
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
|
||||
|
||||
subject = str(subject)
|
||||
body_plain = body
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytz
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Max, Q
|
||||
from django.db.models import F, Max, Q, Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import make_aware, now
|
||||
@@ -128,8 +128,14 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
|
||||
order_paid.send(order.event, order=order)
|
||||
|
||||
invoice = None
|
||||
if order.event.settings.get('invoice_generate') in ('True', 'paid') and invoice_qualified(order):
|
||||
if not order.invoices.exists():
|
||||
if invoice_qualified(order):
|
||||
invoices = order.invoices.filter(is_cancellation=False).count()
|
||||
cancellations = order.invoices.filter(is_cancellation=True).count()
|
||||
gen_invoice = (
|
||||
(invoices == 0 and order.event.settings.get('invoice_generate') in ('True', 'paid')) or
|
||||
0 < invoices <= cancellations
|
||||
)
|
||||
if gen_invoice:
|
||||
invoice = generate_invoice(
|
||||
order,
|
||||
trigger_pdf=not send_mail or not order.event.settings.invoice_email_attachment
|
||||
@@ -231,6 +237,32 @@ def mark_order_refunded(order, user=None):
|
||||
return order
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def mark_order_expired(order, user=None, api_token=None):
|
||||
"""
|
||||
Mark this order as expired. This sets the payment status and returns the order object.
|
||||
:param order: The order to change
|
||||
:param user: The user that performed the change
|
||||
:param api_token: The API token used to performed the change
|
||||
"""
|
||||
if isinstance(order, int):
|
||||
order = Order.objects.get(pk=order)
|
||||
if isinstance(user, int):
|
||||
user = User.objects.get(pk=user)
|
||||
if isinstance(api_token, int):
|
||||
api_token = TeamAPIToken.objects.get(pk=api_token)
|
||||
with order.event.lock():
|
||||
order.status = Order.STATUS_EXPIRED
|
||||
order.save()
|
||||
|
||||
order.log_action('pretix.event.order.expired', user=user, api_token=api_token)
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i:
|
||||
generate_cancellation(i)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None):
|
||||
"""
|
||||
@@ -245,7 +277,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None):
|
||||
if isinstance(api_token, int):
|
||||
api_token = TeamAPIToken.objects.get(pk=api_token)
|
||||
with order.event.lock():
|
||||
if order.status != Order.STATUS_PENDING:
|
||||
if not order.cancel_allowed():
|
||||
raise OrderError(_('You cannot cancel this order.'))
|
||||
order.status = Order.STATUS_CANCELED
|
||||
order.save()
|
||||
@@ -513,7 +545,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
invoice = generate_invoice(order, trigger_pdf=not event.settings.invoice_email_attachment)
|
||||
# send_mail will trigger PDF generation later
|
||||
|
||||
if order.total == Decimal('0.00'):
|
||||
if order.payment_provider == 'free':
|
||||
email_template = event.settings.mail_text_order_free
|
||||
log_entry = 'pretix.event.order.email.order_free'
|
||||
else:
|
||||
@@ -562,9 +594,7 @@ def expire_orders(sender, **kwargs):
|
||||
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
|
||||
eventcache[o.event.pk] = expire
|
||||
if expire:
|
||||
o.status = Order.STATUS_EXPIRED
|
||||
o.log_action('pretix.event.order.expired')
|
||||
o.save()
|
||||
mark_order_expired(o)
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@@ -646,7 +676,7 @@ def send_download_reminders(sender, **kwargs):
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.expire_warning_sent'
|
||||
'pretix.event.order.email.download_reminder_sent'
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
@@ -680,6 +710,7 @@ class OrderChangeManager:
|
||||
self.order = order
|
||||
self.user = user
|
||||
self.split_order = None
|
||||
self._committed = False
|
||||
self._totaldiff = 0
|
||||
self._quotadiff = Counter()
|
||||
self._operations = []
|
||||
@@ -824,7 +855,10 @@ class OrderChangeManager:
|
||||
raise OrderError(self.error_messages['paid_price_change'])
|
||||
|
||||
def _check_paid_to_free(self):
|
||||
if self.order.total == 0:
|
||||
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)):
|
||||
# if the order becomes free, mark it paid using the 'free' provider
|
||||
# this could happen if positions have been made cheaper or removed (_totaldiff < 0)
|
||||
# or positions got split off to a new order (split_order with positive total)
|
||||
try:
|
||||
mark_order_paid(
|
||||
self.order, 'free', send_mail=False, count_waitinglist=False,
|
||||
@@ -1015,6 +1049,16 @@ class OrderChangeManager:
|
||||
self.order.total += sum([f.value for f in self.order.fees.all()])
|
||||
self.order.save()
|
||||
|
||||
def _payment_fee_diff(self):
|
||||
prov = self._get_payment_provider()
|
||||
if self.order.status != Order.STATUS_PAID and prov:
|
||||
# payment fees of paid orders do not change
|
||||
old_fee = OrderFee.objects.filter(order=self.order, fee_type=OrderFee.FEE_TYPE_PAYMENT).aggregate(s=Sum('value'))['s'] or 0
|
||||
new_total = sum([p.price for p in self.order.positions.all()]) + self._totaldiff
|
||||
if new_total != 0:
|
||||
new_fee = prov.calculate_fee(new_total)
|
||||
self._totaldiff += new_fee - old_fee
|
||||
|
||||
def _reissue_invoice(self):
|
||||
i = self.order.invoices.filter(is_cancellation=False).last()
|
||||
if i and self._invoice_dirty:
|
||||
@@ -1062,9 +1106,18 @@ class OrderChangeManager:
|
||||
logger.exception('Order changed email could not be sent')
|
||||
|
||||
def commit(self):
|
||||
if self._committed:
|
||||
# an order change can only be committed once
|
||||
raise OrderError(error_messages['internal'])
|
||||
self._committed = True
|
||||
|
||||
if not self._operations:
|
||||
# Do nothing
|
||||
return
|
||||
|
||||
# finally, incorporate difference in payment fees
|
||||
self._payment_fee_diff()
|
||||
|
||||
with transaction.atomic():
|
||||
with self.order.event.lock():
|
||||
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
@@ -1078,6 +1131,7 @@ class OrderChangeManager:
|
||||
self._reissue_invoice()
|
||||
self._clear_tickets_cache()
|
||||
self._check_paid_to_free()
|
||||
|
||||
if self.notify:
|
||||
self._notify_user(self.order)
|
||||
if self.split_order:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import sys
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.models import Event, User, WaitingListEntry
|
||||
@@ -41,7 +43,7 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
|
||||
if wle.variation
|
||||
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache, subevent=wle.subevent)
|
||||
)
|
||||
if availability[1] > 0:
|
||||
if availability[1] is None or availability[1] > 0:
|
||||
try:
|
||||
wle.send_voucher(quota_cache, user=user)
|
||||
sent += 1
|
||||
@@ -52,7 +54,7 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
|
||||
for q in quotas:
|
||||
quota_cache[q.pk] = (
|
||||
quota_cache[q.pk][0] if quota_cache[q.pk][0] > 1 else 0,
|
||||
quota_cache[q.pk][1] - 1
|
||||
quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize
|
||||
)
|
||||
else:
|
||||
gone.add((wle.item, wle.variation))
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixbase/scss/error.scss" %}" />
|
||||
{% endcompress %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8">
|
||||
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="splitdatetimerow">
|
||||
{% include 'django/forms/widgets/multiwidget.html' %}
|
||||
</div>
|
||||
194
src/pretix/base/views/mixins.py
Normal file
194
src/pretix/base/views/mixins.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db.models import Prefetch
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from pretix.base.forms.questions import (
|
||||
BaseInvoiceAddressForm, BaseQuestionsForm,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
|
||||
QuestionOption,
|
||||
)
|
||||
|
||||
|
||||
class BaseQuestionsViewMixin:
|
||||
form_class = BaseQuestionsForm
|
||||
|
||||
@staticmethod
|
||||
def _keyfunc(pos):
|
||||
# Sort addons after the item they are an addon to
|
||||
if isinstance(pos, OrderPosition):
|
||||
i = pos.addon_to.positionid if pos.addon_to else pos.positionid
|
||||
else:
|
||||
i = pos.addon_to.pk if pos.addon_to else pos.pk
|
||||
addon_penalty = 1 if pos.addon_to else 0
|
||||
return i, addon_penalty, pos.pk
|
||||
|
||||
@cached_property
|
||||
def _positions_for_questions(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@cached_property
|
||||
def forms(self):
|
||||
"""
|
||||
A list of forms with one form for each cart position that has questions
|
||||
the user can answer. All forms have a custom prefix, so that they can all be
|
||||
submitted at once.
|
||||
"""
|
||||
formlist = []
|
||||
for cr in self._positions_for_questions:
|
||||
cartpos = cr if isinstance(cr, CartPosition) else None
|
||||
orderpos = cr if isinstance(cr, OrderPosition) else None
|
||||
form = self.form_class(event=self.request.event,
|
||||
prefix=cr.id,
|
||||
cartpos=cartpos,
|
||||
orderpos=orderpos,
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
files=(self.request.FILES if self.request.method == 'POST' else None))
|
||||
form.pos = cartpos or orderpos
|
||||
if len(form.fields) > 0:
|
||||
formlist.append(form)
|
||||
return formlist
|
||||
|
||||
@cached_property
|
||||
def formdict(self):
|
||||
storage = OrderedDict()
|
||||
for f in self.forms:
|
||||
pos = f.cartpos or f.orderpos
|
||||
if pos.addon_to_id:
|
||||
if pos.addon_to not in storage:
|
||||
storage[pos.addon_to] = []
|
||||
storage[pos.addon_to].append(f)
|
||||
else:
|
||||
if pos not in storage:
|
||||
storage[pos] = []
|
||||
storage[pos].append(f)
|
||||
return storage
|
||||
|
||||
def save(self):
|
||||
failed = False
|
||||
for form in self.forms:
|
||||
meta_info = form.pos.meta_info_data
|
||||
# Every form represents a CartPosition or OrderPosition with questions attached
|
||||
if not form.is_valid():
|
||||
failed = True
|
||||
else:
|
||||
# This form was correctly filled, so we store the data as
|
||||
# answers to the questions / in the CartPosition object
|
||||
for k, v in form.cleaned_data.items():
|
||||
if k == 'attendee_name':
|
||||
form.pos.attendee_name = v if v != '' else None
|
||||
form.pos.save()
|
||||
elif k == 'attendee_email':
|
||||
form.pos.attendee_email = v if v != '' else None
|
||||
form.pos.save()
|
||||
elif k.startswith('question_') and v is not None:
|
||||
field = form.fields[k]
|
||||
if hasattr(field, 'answer'):
|
||||
# We already have a cached answer object, so we don't
|
||||
# have to create a new one
|
||||
if v == '' or v is None or (isinstance(field, forms.FileField) and v is False):
|
||||
if field.answer.file:
|
||||
field.answer.file.delete()
|
||||
field.answer.delete()
|
||||
else:
|
||||
self._save_to_answer(field, field.answer, v)
|
||||
field.answer.save()
|
||||
elif v != '':
|
||||
answer = QuestionAnswer(
|
||||
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
|
||||
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
|
||||
question=field.question,
|
||||
)
|
||||
self._save_to_answer(field, answer, v)
|
||||
answer.save()
|
||||
else:
|
||||
meta_info.setdefault('question_form_data', {})
|
||||
if v is None:
|
||||
if k in meta_info['question_form_data']:
|
||||
del meta_info['question_form_data'][k]
|
||||
else:
|
||||
meta_info['question_form_data'][k] = v
|
||||
|
||||
form.pos.meta_info = json.dumps(meta_info)
|
||||
form.pos.save(update_fields=['meta_info'])
|
||||
return not failed
|
||||
|
||||
def _save_to_answer(self, field, answer, value):
|
||||
if isinstance(field, forms.ModelMultipleChoiceField):
|
||||
answstr = ", ".join([str(o) for o in value])
|
||||
if not answer.pk:
|
||||
answer.save()
|
||||
else:
|
||||
answer.options.clear()
|
||||
answer.answer = answstr
|
||||
answer.options.add(*value)
|
||||
elif isinstance(field, forms.ModelChoiceField):
|
||||
if not answer.pk:
|
||||
answer.save()
|
||||
else:
|
||||
answer.options.clear()
|
||||
answer.options.add(value)
|
||||
answer.answer = value.answer
|
||||
elif isinstance(field, forms.FileField):
|
||||
if isinstance(value, UploadedFile):
|
||||
answer.file.save(value.name, value)
|
||||
answer.answer = 'file://' + value.name
|
||||
else:
|
||||
answer.answer = value
|
||||
|
||||
|
||||
class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
invoice_form_class = BaseInvoiceAddressForm
|
||||
|
||||
@cached_property
|
||||
def _positions_for_questions(self):
|
||||
return self.positions
|
||||
|
||||
@cached_property
|
||||
def positions(self):
|
||||
return list(self.order.positions.select_related(
|
||||
'item', 'variation'
|
||||
).prefetch_related(
|
||||
Prefetch('answers',
|
||||
QuestionAnswer.objects.prefetch_related('options'),
|
||||
to_attr='answerlist'),
|
||||
Prefetch('item__questions',
|
||||
Question.objects.filter(ask_during_checkin=False).prefetch_related(
|
||||
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
|
||||
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
|
||||
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
|
||||
# a prefetch lookup on this query...
|
||||
'question',
|
||||
Question.objects.none(),
|
||||
to_attr='dummy'
|
||||
)))
|
||||
),
|
||||
to_attr='questions_to_ask')
|
||||
))
|
||||
|
||||
@cached_property
|
||||
def invoice_address(self):
|
||||
try:
|
||||
return self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
return InvoiceAddress(order=self.order)
|
||||
|
||||
@cached_property
|
||||
def invoice_form(self):
|
||||
return self.invoice_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
event=self.request.event,
|
||||
instance=self.invoice_address, validate_vat_id=False
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['order'] = self.order
|
||||
ctx['formgroups'] = self.formdict.items()
|
||||
ctx['invoice_form'] = self.invoice_form
|
||||
return ctx
|
||||
@@ -3,11 +3,12 @@ from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import Resolver404, get_script_prefix, resolve
|
||||
from django.utils.translation import get_language
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
from ..helpers.i18n import get_javascript_format, get_moment_locale
|
||||
from .signals import html_head, nav_event, nav_global, nav_topbar
|
||||
from .utils.i18n import get_javascript_format, get_moment_locale
|
||||
|
||||
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
||||
|
||||
@@ -57,6 +58,10 @@ def contextprocessor(request):
|
||||
ctx['new_session'] = child_sess
|
||||
request.session['event_access'] = True
|
||||
|
||||
if request.GET.get('subevent', ''):
|
||||
# Do not use .get() for lazy evaluation
|
||||
ctx['selected_subevents'] = request.event.subevents.filter(pk=request.GET.get('subevent'))
|
||||
|
||||
ctx['nav_event'] = _nav_event
|
||||
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
|
||||
|
||||
@@ -77,6 +82,7 @@ def contextprocessor(request):
|
||||
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()
|
||||
ctx['select2locale'] = get_language()[:2]
|
||||
|
||||
if settings.DEBUG and 'runserver' not in sys.argv:
|
||||
ctx['debug_warning'] = True
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import os
|
||||
|
||||
from django import forms
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from ...base.forms import I18nModelForm
|
||||
# Import for backwards compatibility with okd import paths
|
||||
from ...base.forms.widgets import ( # noqa
|
||||
DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget,
|
||||
)
|
||||
|
||||
|
||||
class TolerantFormsetModelForm(I18nModelForm):
|
||||
@@ -100,34 +102,3 @@ class SlugWidget(forms.TextInput):
|
||||
ctx = super().get_context(name, value, attrs)
|
||||
ctx['pre'] = self.prefix
|
||||
return ctx
|
||||
|
||||
|
||||
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
|
||||
def __init__(self, attrs=None, date_format=None, time_format=None):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
date_attrs = dict(attrs)
|
||||
time_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
time_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
date_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
time_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
|
||||
widgets = (
|
||||
forms.DateInput(attrs=date_attrs, format=date_format),
|
||||
forms.TimeInput(attrs=time_attrs, format=time_format),
|
||||
)
|
||||
# Skip one hierarchy level
|
||||
forms.MultiWidget.__init__(self, widgets, attrs)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from django import forms
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from pretix.base.models.checkin import CheckinList
|
||||
from pretix.control.forms.widgets import Select2
|
||||
|
||||
|
||||
class CheckinListForm(forms.ModelForm):
|
||||
@@ -11,6 +14,17 @@ class CheckinListForm(forms.ModelForm):
|
||||
self.fields['limit_products'].queryset = self.event.items.all()
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'Date')
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
self.fields['subevent'].required = True
|
||||
else:
|
||||
del self.fields['subevent']
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db.models import Q
|
||||
@@ -951,3 +952,43 @@ class WidgetCodeForm(forms.Form):
|
||||
raise ValidationError(_('The given voucher code does not exist.'))
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class EventDeleteForm(forms.Form):
|
||||
error_messages = {
|
||||
'pw_current_wrong': _("The password you entered was not correct."),
|
||||
'slug_wrong': _("The slug you entered was not correct."),
|
||||
}
|
||||
user_pw = forms.CharField(
|
||||
max_length=255,
|
||||
label=_("New password"),
|
||||
widget=forms.PasswordInput()
|
||||
)
|
||||
slug = forms.CharField(
|
||||
max_length=255,
|
||||
label=_("Event slug"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_user_pw(self):
|
||||
user_pw = self.cleaned_data.get('user_pw')
|
||||
if not check_password(user_pw, self.user.password):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_current_wrong'],
|
||||
code='pw_current_wrong',
|
||||
)
|
||||
|
||||
return user_pw
|
||||
|
||||
def clean_slug(self):
|
||||
slug = self.cleaned_data.get('slug')
|
||||
if slug != self.event.slug:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['slug_wrong'],
|
||||
code='slug_wrong',
|
||||
)
|
||||
return slug
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.db.models.functions import Coalesce, Concat
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, Invoice, Item, Order, Organizer, SubEvent
|
||||
from pretix.base.models import (
|
||||
Checkin, Event, Invoice, Item, Order, OrderPosition, Organizer, SubEvent,
|
||||
)
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.control.utils.i18n import i18ncomp
|
||||
from pretix.control.forms.widgets import Select2
|
||||
from pretix.helpers.database import FixedOrderBy, rolledback_transaction
|
||||
from pretix.helpers.i18n import i18ncomp
|
||||
|
||||
PAYMENT_PROVIDERS = []
|
||||
|
||||
@@ -115,25 +119,25 @@ class OrderFilterForm(FilterForm):
|
||||
else:
|
||||
code = Q(code__icontains=Order.normalize_code(u))
|
||||
|
||||
matching_invoice = Invoice.objects.filter(
|
||||
order=OuterRef('pk'),
|
||||
).annotate(
|
||||
inr=Concat('prefix', 'invoice_no')
|
||||
).filter(
|
||||
matching_invoices = Invoice.objects.filter(
|
||||
Q(invoice_no__iexact=u)
|
||||
| Q(invoice_no__iexact=u.zfill(5))
|
||||
| Q(inr=u)
|
||||
)
|
||||
| Q(full_invoice_no__iexact=u)
|
||||
).values_list('order_id', flat=True)
|
||||
|
||||
qs = qs.annotate(has_inv=Exists(matching_invoice))
|
||||
qs = qs.filter(
|
||||
matching_positions = OrderPosition.objects.filter(
|
||||
Q(order=OuterRef('pk')) & Q(
|
||||
Q(attendee_name__icontains=u) | Q(attendee_email__icontains=u)
|
||||
)
|
||||
).values('id')
|
||||
|
||||
qs = qs.annotate(has_pos=Exists(matching_positions)).filter(
|
||||
code
|
||||
| Q(email__icontains=u)
|
||||
| Q(positions__attendee_name__icontains=u)
|
||||
| Q(positions__attendee_email__icontains=u)
|
||||
| Q(invoice_address__name__icontains=u)
|
||||
| Q(invoice_address__company__icontains=u)
|
||||
| Q(has_inv=True)
|
||||
| Q(pk__in=matching_invoices)
|
||||
| Q(has_pos=True)
|
||||
)
|
||||
|
||||
if fdata.get('status'):
|
||||
@@ -180,6 +184,17 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'All dates')
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
elif 'subevent':
|
||||
del self.fields['subevent']
|
||||
|
||||
@@ -205,7 +220,14 @@ class OrderSearchFilterForm(OrderFilterForm):
|
||||
label=_('Organizer'),
|
||||
queryset=Organizer.objects.none(),
|
||||
required=False,
|
||||
empty_label=_('All organizers')
|
||||
empty_label=_('All organizers'),
|
||||
widget=Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse_lazy('control:organizers.select2'),
|
||||
'data-placeholder': _('All organizers')
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -339,6 +361,9 @@ class EventFilterForm(FilterForm):
|
||||
('notlive', _('Shop not live')),
|
||||
('future', _('Presale not started')),
|
||||
('past', _('Presale over')),
|
||||
('date_future', _('Single event running or in the future')),
|
||||
('date_past', _('Single event in the past')),
|
||||
('series', _('Event series')),
|
||||
),
|
||||
required=False
|
||||
)
|
||||
@@ -346,7 +371,14 @@ class EventFilterForm(FilterForm):
|
||||
label=_('Organizer'),
|
||||
queryset=Organizer.objects.none(),
|
||||
required=False,
|
||||
empty_label=_('All organizers')
|
||||
empty_label=_('All organizers'),
|
||||
widget=Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse_lazy('control:organizers.select2'),
|
||||
'data-placeholder': _('All organizers')
|
||||
}
|
||||
)
|
||||
)
|
||||
query = forms.CharField(
|
||||
label=_('Event name'),
|
||||
@@ -386,6 +418,24 @@ class EventFilterForm(FilterForm):
|
||||
qs = qs.filter(presale_start__gte=now())
|
||||
elif fdata.get('status') == 'past':
|
||||
qs = qs.filter(presale_end__lte=now())
|
||||
elif fdata.get('status') == 'date_future':
|
||||
qs = qs.filter(
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
|
||||
)
|
||||
)
|
||||
elif fdata.get('status') == 'date_past':
|
||||
qs = qs.filter(
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
|
||||
)
|
||||
)
|
||||
elif fdata.get('status') == 'series':
|
||||
qs = qs.filter(has_subevents=True)
|
||||
|
||||
if fdata.get('organizer'):
|
||||
qs = qs.filter(organizer=fdata.get('organizer'))
|
||||
@@ -489,3 +539,152 @@ class CheckInFilterForm(FilterForm):
|
||||
qs = qs.filter(item=fdata.get('item'))
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class UserFilterForm(FilterForm):
|
||||
orders = {
|
||||
'fullname': 'fullname',
|
||||
'email': 'email',
|
||||
}
|
||||
status = forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('active', _('Active')),
|
||||
('inactive', _('Inactive')),
|
||||
),
|
||||
required=False
|
||||
)
|
||||
superuser = forms.ChoiceField(
|
||||
label=_('Administrator'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('yes', _('Administrator')),
|
||||
('no', _('No administrator')),
|
||||
),
|
||||
required=False
|
||||
)
|
||||
query = forms.CharField(
|
||||
label=_('Search query'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('Search query'),
|
||||
'autofocus': 'autofocus'
|
||||
}),
|
||||
required=False
|
||||
)
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
|
||||
if fdata.get('status') == 'active':
|
||||
qs = qs.filter(is_active=True)
|
||||
elif fdata.get('status') == 'inactive':
|
||||
qs = qs.filter(is_active=False)
|
||||
|
||||
if fdata.get('superuser') == 'yes':
|
||||
qs = qs.filter(is_superuser=True)
|
||||
elif fdata.get('superuser') == 'no':
|
||||
qs = qs.filter(is_superuser=False)
|
||||
|
||||
if fdata.get('query'):
|
||||
qs = qs.filter(
|
||||
Q(email__icontains=fdata.get('query'))
|
||||
| Q(fullname__icontains=fdata.get('query'))
|
||||
)
|
||||
|
||||
if fdata.get('ordering'):
|
||||
qs = qs.order_by(self.get_order_by())
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class VoucherFilterForm(FilterForm):
|
||||
orders = {
|
||||
}
|
||||
status = forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('v', _('Valid')),
|
||||
('r', _('Redeemed')),
|
||||
('e', _('Expired')),
|
||||
('c', _('Redeemed and checked in with ticket')),
|
||||
),
|
||||
required=False
|
||||
)
|
||||
tag = forms.CharField(
|
||||
label=_('Filter by tag'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('Filter by tag'),
|
||||
}),
|
||||
required=False
|
||||
)
|
||||
search = forms.CharField(
|
||||
label=_('Search voucher'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('Search voucher'),
|
||||
'autofocus': 'autofocus'
|
||||
}),
|
||||
required=False
|
||||
)
|
||||
subevent = forms.ModelChoiceField(
|
||||
label=pgettext_lazy('subevent', 'Date'),
|
||||
queryset=SubEvent.objects.none(),
|
||||
required=False,
|
||||
empty_label=pgettext_lazy('subevent', 'All dates')
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'All dates')
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
elif 'subevent':
|
||||
del self.fields['subevent']
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
|
||||
if fdata.get('search'):
|
||||
s = fdata.get('search').strip()
|
||||
qs = qs.filter(Q(code__icontains=s) | Q(tag__icontains=s) | Q(comment__icontains=s))
|
||||
|
||||
if fdata.get('tag'):
|
||||
s = fdata.get('tag').strip()
|
||||
qs = qs.filter(tag__icontains=s)
|
||||
|
||||
if fdata.get('status'):
|
||||
s = fdata.get('status')
|
||||
if s == 'v':
|
||||
qs = qs.filter(Q(valid_until__isnull=True) | Q(valid_until__gt=now())).filter(redeemed=0)
|
||||
elif s == 'r':
|
||||
qs = qs.filter(redeemed__gt=0)
|
||||
elif s == 'e':
|
||||
qs = qs.filter(Q(valid_until__isnull=False) & Q(valid_until__lt=now())).filter(redeemed=0)
|
||||
elif s == 'c':
|
||||
checkins = Checkin.objects.filter(
|
||||
position__voucher=OuterRef('pk')
|
||||
)
|
||||
qs = qs.annotate(has_checkin=Exists(checkins)).filter(
|
||||
redeemed__gt=0, has_checkin=True
|
||||
)
|
||||
|
||||
if fdata.get('subevent'):
|
||||
qs = qs.filter(subevent_id=fdata.get('subevent').pk)
|
||||
|
||||
if fdata.get('ordering'):
|
||||
qs = qs.order_by(self.get_order_by())
|
||||
|
||||
return qs
|
||||
|
||||
@@ -4,7 +4,10 @@ from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Max
|
||||
from django.forms.formsets import DELETION_FIELD_NAME
|
||||
from django.utils.translation import ugettext as __, ugettext_lazy as _
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import (
|
||||
pgettext_lazy, ugettext as __, ugettext_lazy as _,
|
||||
)
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
|
||||
from pretix.base.forms import I18nFormSet, I18nModelForm
|
||||
@@ -13,6 +16,7 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.items import ItemAddOn
|
||||
from pretix.control.forms import SplitDateTimePickerWidget
|
||||
from pretix.control.forms.widgets import Select2
|
||||
|
||||
|
||||
class CategoryForm(I18nModelForm):
|
||||
@@ -45,6 +49,7 @@ class QuestionForm(I18nModelForm):
|
||||
'help_text',
|
||||
'type',
|
||||
'required',
|
||||
'ask_during_checkin',
|
||||
'items'
|
||||
]
|
||||
widgets = {
|
||||
@@ -94,6 +99,18 @@ class QuotaForm(I18nModelForm):
|
||||
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'Date')
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
self.fields['subevent'].required = True
|
||||
else:
|
||||
del self.fields['subevent']
|
||||
|
||||
|
||||
88
src/pretix/control/forms/users.py
Normal file
88
src/pretix/control/forms/users.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
)
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from pytz import common_timezones
|
||||
|
||||
from pretix.base.models import User
|
||||
|
||||
|
||||
class UserEditForm(forms.ModelForm):
|
||||
error_messages = {
|
||||
'duplicate_identifier': _("There already is an account associated with this e-mail address. "
|
||||
"Please choose a different one."),
|
||||
'pw_mismatch': _("Please enter the same password twice"),
|
||||
}
|
||||
|
||||
new_pw = forms.CharField(max_length=255,
|
||||
required=False,
|
||||
label=_("New password"),
|
||||
widget=forms.PasswordInput())
|
||||
new_pw_repeat = forms.CharField(max_length=255,
|
||||
required=False,
|
||||
label=_("Repeat new password"),
|
||||
widget=forms.PasswordInput())
|
||||
timezone = forms.ChoiceField(
|
||||
choices=((a, a) for a in common_timezones),
|
||||
label=_("Default timezone"),
|
||||
help_text=_('Only used for views that are not bound to an event. For all '
|
||||
'event views, the event timezone is used instead.')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'fullname',
|
||||
'locale',
|
||||
'timezone',
|
||||
'email',
|
||||
'require_2fa',
|
||||
'is_active',
|
||||
'is_superuser'
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['email'].required = True
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
if User.objects.filter(Q(email=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_identifier'],
|
||||
code='duplicate_identifier',
|
||||
)
|
||||
return email
|
||||
|
||||
def clean_new_pw(self):
|
||||
password1 = self.cleaned_data.get('new_pw', '')
|
||||
if password1 and validate_password(password1, user=self.instance) is not None:
|
||||
raise forms.ValidationError(
|
||||
_(password_validators_help_texts()),
|
||||
code='pw_invalid'
|
||||
)
|
||||
return password1
|
||||
|
||||
def clean_new_pw_repeat(self):
|
||||
password1 = self.cleaned_data.get('new_pw')
|
||||
password2 = self.cleaned_data.get('new_pw_repeat')
|
||||
if password1 and password1 != password2:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_mismatch'],
|
||||
code='pw_mismatch'
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
password1 = self.cleaned_data.get('new_pw')
|
||||
|
||||
if password1:
|
||||
self.instance.set_password(password1)
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('Your changes could not be saved. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
38
src/pretix/control/forms/widgets.py
Normal file
38
src/pretix/control/forms/widgets.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class Select2Mixin:
|
||||
template_name = 'pretixcontrol/select2_widget.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def options(self, name, value, attrs=None):
|
||||
if value and value[0]:
|
||||
for i, selected in enumerate(self.choices.queryset.filter(pk__in=value)):
|
||||
yield self.create_option(
|
||||
None,
|
||||
self.choices.field.prepare_value(selected),
|
||||
self.choices.field.label_from_instance(selected),
|
||||
True,
|
||||
i,
|
||||
subindex=None,
|
||||
attrs=attrs
|
||||
)
|
||||
return
|
||||
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
if value:
|
||||
return [
|
||||
(None, [c], i)
|
||||
for i, c in enumerate(self.options(name, value, attrs))
|
||||
]
|
||||
return
|
||||
|
||||
|
||||
class Select2(Select2Mixin, forms.Select):
|
||||
pass
|
||||
|
||||
|
||||
class Select2Multiple(Select2Mixin, forms.SelectMultiple):
|
||||
pass
|
||||
@@ -127,6 +127,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.payment.changed': _('The payment method has been changed.'),
|
||||
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
|
||||
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
|
||||
'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket '
|
||||
'is available for download.'),
|
||||
'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 the user that the order has been canceled.'),
|
||||
@@ -135,6 +137,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.email.order_paid': _('An email has been sent to notify the user that payment has been received.'),
|
||||
'pretix.event.order.email.order_placed': _('An email has been sent to notify the user that the order has been received and requires payment.'),
|
||||
'pretix.event.order.email.resend': _('An email with a link to the order detail page has been resent to the user.'),
|
||||
'pretix.control.auth.user.created': _('The user has been created.'),
|
||||
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
|
||||
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
|
||||
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
|
||||
@@ -280,4 +283,14 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
text = text + ' ' + str(_('Your email address has been changed to {email}.').format(email=data['email']))
|
||||
if 'new_pw' in data:
|
||||
text = text + ' ' + str(_('Your password has been changed.'))
|
||||
if data.get('is_active') is True:
|
||||
text = text + ' ' + str(_('Your account has been enabled.'))
|
||||
elif data.get('is_active') is False:
|
||||
text = text + ' ' + str(_('Your account has been disabled.'))
|
||||
return text
|
||||
|
||||
if logentry.action_type == 'pretix.control.auth.user.impersonated':
|
||||
return str(_('You impersonated {}.')).format(data['other_email'])
|
||||
|
||||
if logentry.action_type == 'pretix.control.auth.user.impersonate_stopped':
|
||||
return str(_('You stopped impersonating {}.')).format(data['other_email'])
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% csrf_token %}
|
||||
<h3>{% trans "Welcome back!" %}</h3>
|
||||
<p>
|
||||
{% trans "You configured your account two require authentification with a second medium, e.g. your phone. Please enter your verification code here:" %}
|
||||
{% trans "You configured your account to require authentification with a second medium, e.g. your phone. Please enter your verification code here:" %}
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<input class="form-control" name="token" placeholder="{% trans "Token" %}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% load compress %}
|
||||
{% load staticfiles %}
|
||||
{% load i18n %}
|
||||
{% load hijack_tags %}
|
||||
{% load statici18n %}
|
||||
{% load eventurl %}
|
||||
<!DOCTYPE html>
|
||||
@@ -23,6 +24,9 @@
|
||||
<script type="text/javascript" src="{% static "bootstrap/js/bootstrap.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "moment/moment-with-locales.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "select2/select2.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "select2/i18n/de.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "select2/i18n/en.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "charts/raphael-min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "charts/morris.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "clipboard/clipboard.js" %}"></script>
|
||||
@@ -47,7 +51,7 @@
|
||||
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
{% block custom_header %}{% endblock %}
|
||||
</head>
|
||||
<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 }}">
|
||||
<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 }}" data-select2-locale="{{ select2locale }}">
|
||||
<div id="wrapper">
|
||||
<nav class="navbar navbar-inverse navbar-static-top" role="navigation">
|
||||
<div class="navbar-header">
|
||||
@@ -214,6 +218,15 @@
|
||||
{% trans "Order search" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li>
|
||||
<a href="{% url 'control:users' %}"
|
||||
{% if "users" in url_name %}class="active"{% endif %}>
|
||||
<i class="fa fa-user fa-fw"></i>
|
||||
{% trans "Users" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for nav in nav_global %}
|
||||
<li>
|
||||
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
|
||||
@@ -246,6 +259,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% if request|is_hijacked %}
|
||||
<div class="impersonate-warning">
|
||||
<span class="fa fa-user-secret"></span>
|
||||
{% blocktrans with user=request.user%}You are currently working on behalf of {{ user }}.{% endblocktrans %}
|
||||
|
||||
<form action="{% url 'control:users.impersonate.stop' %}" method="post" class="helper-display-inline">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-sm">
|
||||
{% trans "Stop impersonating" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="page-wrapper">
|
||||
<div class="container-fluid">
|
||||
{% if messages %}
|
||||
|
||||
@@ -21,20 +21,9 @@
|
||||
</p>
|
||||
{% if request.event.has_subevents %}
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<p>
|
||||
{% 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 %}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
</p>
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
|
||||
</form>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if checkinlists|length == 0 %}
|
||||
|
||||
@@ -2,29 +2,88 @@
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Dashboard" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Dashboard" %}</h1>
|
||||
<h1>{% trans "Dashboard" %}</h1>
|
||||
|
||||
<div class="dropdown-container">
|
||||
<input type="text" class="form-control" id="dashboard_query"
|
||||
placeholder="{% trans "Go to event" %}"
|
||||
data-typeahead-query autofocus>
|
||||
<ul data-event-typeahead data-source="{% url "control:events.typeahead" %}" data-typeahead-field="#dashboard_query"
|
||||
class="event-dropdown dropdown-menu">
|
||||
class="event-dropdown dropdown-menu">
|
||||
</ul>
|
||||
</div>
|
||||
<h2>{% trans "Your upcoming events" %}</h2>
|
||||
<div class="dashboard">
|
||||
{% for w in widgets %}
|
||||
<div class="widget-small widget-container">
|
||||
<a href="{% url "control:events.add" %}" class="widget">
|
||||
<div class="newevent"><span class="fa fa-plus-circle"></span>{% trans "Create a new event" %}</div>
|
||||
</a>
|
||||
</div>
|
||||
{% for w in upcoming %}
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }}">
|
||||
{% if w.url %}
|
||||
<a href="{{ w.url }}" class="widget">
|
||||
{{ w.content|safe }}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="widget">
|
||||
{{ w.content|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="widget">
|
||||
{{ w.content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if upcoming %}
|
||||
<p class="">
|
||||
<a href="{% url "control:events" %}?ordering=date_from&status=date_future" class="">
|
||||
{% trans "View all upcoming events" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if past %}
|
||||
<h2>{% trans "Your most recent events" %}</h2>
|
||||
<div class="dashboard">
|
||||
{% for w in past %}
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }}">
|
||||
<div class="widget">
|
||||
{{ w.content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="">
|
||||
<a href="{% url "control:events" %}?ordering=date_from&status=-date_to" class="">
|
||||
{% trans "View all recent events" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if series %}
|
||||
<h2>{% trans "Your event series" %}</h2>
|
||||
<div class="dashboard">
|
||||
{% for w in series %}
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }}">
|
||||
<div class="widget">
|
||||
{{ w.content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="">
|
||||
<a href="{% url "control:events" %}?ordering=-date_to&status=series" class="">
|
||||
{% trans "View all event series" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if widgets %}
|
||||
<h2>{% trans "Other features" %}</h2>
|
||||
<div class="dashboard">
|
||||
{% for w in widgets %}
|
||||
<div class="widget-{{ w.display_size|default:"small" }} {{ w.container_class|default:"widget-container" }}">
|
||||
{% if w.url %}
|
||||
<a href="{{ w.url }}" class="widget">
|
||||
{{ w.content|safe }}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="widget">
|
||||
{{ w.content|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
70
src/pretix/control/templates/pretixcontrol/event/delete.html
Normal file
70
src/pretix/control/templates/pretixcontrol/event/delete.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Delete event" %}</h1>
|
||||
{% if request.event.allow_delete %}
|
||||
{% bootstrap_form_errors form layout="inline" %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
This operation will destroy your event including all configuration, products, quotas, questions,
|
||||
vouchers, lists, etc.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p><strong>
|
||||
{% blocktrans trimmed %}
|
||||
This operation is irreversible and there is no way to bring your data back.
|
||||
{% endblocktrans %}
|
||||
</strong></p>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans trimmed with slug=request.event.slug %}
|
||||
To confirm you really want this, please type out the event's short name ("{{ slug }}") here:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_field form.slug layout="inline" %}
|
||||
<p>
|
||||
{% blocktrans trimmed with slug=request.event.slug %}
|
||||
Also, to make sure it's really you, please enter your user password here:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_field form.user_pw layout="inline" %}
|
||||
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans "Your event can not be deleted as it already contains orders." %}
|
||||
</p>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
pretix does not allow deleting orders once they have been placed in order to be audit-proof and
|
||||
trustable by financial authorities.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% if request.event.live %}
|
||||
<p>
|
||||
{% trans "You can instead take your shop offline. This will hide it from everyone except from the organizer teams you configured to have access to the event." %}
|
||||
</p>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="live" value="false">
|
||||
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Go offline" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans "However, since your shop is offline, it is only visible to the organizing team according to the permissions you configured." %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,13 +1,12 @@
|
||||
{% load i18n %}
|
||||
<p>
|
||||
<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 %}>
|
||||
<select name="subevent" class="form-control simple-subevent-choice" data-model-select2="event"
|
||||
data-select2-url="{% url "control:event.subevents.select2" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
data-placeholder="{% trans "All dates" context "subevent" %}">
|
||||
{% for se in selected_subevents %}
|
||||
<option value="{{ se.pk }}" selected>
|
||||
{{ se.name }} – {{ se.get_date_range_display }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">{% trans "Show" %}</button>
|
||||
</p>
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="control" %}
|
||||
{% bootstrap_field form.slug layout="control" %}
|
||||
{% bootstrap_field form.date_from layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.date_to layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.date_from layout="control" %}
|
||||
{% bootstrap_field form.date_to layout="control" %}
|
||||
{% bootstrap_field form.location layout="control" %}
|
||||
{% bootstrap_field form.date_admission layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.date_admission layout="control" %}
|
||||
{% bootstrap_field form.currency layout="control" %}
|
||||
{% bootstrap_field form.is_public layout="control" %}
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Timeline" %}</legend>
|
||||
{% bootstrap_field form.presale_start layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.presale_start layout="control" %}
|
||||
{% bootstrap_field sform.presale_start_show_date layout="control" %}
|
||||
{% bootstrap_field form.presale_end layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.presale_end layout="control" %}
|
||||
{% bootstrap_field sform.show_items_outside_presale_period layout="control" %}
|
||||
{% bootstrap_field sform.last_order_modification_date layout="control" %}
|
||||
</fieldset>
|
||||
@@ -78,6 +78,10 @@
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
<a href="{% url "control:event.delete" organizer=request.organizer.slug event=request.event.slug %}"
|
||||
class="btn {% if request.event.allow_delete %}{% endif %} btn-danger btn-lg pull-left">
|
||||
{% trans "Delete event" %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% bootstrap_field form.date_from layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.date_to layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.date_from layout="control" %}
|
||||
{% bootstrap_field form.date_to layout="control" %}
|
||||
{% bootstrap_field form.location layout="control" %}
|
||||
{% bootstrap_field form.currency layout="control" %}
|
||||
{% bootstrap_field form.tax_rate addon_after="%" layout="control" %}
|
||||
@@ -43,8 +43,8 @@
|
||||
{% if form.presale_start %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Timeline" %}</legend>
|
||||
{% bootstrap_field form.presale_start layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.presale_end layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.presale_start layout="control" %}
|
||||
{% bootstrap_field form.presale_end layout="control" %}
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,4 +5,8 @@
|
||||
{% bootstrap_field form.organizer layout="horizontal" %}
|
||||
{% bootstrap_field form.locales layout="horizontal" %}
|
||||
{% bootstrap_field form.has_subevents layout="horizontal" %}
|
||||
<p>
|
||||
<span class="fa fa-info-circle"></span>
|
||||
{% trans "Please note that you will only be able to delete your event until the first order has been created." %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Availability" %}</legend>
|
||||
{% bootstrap_field form.available_from layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.available_until layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.available_from layout="control" %}
|
||||
{% bootstrap_field form.available_until layout="control" %}
|
||||
{% bootstrap_field form.max_per_order layout="control" %}
|
||||
{% bootstrap_field form.min_per_order layout="control" %}
|
||||
{% bootstrap_field form.require_voucher layout="control" %}
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<p>
|
||||
<select name="status" class="form-control">
|
||||
<option value="">{% trans "All orders" %}</option>
|
||||
<option value="" {% if request.GET.status == "" %}selected="selected"{% endif %}>{% trans "All orders" %}</option>
|
||||
<option value="p" {% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "Paid" %}</option>
|
||||
<option value="n" {% if request.GET.status == "n" %}selected="selected"{% endif %}>{% trans "Pending" %}</option>
|
||||
<option value="np" {% if request.GET.status == "np" or "status" not in request.GET %}selected="selected"{% endif %}>{% trans "Pending or paid" %}</option>
|
||||
<option value="o" {% if request.GET.status == "o" %}selected="selected"{% endif %}>{% trans "Pending (overdue)" %}</option>
|
||||
<option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option>
|
||||
<option value="ne" {% if request.GET.status == "ne" %}selected="selected"{% endif %}>{% trans "Pending or expired" %}</option>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
{% bootstrap_field form.question layout="control" %}
|
||||
{% bootstrap_field form.help_text layout="control" %}
|
||||
{% bootstrap_field form.type layout="control" %}
|
||||
{% bootstrap_field form.ask_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.required layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -42,7 +42,14 @@
|
||||
<td><strong><a href="
|
||||
{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">{{ q.question }}</a></strong>
|
||||
</td>
|
||||
<td>{{ q.get_type_display }}</td>
|
||||
<td>
|
||||
{{ q.get_type_display }}
|
||||
{% if q.required %}
|
||||
<span class="fa fa-exclamation-circle text-muted"
|
||||
data-toggle="tooltip" title="{% trans "Required question" %}">
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
{% for item in q.items.all %}
|
||||
|
||||
@@ -14,20 +14,7 @@
|
||||
</p>
|
||||
{% if request.event.has_subevents %}
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<p>
|
||||
{% 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 %}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
</p>
|
||||
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if quotas|length == 0 %}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}
|
||||
{% trans "Change contact information" %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% trans "Change order information" %}
|
||||
<a class="btn btn-link btn-lg"
|
||||
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% blocktrans trimmed with order=order.code %}
|
||||
Back to order {{ order }}
|
||||
{% endblocktrans %}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<form method="post" class="form-horizontal" href="">
|
||||
{% csrf_token %}
|
||||
<div class="panel-group" id="questions_accordion">
|
||||
{% if request.event.settings.invoice_address_asked %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#invoice" data-parent="#questions_accordion">
|
||||
<strong>{% trans "Invoice information" %} {% if not request.event.settings.invoice_address_required %}
|
||||
{% trans "(optional)" %}
|
||||
{% endif %}</strong>
|
||||
<i class="fa fa-angle-down collapse-indicator"></i>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="invoice" class="panel-collapse collapsed in">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_form invoice_form layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for pos, forms in formgroups %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#cp{{ pos.id }}">
|
||||
<strong>{{ pos.item.name }}{% if pos.variation %}
|
||||
– {{ pos.variation }}
|
||||
{% endif %}</strong>
|
||||
<i class="fa fa-angle-down collapse-indicator"></i>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="cp{{ pos.id }}"
|
||||
class="panel-collapse collapsed in">
|
||||
<div class="panel-body">
|
||||
{% for form in forms %}
|
||||
{% if form.pos.item != pos.item %}
|
||||
{# Add-Ons #}
|
||||
<legend>+ {{ form.pos.item }}</legend>
|
||||
{% endif %}
|
||||
{% bootstrap_form form layout="control" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<a class="btn btn-default btn-lg"
|
||||
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button class="btn btn-primary btn-save btn-lg" type="submit">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -29,7 +29,7 @@
|
||||
{% trans "Extend payment term" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if order.status == 'n' %}
|
||||
{% if order.cancel_allowed %}
|
||||
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=c" class="btn btn-default">
|
||||
{% trans "Cancel order" %}
|
||||
</a>
|
||||
@@ -163,6 +163,10 @@
|
||||
<div class="panel-heading">
|
||||
<div class="pull-right">
|
||||
{% if order.changable and 'can_change_orders' in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.info" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change answers" %}
|
||||
</a> ·
|
||||
<a href="{% url "control:event.order.change" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change products" %}
|
||||
@@ -213,7 +217,15 @@
|
||||
<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
{% endif %}
|
||||
{% for q in line.questions %}
|
||||
<dt>{{ q.question }}</dt>
|
||||
<dt>
|
||||
{{ q.question }}
|
||||
{% if q.ask_during_checkin %}
|
||||
<span class="fa fa-qrcode text-muted"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This question will be asked during check-in." %}"
|
||||
></span>
|
||||
{% endif %}
|
||||
</dt>
|
||||
<dd>
|
||||
{% if q.answer %}
|
||||
{% if q.answer.file %}
|
||||
@@ -360,6 +372,14 @@
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<div class="pull-right">
|
||||
{% if order.changable and 'can_change_orders' in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.info" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3 class="panel-title">
|
||||
{% trans "Invoice information" %}
|
||||
</h3>
|
||||
|
||||
@@ -44,17 +44,20 @@
|
||||
<div class="col-md-1 col-xs-6">
|
||||
{% bootstrap_field filter_form.item layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-1 col-xs-6">
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% bootstrap_field filter_form.subevent layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-1 col-xs-6">
|
||||
{% bootstrap_field filter_form.provider layout='inline' %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% bootstrap_field filter_form.item layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% bootstrap_field filter_form.provider layout='inline' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% bootstrap_field filter_form.provider layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% bootstrap_field filter_form.query layout='inline' %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{% load i18n %}
|
||||
{% load urlreplace %}
|
||||
<nav class="text-center pagination-container">
|
||||
<ul class="pagination">
|
||||
{% if is_paginated %}
|
||||
{% if page_obj.has_previous %}
|
||||
<li>
|
||||
<a href="?{% url_replace request 'page' page_obj.previous_page_number %}">
|
||||
<span>«</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="page-current"><a>
|
||||
{% blocktrans trimmed with page=page_obj.number of=page_obj.paginator.num_pages count=page_obj.paginator.count %}
|
||||
Page {{ page }}
|
||||
{% endblocktrans %}
|
||||
</a></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li>
|
||||
<a href="?{% url_replace request 'page' page_obj.next_page_number %}">
|
||||
<span>»</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if page_obj.paginator.count > 1 %}
|
||||
<li class="page-current"><a>
|
||||
{% blocktrans trimmed with count=page_obj.paginator.count %}
|
||||
{{ count }} elements
|
||||
{% endblocktrans %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if page_size %}
|
||||
<div class="clearfix">
|
||||
<small>
|
||||
{% trans "Show per page:" %}
|
||||
</small>
|
||||
<a href="?{% url_replace request "page_size" "25" "page" "1" %}">
|
||||
{% if page_size == 25 %}<strong>{% endif %}25{% if page_size == 25 %}</strong>{% endif %}</a> |
|
||||
<a href="?{% url_replace request "page_size" "50" "page" "1" %}">
|
||||
{% if page_size == 50 %}<strong>{% endif %}50{% if page_size == 50 %}</strong>{% endif %}</a> |
|
||||
<a href="?{% url_replace request "page_size" "100" "page" "1" %}">
|
||||
{% if page_size == 100 %}<strong>{% endif %}100{% if page_size == 100 %}</strong>{% endif %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -47,9 +47,6 @@
|
||||
<th class="text-right">{% trans "Order total" %}
|
||||
<a href="?{% url_replace request 'ordering' '-total' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'total' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th class="text-right">{% trans "Positions" %}
|
||||
<a href="?{% url_replace request 'ordering' '-pcnt' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'pcnt' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th class="text-right">{% trans "Status" %}
|
||||
<a href="?{% url_replace request 'ordering' '-status' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'status' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
@@ -74,7 +71,6 @@
|
||||
</td>
|
||||
<td>{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td class="text-right">{{ o.total|floatformat:2 }} {{ o.event.currency }}</td>
|
||||
<td class="text-right">{{ o.pcnt }}</td>
|
||||
<td class="text-right">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
@@ -87,5 +83,5 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% include "pretixcontrol/pagination_huge.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{% load i18n %}
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
||||
<optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
|
||||
{% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
|
||||
</optgroup>{% endif %}{% endfor %}
|
||||
</select>
|
||||
<noscript>
|
||||
<div class="alert alert-danger">
|
||||
{% trans "Please enable JavaScript in your browser." %}
|
||||
</div>
|
||||
</noscript>
|
||||
@@ -22,10 +22,10 @@
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="control" %}
|
||||
{% bootstrap_field form.active layout="control" %}
|
||||
{% bootstrap_field form.date_from layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.date_to layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.date_from layout="control" %}
|
||||
{% bootstrap_field form.date_to layout="control" %}
|
||||
{% bootstrap_field form.location layout="control" %}
|
||||
{% bootstrap_field form.date_admission layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.date_admission layout="control" %}
|
||||
{% bootstrap_field form.frontpage_text layout="control" %}
|
||||
{% if meta_forms %}
|
||||
<div class="form-group metadata-group">
|
||||
@@ -49,8 +49,8 @@
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Timeline" %}</legend>
|
||||
{% bootstrap_field form.presale_start layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.presale_end layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.presale_start layout="control" %}
|
||||
{% bootstrap_field form.presale_end layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Quotas" %}</legend>
|
||||
|
||||
29
src/pretix/control/templates/pretixcontrol/users/create.html
Normal file
29
src/pretix/control/templates/pretixcontrol/users/create.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Create user" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Create user" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Base settings" %}</legend>
|
||||
{% bootstrap_field form.is_active layout='control' %}
|
||||
{% bootstrap_field form.fullname layout='control' %}
|
||||
{% bootstrap_field form.locale layout='control' %}
|
||||
{% bootstrap_field form.timezone layout='control' %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Log-in settings" %}</legend>
|
||||
{% bootstrap_field form.email layout='control' %}
|
||||
{% bootstrap_field form.new_pw layout='control' %}
|
||||
{% bootstrap_field form.new_pw_repeat layout='control' %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
77
src/pretix/control/templates/pretixcontrol/users/form.html
Normal file
77
src/pretix/control/templates/pretixcontrol/users/form.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "User" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "User" %} {{ user.email }}</h1>
|
||||
<p>
|
||||
<form action="{% url "control:users.reset" id=user.pk %}" method="post" class="form-inline helper-display-inline">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default">{% trans "Send password reset email" %}</button>
|
||||
</form>
|
||||
<form action="{% url "control:users.impersonate" id=user.pk %}" method="post" class="form-inline helper-display-inline">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default">{% trans "Impersonate user" %}</button>
|
||||
</form>
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-xs-12">
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Base settings" %}</legend>
|
||||
{% bootstrap_field form.is_active layout='control' %}
|
||||
{% bootstrap_field form.fullname layout='control' %}
|
||||
{% bootstrap_field form.locale layout='control' %}
|
||||
{% bootstrap_field form.timezone layout='control' %}
|
||||
{% bootstrap_field form.is_superuser layout='control' %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Log-in settings" %}</legend>
|
||||
{% bootstrap_field form.email layout='control' %}
|
||||
{% bootstrap_field form.new_pw layout='control' %}
|
||||
{% bootstrap_field form.new_pw_repeat layout='control' %}
|
||||
{% bootstrap_field form.require_2fa layout='control' %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Team memberships" %}</legend>
|
||||
<ul>
|
||||
{% for t in teams %}
|
||||
<li>
|
||||
<a href="{% url "control:organizer.team" organizer=t.organizer.slug team=t.pk %}">
|
||||
{% blocktrans trimmed with team=t.name organizer=t.organizer.name %}
|
||||
Team "{{ team }}" of organizer "{{ organizer }}"
|
||||
{% endblocktrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-xs-12 col-lg-2">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "User history" %}
|
||||
</h3>
|
||||
</div>
|
||||
{% include "pretixcontrol/includes/logs.html" with obj=user %}
|
||||
<li class="list-group-item logentry">
|
||||
<p class="meta">
|
||||
<span class="fa fa-clock-o"></span> {{ user.date_joined|date:"SHORT_DATETIME_FORMAT" }}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "User created." %}
|
||||
</p>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
68
src/pretix/control/templates/pretixcontrol/users/index.html
Normal file
68
src/pretix/control/templates/pretixcontrol/users/index.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load urlreplace %}
|
||||
{% block title %}{% trans "Users" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Users" %}</h1>
|
||||
<form class="row filter-form" action="" method="get">
|
||||
<div class="col-md-4 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.query layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.status layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.superuser layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6 col-xs-12">
|
||||
<button class="btn btn-primary btn-block" type="submit">
|
||||
<span class="fa fa-filter"></span>
|
||||
<span class="hidden-md">
|
||||
{% trans "Filter" %}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p>
|
||||
<a href="{% url "control:users.add" %}" class="btn btn-default">
|
||||
<span class="fa fa-plus"></span>
|
||||
{% trans "Create a new user" %}
|
||||
</a>
|
||||
</p>
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{% trans "E-mail address" %}
|
||||
<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 "Full name" %}
|
||||
<a href="?{% url_replace request 'ordering' '-fullname' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'fullname' %}"><i class="fa fa-caret-up"></i></a>
|
||||
</th>
|
||||
<th>{% trans "Active" %}</th>
|
||||
<th>{% trans "Administrator" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td><strong>
|
||||
<a href="{% url "control:users.edit" id=u.pk %}">{{ u.email }}</a>
|
||||
</strong></td>
|
||||
<td>{{ u.fullname|default_if_none:"" }}</td>
|
||||
<td>{% if u.is_active %}<span class="fa fa-check-circle"></span>{% endif %}</td>
|
||||
<td>{% if u.is_superuser %}<span class="fa fa-check-circle"></span>{% endif %}</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:users.edit" id=u.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endblock %}
|
||||
@@ -38,7 +38,7 @@
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Voucher details" %}</legend>
|
||||
{% bootstrap_field form.valid_until layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.valid_until layout="control" %}
|
||||
{% bootstrap_field form.block_quota layout="control" %}
|
||||
{% bootstrap_field form.allow_ignore_quota layout="control" %}
|
||||
<div class="form-group">
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<legend>{% trans "Voucher details" %}</legend>
|
||||
{% bootstrap_field form.code layout="control" %}
|
||||
{% bootstrap_field form.max_usages layout="control" %}
|
||||
{% bootstrap_field form.valid_until layout="control" horizontal_field_class="col-md-9 splitdatetimerow" %}
|
||||
{% bootstrap_field form.valid_until layout="control" %}
|
||||
{% bootstrap_field form.block_quota layout="control" %}
|
||||
{% bootstrap_field form.allow_ignore_quota layout="control" %}
|
||||
<div class="form-group">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{% extends "pretixcontrol/vouchers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load urlreplace %}
|
||||
{% block title %}{% trans "Vouchers" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<p>
|
||||
@@ -8,34 +10,36 @@
|
||||
reserve some quota for your very special guests.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<p>
|
||||
<input type="text" name="search" class="form-control" placeholder="{% trans "Search voucher" %}"
|
||||
value="{{ request.GET.search }}" autofocus>
|
||||
<input type="text" name="tag" class="form-control" placeholder="{% trans "Filter by tag" %}"
|
||||
value="{{ request.GET.tag }}">
|
||||
<select name="status" class="form-control">
|
||||
<option value="">{% trans "All vouchers" %}</option>
|
||||
<option value="v" {% if request.GET.status == "v" %}selected="selected"{% endif %}>{% trans "Valid" %}</option>
|
||||
<option value="r" {% if request.GET.status == "r" %}selected="selected"{% endif %}>{% trans "Redeemed" %}</option>
|
||||
<option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option>
|
||||
<option value="c" {% if request.GET.status == "c" %}selected="selected"{% endif %}>{% trans "Redeemed and checked in with ticket" %}</option>
|
||||
</select>
|
||||
<div class="row filter-form">
|
||||
<form class="" action="" method="get">
|
||||
<div class="col-md-3 col-xs-6">
|
||||
{% bootstrap_field filter_form.search layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6">
|
||||
{% bootstrap_field filter_form.tag layout='inline' %}
|
||||
</div>
|
||||
{% 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>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% bootstrap_field filter_form.status layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% bootstrap_field filter_form.subevent layout='inline' %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-md-4 col-xs-6">
|
||||
{% bootstrap_field filter_form.status layout='inline' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
<button class="btn btn-default" type="submit" name="download" value="yes">{% trans "Download list" %}</button>
|
||||
</p>
|
||||
</form>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
<button class="btn btn-primary btn-block" type="submit">
|
||||
<span class="fa fa-filter"></span>
|
||||
<span class="hidden-md">
|
||||
{% trans "Filter" %}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% if vouchers|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
@@ -60,6 +64,9 @@
|
||||
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-plus"></i>
|
||||
{% trans "Create multiple new vouchers" %}</a>
|
||||
<a href="?{% url_replace request "download" "yes" %}"
|
||||
class="btn btn-default"><i class="fa fa-download"></i>
|
||||
{% trans "Download list" %}</a>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
|
||||
@@ -2,7 +2,8 @@ from django.conf.urls import include, url
|
||||
|
||||
from pretix.control.views import (
|
||||
auth, checkin, dashboards, event, global_settings, item, main, orders,
|
||||
organizer, search, subevents, typeahead, user, vouchers, waitinglist,
|
||||
organizer, search, subevents, typeahead, user, users, vouchers,
|
||||
waitinglist,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -17,6 +18,13 @@ urlpatterns = [
|
||||
url(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='global.settings'),
|
||||
url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'),
|
||||
url(r'^reauth/$', user.ReauthView.as_view(), name='user.reauth'),
|
||||
url(r'^users/$', users.UserListView.as_view(), name='users'),
|
||||
url(r'^users/select2$', typeahead.users_select2, name='users.select2'),
|
||||
url(r'^users/add$', users.UserCreateView.as_view(), name='users.add'),
|
||||
url(r'^users/impersonate/stop', users.UserImpersonateStopView.as_view(), name='users.impersonate.stop'),
|
||||
url(r'^users/(?P<id>\d+)/$', users.UserEditView.as_view(), name='users.edit'),
|
||||
url(r'^users/(?P<id>\d+)/reset$', users.UserResetView.as_view(), name='users.reset'),
|
||||
url(r'^users/(?P<id>\d+)/impersonate', users.UserImpersonateView.as_view(), name='users.impersonate'),
|
||||
url(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
|
||||
url(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
|
||||
url(r'^settings/notifications/$', user.UserNotificationsEditView.as_view(), name='user.settings.notifications'),
|
||||
@@ -36,6 +44,7 @@ urlpatterns = [
|
||||
name='user.settings.2fa.delete'),
|
||||
url(r'^organizers/$', organizer.OrganizerList.as_view(), name='organizers'),
|
||||
url(r'^organizers/add$', organizer.OrganizerCreate.as_view(), name='organizers.add'),
|
||||
url(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/$', organizer.OrganizerDetail.as_view(), name='organizer'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(),
|
||||
@@ -57,6 +66,7 @@ urlpatterns = [
|
||||
url(r'^$', dashboards.event_index, name='event.index'),
|
||||
url(r'^live/$', event.EventLive.as_view(), name='event.live'),
|
||||
url(r'^logs/$', event.EventLog.as_view(), name='event.log'),
|
||||
url(r'^delete/$', event.EventDelete.as_view(), name='event.delete'),
|
||||
url(r'^requiredactions/$', event.EventActions.as_view(), name='event.requiredactions'),
|
||||
url(r'^requiredactions/(?P<id>\d+)/discard$', event.EventActionDiscard.as_view(),
|
||||
name='event.requiredaction.discard'),
|
||||
@@ -80,6 +90,7 @@ urlpatterns = [
|
||||
url(r'^settings/tax/(?P<rule>\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'),
|
||||
url(r'^settings/widget$', event.WidgetSettings.as_view(), name='event.settings.widget'),
|
||||
url(r'^subevents/$', subevents.SubEventList.as_view(), name='event.subevents'),
|
||||
url(r'^subevents/select2$', typeahead.subevent_select2, name='event.subevents.select2'),
|
||||
url(r'^subevents/(?P<subevent>\d+)/$', subevents.SubEventUpdate.as_view(), name='event.subevent'),
|
||||
url(r'^subevents/(?P<subevent>\d+)/delete$', subevents.SubEventDelete.as_view(),
|
||||
name='event.subevent.delete'),
|
||||
@@ -153,6 +164,8 @@ urlpatterns = [
|
||||
name='event.order.comment'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/change$', orders.OrderChange.as_view(),
|
||||
name='event.order.change'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/info', orders.OrderModifyInformation.as_view(),
|
||||
name='event.order.info'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/sendmail$', orders.OrderSendMail.as_view(),
|
||||
name='event.order.sendmail'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/mail_history$', orders.OrderEmailHistory.as_view(),
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import collections
|
||||
import warnings
|
||||
|
||||
from django.core.paginator import (
|
||||
EmptyPage, PageNotAnInteger, UnorderedObjectListWarning,
|
||||
)
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import edit
|
||||
|
||||
|
||||
@@ -56,3 +63,122 @@ class PaginationMixin:
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_size'] = self.get_paginate_by(None)
|
||||
return ctx
|
||||
|
||||
|
||||
class LargeResultSetPage(collections.Sequence):
|
||||
|
||||
def __init__(self, object_list, number, paginator):
|
||||
self.object_list = object_list
|
||||
self.number = number
|
||||
self.paginator = paginator
|
||||
|
||||
def __repr__(self):
|
||||
return '<Page %s>' % self.number
|
||||
|
||||
def __len__(self):
|
||||
return len(self.object_list)
|
||||
|
||||
def __getitem__(self, index):
|
||||
if not isinstance(index, (slice, int)):
|
||||
raise TypeError
|
||||
# The object_list is converted to a list so that if it was a QuerySet
|
||||
# it won't be a database hit per __getitem__.
|
||||
if not isinstance(self.object_list, list):
|
||||
self.object_list = list(self.object_list)
|
||||
return self.object_list[index]
|
||||
|
||||
def has_next(self):
|
||||
try:
|
||||
return self[self.paginator.per_page - 1]
|
||||
except:
|
||||
return False
|
||||
|
||||
def has_previous(self):
|
||||
return self.number > 1
|
||||
|
||||
def has_other_pages(self):
|
||||
return self.has_previous() or self.has_next()
|
||||
|
||||
def next_page_number(self):
|
||||
return self.paginator.validate_number(self.number + 1)
|
||||
|
||||
def previous_page_number(self):
|
||||
return self.paginator.validate_number(self.number - 1)
|
||||
|
||||
def start_index(self):
|
||||
"""
|
||||
Returns the 1-based index of the first object on this page,
|
||||
relative to total objects in the paginator.
|
||||
"""
|
||||
# Special case, return zero if no items.
|
||||
if self.paginator.count == 0:
|
||||
return 0
|
||||
return (self.paginator.per_page * (self.number - 1)) + 1
|
||||
|
||||
def end_index(self):
|
||||
"""
|
||||
Returns the 1-based index of the last object on this page,
|
||||
relative to total objects found (hits).
|
||||
"""
|
||||
# Special case for the last page because there can be orphans.
|
||||
if self.number == self.paginator.num_pages:
|
||||
return self.paginator.count
|
||||
return self.number * self.paginator.per_page
|
||||
|
||||
|
||||
class LargeResultSetPaginator(object):
|
||||
|
||||
def __init__(self, object_list, per_page, orphans=0,
|
||||
allow_empty_first_page=True):
|
||||
self.object_list = object_list
|
||||
self._check_object_list_is_ordered()
|
||||
self.per_page = int(per_page)
|
||||
self.orphans = int(orphans)
|
||||
|
||||
def validate_number(self, number):
|
||||
"""
|
||||
Validates the given 1-based page number.
|
||||
"""
|
||||
try:
|
||||
number = int(number)
|
||||
except (TypeError, ValueError):
|
||||
raise PageNotAnInteger(_('That page number is not an integer'))
|
||||
if number < 1:
|
||||
raise EmptyPage(_('That page number is less than 1'))
|
||||
return number
|
||||
|
||||
def page(self, number):
|
||||
"""
|
||||
Returns a Page object for the given 1-based page number.
|
||||
"""
|
||||
number = self.validate_number(number)
|
||||
bottom = (number - 1) * self.per_page
|
||||
top = bottom + self.per_page
|
||||
return self._get_page(self.object_list[bottom:top], number, self)
|
||||
|
||||
def _get_page(self, *args, **kwargs):
|
||||
"""
|
||||
Returns an instance of a single page.
|
||||
|
||||
This hook can be used by subclasses to use an alternative to the
|
||||
standard :cls:`Page` object.
|
||||
"""
|
||||
return LargeResultSetPage(*args, **kwargs)
|
||||
|
||||
def _check_object_list_is_ordered(self):
|
||||
"""
|
||||
Warn if self.object_list is unordered (typically a QuerySet).
|
||||
"""
|
||||
ordered = getattr(self.object_list, 'ordered', None)
|
||||
if ordered is not None and not ordered:
|
||||
obj_list_repr = (
|
||||
'{} {}'.format(self.object_list.model, self.object_list.__class__.__name__)
|
||||
if hasattr(self.object_list, 'model')
|
||||
else '{!r}'.format(self.object_list)
|
||||
)
|
||||
warnings.warn(
|
||||
'Pagination may yield inconsistent results with an unordered '
|
||||
'object_list: {}.'.format(obj_list_repr),
|
||||
UnorderedObjectListWarning,
|
||||
stacklevel=3
|
||||
)
|
||||
|
||||
@@ -25,8 +25,7 @@ from pretix.base.forms.auth import (
|
||||
LoginForm, PasswordForgotForm, PasswordRecoverForm, RegistrationForm,
|
||||
)
|
||||
from pretix.base.models import TeamInvite, U2FDevice, User
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
from pretix.base.services.mail import SendMailException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -200,15 +199,7 @@ class Forgot(TemplateView):
|
||||
rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
|
||||
|
||||
try:
|
||||
mail(
|
||||
user.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
|
||||
{
|
||||
'user': user,
|
||||
'url': (build_absolute_uri('control:auth.forgot.recover')
|
||||
+ '?id=%d&token=%s' % (user.id, default_token_generator.make_token(user)))
|
||||
},
|
||||
None, locale=user.locale
|
||||
)
|
||||
user.send_password_reset()
|
||||
except SendMailException:
|
||||
messages.error(request, _('There was an error sending the mail. Please try again later.'))
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
@@ -17,7 +17,7 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _, ungettext
|
||||
|
||||
from pretix.base.models import (
|
||||
Event, Item, Order, OrderPosition, RequiredAction, SubEvent, Voucher,
|
||||
Item, Order, OrderPosition, RequiredAction, SubEvent, Voucher,
|
||||
WaitingListEntry,
|
||||
)
|
||||
from pretix.base.models.checkin import CheckinList
|
||||
@@ -280,11 +280,43 @@ def event_index(request, organizer, event):
|
||||
return render(request, 'pretixcontrol/event/index.html', ctx)
|
||||
|
||||
|
||||
@receiver(signal=user_dashboard_widgets)
|
||||
def user_event_widgets(**kwargs):
|
||||
user = kwargs.pop('user')
|
||||
def annotated_event_query(user):
|
||||
active_orders = Order.objects.filter(
|
||||
event=OuterRef('pk'),
|
||||
status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
|
||||
).order_by().values('event').annotate(
|
||||
c=Count('*')
|
||||
).values(
|
||||
'c'
|
||||
)
|
||||
|
||||
required_actions = RequiredAction.objects.filter(
|
||||
event=OuterRef('pk'),
|
||||
done=False
|
||||
)
|
||||
qs = user.get_events_with_any_permission().annotate(
|
||||
order_count=Subquery(active_orders, output_field=IntegerField()),
|
||||
has_ra=Exists(required_actions)
|
||||
).annotate(
|
||||
min_from=Min('subevents__date_from'),
|
||||
max_from=Max('subevents__date_from'),
|
||||
max_to=Max('subevents__date_to'),
|
||||
max_fromto=Greatest(Max('subevents__date_to'), Max('subevents__date_from')),
|
||||
).annotate(
|
||||
order_to=Coalesce('max_fromto', 'max_to', 'max_from', 'date_to', 'date_from'),
|
||||
)
|
||||
return qs
|
||||
|
||||
|
||||
def widgets_for_event_qs(qs, user, nmax):
|
||||
widgets = []
|
||||
|
||||
# Get set of events where we have the permission to show the # of orders
|
||||
events_with_orders = set(qs.filter(
|
||||
Q(organizer_id__in=user.teams.filter(all_events=True, can_view_orders=True).values_list('organizer', flat=True))
|
||||
| Q(id__in=user.teams.filter(can_view_orders=True).values_list('limit_events__id', flat=True))
|
||||
).values_list('id', flat=True))
|
||||
|
||||
tpl = """
|
||||
<a href="{url}" class="event">
|
||||
<div class="name">{event}</div>
|
||||
@@ -299,50 +331,21 @@ def user_event_widgets(**kwargs):
|
||||
</div>
|
||||
"""
|
||||
|
||||
active_orders = Order.objects.filter(
|
||||
event=OuterRef('pk'),
|
||||
status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
|
||||
).order_by().values('event').annotate(
|
||||
c=Count('*')
|
||||
).values(
|
||||
'c'
|
||||
)
|
||||
|
||||
required_actions = RequiredAction.objects.filter(
|
||||
event=OuterRef('pk'),
|
||||
done=False
|
||||
)
|
||||
|
||||
# Get set of events where we have the permission to show the # of orders
|
||||
events_with_orders = set(Event.objects.filter(
|
||||
Q(organizer_id__in=user.teams.filter(all_events=True, can_view_orders=True).values_list('organizer', flat=True))
|
||||
| Q(id__in=user.teams.filter(can_view_orders=True).values_list('limit_events__id', flat=True))
|
||||
).values_list('id', flat=True))
|
||||
|
||||
events = user.get_events_with_any_permission().annotate(
|
||||
order_count=Subquery(active_orders, output_field=IntegerField()),
|
||||
has_ra=Exists(required_actions)
|
||||
).annotate(
|
||||
min_from=Min('subevents__date_from'),
|
||||
max_from=Max('subevents__date_from'),
|
||||
max_to=Max('subevents__date_to'),
|
||||
max_fromto=Greatest(Max('subevents__date_to'), Max('subevents__date_from'))
|
||||
).annotate(
|
||||
order_from=Coalesce('min_from', 'date_from'),
|
||||
order_to=Coalesce('max_fromto', 'max_to', 'max_from', 'date_to'),
|
||||
).order_by(
|
||||
'-order_from', 'name'
|
||||
).prefetch_related(
|
||||
events = qs.prefetch_related(
|
||||
'_settings_objects', 'organizer___settings_objects'
|
||||
).select_related('organizer')[:100]
|
||||
).select_related('organizer')[:nmax]
|
||||
for event in events:
|
||||
dr = event.get_date_range_display()
|
||||
tz = pytz.timezone(event.settings.timezone)
|
||||
tz = pytz.timezone(event.cache.get_or_set('timezone', lambda: event.settings.timezone))
|
||||
if event.has_subevents:
|
||||
dr = daterange(
|
||||
(event.min_from).astimezone(tz),
|
||||
(event.max_fromto or event.max_to or event.max_from).astimezone(tz)
|
||||
)
|
||||
else:
|
||||
if event.date_to:
|
||||
dr = daterange(event.date_from.astimezone(tz), event.date_to.astimezone(tz))
|
||||
else:
|
||||
dr = date_format(event.date_from.astimezone(tz), "DATE_FORMAT")
|
||||
|
||||
if event.has_ra:
|
||||
status = ('danger', _('Action required'))
|
||||
@@ -400,26 +403,42 @@ def user_event_widgets(**kwargs):
|
||||
return widgets
|
||||
|
||||
|
||||
@receiver(signal=user_dashboard_widgets)
|
||||
def new_event_widgets(**kwargs):
|
||||
return [
|
||||
{
|
||||
'content': '<div class="newevent"><span class="fa fa-plus-circle"></span>{t}</div>'.format(
|
||||
t=_('Create a new event')
|
||||
),
|
||||
'display_size': 'small',
|
||||
'priority': 50,
|
||||
'url': reverse('control:events.add')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def user_index(request):
|
||||
widgets = []
|
||||
for r, result in user_dashboard_widgets.send(request, user=request.user):
|
||||
widgets.extend(result)
|
||||
|
||||
ctx = {
|
||||
'widgets': rearrange(widgets),
|
||||
'upcoming': widgets_for_event_qs(
|
||||
annotated_event_query(request.user).filter(
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
|
||||
)
|
||||
).order_by('date_from'),
|
||||
request.user,
|
||||
7
|
||||
),
|
||||
'past': widgets_for_event_qs(
|
||||
annotated_event_query(request.user).filter(
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
|
||||
)
|
||||
).order_by('-order_to'),
|
||||
request.user,
|
||||
8
|
||||
),
|
||||
'series': widgets_for_event_qs(
|
||||
annotated_event_query(request.user).filter(
|
||||
has_subevents=True
|
||||
).order_by('-order_to'),
|
||||
request.user,
|
||||
8
|
||||
),
|
||||
}
|
||||
return render(request, 'pretixcontrol/dashboard.html', ctx)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files import File
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import transaction
|
||||
from django.db.models import ProtectedError
|
||||
from django.http import (
|
||||
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
|
||||
JsonResponse,
|
||||
@@ -34,8 +35,8 @@ from pretix.base.services import tickets
|
||||
from pretix.base.services.invoices import build_preview_invoice_pdf
|
||||
from pretix.base.signals import event_live_issues, register_ticket_outputs
|
||||
from pretix.control.forms.event import (
|
||||
CommentForm, DisplaySettingsForm, EventMetaValueForm, EventSettingsForm,
|
||||
EventUpdateForm, InvoiceSettingsForm, MailSettingsForm,
|
||||
CommentForm, DisplaySettingsForm, EventDeleteForm, EventMetaValueForm,
|
||||
EventSettingsForm, EventUpdateForm, InvoiceSettingsForm, MailSettingsForm,
|
||||
PaymentSettingsForm, ProviderForm, TaxRuleForm, TicketSettingsForm,
|
||||
WidgetCodeForm,
|
||||
)
|
||||
@@ -786,6 +787,48 @@ class EventLive(EventPermissionRequiredMixin, TemplateView):
|
||||
})
|
||||
|
||||
|
||||
class EventDelete(EventPermissionRequiredMixin, FormView):
|
||||
permission = 'can_change_event_settings'
|
||||
template_name = 'pretixcontrol/event/delete.html'
|
||||
form_class = EventDeleteForm
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not self.request.event.allow_delete():
|
||||
messages.error(self.request, _('This event can not be deleted.'))
|
||||
return self.get(self.request, *self.args, **self.kwargs)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['user'] = self.request.user
|
||||
kwargs['event'] = self.request.event
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
self.request.organizer.log_action(
|
||||
'pretix.event.deleted', user=self.request.user,
|
||||
data={
|
||||
'event_id': self.request.event.pk,
|
||||
'name': str(self.request.event.name),
|
||||
'logentries': list(self.request.event.logentry_set.values_list('pk', flat=True))
|
||||
}
|
||||
)
|
||||
self.request.event.items.all().delete()
|
||||
self.request.event.subevents.all().delete()
|
||||
self.request.event.delete()
|
||||
messages.success(self.request, _('The event has been deleted.'))
|
||||
return redirect(self.get_success_url())
|
||||
except ProtectedError:
|
||||
messages.error(self.request, _('The event could not be deleted as some constraints (e.g. data created by '
|
||||
'plug-ins) do not allow it.'))
|
||||
return self.get(self.request, *self.args, **self.kwargs)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('control:index')
|
||||
|
||||
|
||||
class EventLog(EventPermissionRequiredMixin, ListView):
|
||||
template_name = 'pretixcontrol/event/logs.html'
|
||||
model = LogEntry
|
||||
|
||||
@@ -402,11 +402,13 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
|
||||
question=self.object, orderposition__isnull=False,
|
||||
orderposition__order__event=self.request.event
|
||||
)
|
||||
if self.request.GET.get("status", "") != "":
|
||||
s = self.request.GET.get("status", "")
|
||||
if self.request.GET.get("status", "np") != "":
|
||||
s = self.request.GET.get("status", "np")
|
||||
if s == 'o':
|
||||
qs = qs.filter(orderposition__order__status=Order.STATUS_PENDING,
|
||||
expires__lt=now().replace(hour=0, minute=0, second=0))
|
||||
orderposition__order__expires__lt=now().replace(hour=0, minute=0, second=0))
|
||||
elif s == 'np':
|
||||
qs = qs.filter(orderposition__order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
|
||||
elif s == 'ne':
|
||||
qs = qs.filter(orderposition__order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
|
||||
else:
|
||||
@@ -428,6 +430,12 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
|
||||
for a in qs:
|
||||
a['answer'] = str(a['options__answer'])
|
||||
del a['options__answer']
|
||||
elif self.object.type in (Question.TYPE_TIME, Question.TYPE_DATE, Question.TYPE_DATETIME):
|
||||
qs = qs.order_by('answer')
|
||||
qs_model = qs
|
||||
qs = qs.values('answer').annotate(count=Count('id')).order_by('-count')
|
||||
for a, a_model in zip(qs, qs_model):
|
||||
a['answer'] = str(a_model)
|
||||
else:
|
||||
qs = qs.order_by('answer').values('answer').annotate(count=Count('id')).order_by('-count')
|
||||
|
||||
@@ -1023,7 +1031,8 @@ class ItemDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
@transaction.atomic
|
||||
def delete(self, request, *args, **kwargs):
|
||||
success_url = self.get_success_url()
|
||||
if self.is_allowed():
|
||||
o = self.get_object()
|
||||
if o.allow_delete():
|
||||
self.get_object().cartposition_set.all().delete()
|
||||
self.get_object().log_action('pretix.event.item.deleted', user=self.request.user)
|
||||
self.get_object().delete()
|
||||
|
||||
@@ -42,7 +42,7 @@ class EventList(PaginationMixin, ListView):
|
||||
max_fromto=Greatest(Max('subevents__date_to'), Max('subevents__date_from'))
|
||||
).annotate(
|
||||
order_from=Coalesce('min_from', 'date_from'),
|
||||
order_to=Coalesce('max_fromto', 'max_to', 'max_from', 'date_to'),
|
||||
order_to=Coalesce('max_fromto', 'max_to', 'max_from', 'date_to', 'date_from'),
|
||||
)
|
||||
|
||||
sum_tickets_paid = Quota.objects.filter(
|
||||
|
||||
@@ -38,11 +38,12 @@ from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.base.services.mail import SendMailException, render_mail
|
||||
from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, cancel_order, extend_order,
|
||||
mark_order_paid,
|
||||
mark_order_expired, mark_order_paid,
|
||||
)
|
||||
from pretix.base.services.stats import order_overview
|
||||
from pretix.base.signals import register_data_exporters
|
||||
from pretix.base.views.async import AsyncAction
|
||||
from pretix.base.views.mixins import OrderQuestionsViewMixin
|
||||
from pretix.control.forms.filter import EventOrderFilterForm
|
||||
from pretix.control.forms.orders import (
|
||||
CommentForm, ExporterForm, ExtendForm, OrderContactForm, OrderLocaleForm,
|
||||
@@ -219,7 +220,7 @@ class OrderTransition(OrderView):
|
||||
messages.warning(self.request, _('The order has been marked as paid, but we were unable to send a confirmation mail.'))
|
||||
else:
|
||||
messages.success(self.request, _('The order has been marked as paid.'))
|
||||
elif self.order.status == Order.STATUS_PENDING and to == 'c':
|
||||
elif self.order.cancel_allowed() and to == 'c':
|
||||
cancel_order(self.order, user=self.request.user, send_mail=self.request.POST.get("send_email") == "on")
|
||||
messages.success(self.request, _('The order has been canceled.'))
|
||||
elif self.order.status == Order.STATUS_PAID and to == 'n':
|
||||
@@ -229,9 +230,7 @@ class OrderTransition(OrderView):
|
||||
self.order.log_action('pretix.event.order.unpaid', user=self.request.user)
|
||||
messages.success(self.request, _('The order has been marked as not paid.'))
|
||||
elif self.order.status == Order.STATUS_PENDING and to == 'e':
|
||||
self.order.status = Order.STATUS_EXPIRED
|
||||
self.order.save()
|
||||
self.order.log_action('pretix.event.order.expired', user=self.request.user)
|
||||
mark_order_expired(self.order, user=self.request.user)
|
||||
messages.success(self.request, _('The order has been marked as expired.'))
|
||||
elif self.order.status == Order.STATUS_PAID and to == 'r':
|
||||
ret = self.payment_provider.order_control_refund_perform(self.request, self.order)
|
||||
@@ -241,7 +240,7 @@ class OrderTransition(OrderView):
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
to = self.request.GET.get('status', '')
|
||||
if self.order.status == Order.STATUS_PENDING and to == 'c':
|
||||
if self.order.cancel_allowed() and to == 'c':
|
||||
return render(self.request, 'pretixcontrol/order/cancel.html', {
|
||||
'order': self.order,
|
||||
})
|
||||
@@ -627,6 +626,28 @@ class OrderChange(OrderView):
|
||||
return self.get(*args, **kwargs)
|
||||
|
||||
|
||||
class OrderModifyInformation(OrderQuestionsViewMixin, OrderView):
|
||||
permission = 'can_change_orders'
|
||||
template_name = 'pretixcontrol/order/change_questions.html'
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
failed = not self.save() or not self.invoice_form.is_valid()
|
||||
if failed:
|
||||
messages.error(self.request,
|
||||
_("We had difficulties processing your input. Please review the errors below."))
|
||||
return self.get(request, *args, **kwargs)
|
||||
self.invoice_form.save()
|
||||
self.order.log_action('pretix.event.order.modified', user=request.user)
|
||||
if self.invoice_form.has_changed():
|
||||
success_message = ('The invoice address has been updated. If you want to generate a new invoice, '
|
||||
'you need to do this manually.')
|
||||
messages.success(self.request, _(success_message))
|
||||
|
||||
CachedTicket.objects.filter(order_position__order=self.order).delete()
|
||||
CachedCombinedTicket.objects.filter(order=self.order).delete()
|
||||
return redirect(self.get_order_url())
|
||||
|
||||
|
||||
class OrderContactChange(OrderView):
|
||||
permission = 'can_change_orders'
|
||||
template_name = 'pretixcontrol/order/change_contact.html'
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Q
|
||||
from django.utils.functional import cached_property
|
||||
from django.views.generic import ListView
|
||||
|
||||
from pretix.base.models import Order
|
||||
from pretix.control.forms.filter import OrderSearchFilterForm
|
||||
from pretix.control.views import PaginationMixin
|
||||
from pretix.control.views import LargeResultSetPaginator, PaginationMixin
|
||||
|
||||
|
||||
class OrderSearch(PaginationMixin, ListView):
|
||||
model = Order
|
||||
paginator_class = LargeResultSetPaginator
|
||||
context_object_name = 'orders'
|
||||
template_name = 'pretixcontrol/search/orders.html'
|
||||
|
||||
@@ -22,7 +23,7 @@ class OrderSearch(PaginationMixin, ListView):
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Order.objects.all().annotate(pcnt=Count('positions', distinct=True)).select_related('invoice_address')
|
||||
qs = Order.objects.select_related('invoice_address')
|
||||
if not self.request.user.is_superuser:
|
||||
qs = qs.filter(
|
||||
Q(event__organizer_id__in=self.request.user.teams.filter(
|
||||
@@ -34,4 +35,8 @@ class OrderSearch(PaginationMixin, ListView):
|
||||
if self.filter_form.is_valid():
|
||||
qs = self.filter_form.filter_qs(qs)
|
||||
|
||||
return qs.distinct().prefetch_related('event', 'event__organizer')
|
||||
return qs.only(
|
||||
'id', 'invoice_address__name', 'code', 'event', 'email', 'datetime', 'total', 'status'
|
||||
).prefetch_related(
|
||||
'event', 'event__organizer'
|
||||
)
|
||||
|
||||
@@ -101,10 +101,13 @@ class SubEventDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
self.object = self.get_object()
|
||||
success_url = self.get_success_url()
|
||||
|
||||
if self.get_object().orderposition_set.count() > 0:
|
||||
if self.object.orderposition_set.count() > 0:
|
||||
messages.error(request, pgettext_lazy('subevent', 'A date can not be deleted if orders already have been '
|
||||
'placed.'))
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
elif not self.object.allow_delete(): # checking if this is the last date in the event series
|
||||
messages.error(request, pgettext_lazy('subevent', 'The last date of an event series can not be deleted.'))
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
else:
|
||||
self.object.log_action('pretix.subevent.deleted', user=self.request.user)
|
||||
self.object.delete()
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import pytz
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Max, Min, Q
|
||||
from django.db.models.functions import Coalesce, Greatest
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from pretix.control.utils.i18n import i18ncomp
|
||||
from pretix.base.models import Organizer, User
|
||||
from pretix.control.permissions import event_permission_required
|
||||
from pretix.helpers.daterange import daterange
|
||||
from pretix.helpers.i18n import i18ncomp
|
||||
|
||||
|
||||
def event_list(request):
|
||||
query = request.GET.get('query', '')
|
||||
try:
|
||||
page = int(request.GET.get('page', '1'))
|
||||
except ValueError:
|
||||
page = 1
|
||||
qs = request.user.get_events_with_any_permission().filter(
|
||||
Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) |
|
||||
Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query)
|
||||
@@ -33,9 +40,11 @@ def event_list(request):
|
||||
(e.max_fromto or e.max_to or e.max_from).astimezone(tz)
|
||||
)
|
||||
return {
|
||||
'id': e.pk,
|
||||
'slug': e.slug,
|
||||
'organizer': str(e.organizer.name),
|
||||
'name': str(e.name),
|
||||
'text': str(e.name),
|
||||
'date_range': dr,
|
||||
'url': reverse('control:event.index', kwargs={
|
||||
'event': e.slug,
|
||||
@@ -43,9 +52,110 @@ def event_list(request):
|
||||
})
|
||||
}
|
||||
|
||||
total = qs.count()
|
||||
pagesize = 20
|
||||
offset = (page - 1) * pagesize
|
||||
doc = {
|
||||
'results': [
|
||||
serialize(e) for e in qs.select_related('organizer')[:10]
|
||||
]
|
||||
serialize(e) for e in qs.select_related('organizer')[offset:offset + pagesize]
|
||||
],
|
||||
'pagination': {
|
||||
"more": total >= (offset + pagesize)
|
||||
}
|
||||
}
|
||||
return JsonResponse(doc)
|
||||
|
||||
|
||||
@event_permission_required(None)
|
||||
def subevent_select2(request, **kwargs):
|
||||
query = request.GET.get('query', '')
|
||||
try:
|
||||
page = int(request.GET.get('page', '1'))
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
qs = request.event.subevents.filter(
|
||||
Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query)
|
||||
).order_by('-date_from')
|
||||
|
||||
total = qs.count()
|
||||
pagesize = 20
|
||||
offset = (page - 1) * pagesize
|
||||
doc = {
|
||||
'results': [
|
||||
{
|
||||
'id': e.pk,
|
||||
'name': str(e.name),
|
||||
'date_range': e.get_date_range_display(),
|
||||
'text': '{} – {}'.format(e.name, e.get_date_range_display()),
|
||||
}
|
||||
for e in qs[offset:offset + pagesize]
|
||||
],
|
||||
'pagination': {
|
||||
"more": total >= (offset + pagesize)
|
||||
}
|
||||
}
|
||||
return JsonResponse(doc)
|
||||
|
||||
|
||||
def organizer_select2(request):
|
||||
term = request.GET.get('query', '')
|
||||
try:
|
||||
page = int(request.GET.get('page', '1'))
|
||||
except ValueError:
|
||||
page = 1
|
||||
qs = Organizer.objects.all()
|
||||
if term:
|
||||
qs = qs.filter(Q(name__icontains=term) | Q(slug__icontains=term))
|
||||
if not request.user.is_superuser:
|
||||
qs = qs.filter(pk__in=request.user.teams.values_list('organizer', flat=True))
|
||||
|
||||
total = qs.count()
|
||||
pagesize = 20
|
||||
offset = (page - 1) * pagesize
|
||||
|
||||
doc = {
|
||||
"results": [
|
||||
{
|
||||
'id': o.pk,
|
||||
'text': str(o.name)
|
||||
} for o in qs[offset:offset + pagesize]
|
||||
],
|
||||
"pagination": {
|
||||
"more": total >= (offset + pagesize)
|
||||
}
|
||||
}
|
||||
|
||||
return JsonResponse(doc)
|
||||
|
||||
|
||||
def users_select2(request):
|
||||
if not request.user.is_superuser:
|
||||
raise PermissionDenied()
|
||||
|
||||
term = request.GET.get('query', '')
|
||||
try:
|
||||
page = int(request.GET.get('page', '1'))
|
||||
except ValueError:
|
||||
page = 1
|
||||
qs = User.objects.all()
|
||||
if term:
|
||||
qs = qs.filter(Q(email__icontains=term) | Q(fullname__icontains=term))
|
||||
|
||||
total = qs.count()
|
||||
pagesize = 20
|
||||
offset = (page - 1) * pagesize
|
||||
|
||||
doc = {
|
||||
"results": [
|
||||
{
|
||||
'id': o.pk,
|
||||
'text': str(o.email)
|
||||
} for o in qs[offset:offset + pagesize]
|
||||
],
|
||||
"pagination": {
|
||||
"more": total >= (offset + pagesize)
|
||||
}
|
||||
}
|
||||
|
||||
return JsonResponse(doc)
|
||||
|
||||
154
src/pretix/control/views/users.py
Normal file
154
src/pretix/control/views/users.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import ListView
|
||||
from hijack.helpers import login_user, release_hijack
|
||||
|
||||
from pretix.base.models import User
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.control.forms.filter import UserFilterForm
|
||||
from pretix.control.forms.users import UserEditForm
|
||||
from pretix.control.permissions import AdministratorPermissionRequiredMixin
|
||||
from pretix.control.views import CreateView, UpdateView
|
||||
from pretix.control.views.user import RecentAuthenticationRequiredMixin
|
||||
|
||||
|
||||
class UserListView(AdministratorPermissionRequiredMixin, ListView):
|
||||
template_name = 'pretixcontrol/users/index.html'
|
||||
context_object_name = 'users'
|
||||
paginate_by = 30
|
||||
|
||||
def get_queryset(self):
|
||||
qs = User.objects.all()
|
||||
if self.filter_form.is_valid():
|
||||
qs = self.filter_form.filter_qs(qs)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['filter_form'] = self.filter_form
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
def filter_form(self):
|
||||
return UserFilterForm(data=self.request.GET)
|
||||
|
||||
|
||||
class UserEditView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, UpdateView):
|
||||
template_name = 'pretixcontrol/users/form.html'
|
||||
context_object_name = 'user'
|
||||
form_class = UserEditForm
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return get_object_or_404(User, pk=self.kwargs.get("id"))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['teams'] = self.object.teams.select_related('organizer')
|
||||
return ctx
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:users.edit', kwargs=self.kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
|
||||
data = {}
|
||||
for k in form.changed_data:
|
||||
if k != 'new_pw_repeat':
|
||||
if 'new_pw' == k:
|
||||
data['new_pw'] = True
|
||||
else:
|
||||
data[k] = form.cleaned_data[k]
|
||||
|
||||
sup = super().form_valid(form)
|
||||
|
||||
if 'require_2fa' in form.changed_data and form.cleaned_data['require_2fa']:
|
||||
self.object.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
|
||||
elif 'require_2fa' in form.changed_data and not form.cleaned_data['require_2fa']:
|
||||
self.object.log_action('pretix.user.settings.2fa.disabled', user=self.request.user)
|
||||
self.object.log_action('pretix.user.settings.changed', user=self.request.user, data=data)
|
||||
|
||||
return sup
|
||||
|
||||
|
||||
class UserResetView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, View):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return redirect(reverse('control:users.edit', kwargs=self.kwargs))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
|
||||
try:
|
||||
self.object.send_password_reset()
|
||||
except SendMailException:
|
||||
messages.error(request, _('There was an error sending the mail. Please try again later.'))
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
self.object.log_action('pretix.control.auth.user.forgot_password.mail_sent',
|
||||
user=request.user)
|
||||
messages.success(request, _('We sent out an e-mail containing further instructions.'))
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:users.edit', kwargs=self.kwargs)
|
||||
|
||||
|
||||
class UserImpersonateView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, View):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return redirect(reverse('control:users.edit', kwargs=self.kwargs))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
|
||||
self.request.user.log_action('pretix.control.auth.user.impersonated',
|
||||
user=request.user,
|
||||
data={
|
||||
'other': self.kwargs.get("id"),
|
||||
'other_email': self.object.email
|
||||
})
|
||||
login_user(request, self.object)
|
||||
return redirect(reverse('control:index'))
|
||||
|
||||
|
||||
class UserImpersonateStopView(LoginRequiredMixin, View):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
impersonated = request.user
|
||||
release_hijack(request)
|
||||
request.user.log_action('pretix.control.auth.user.impersonate_stopped',
|
||||
user=request.user,
|
||||
data={
|
||||
'other': impersonated.pk,
|
||||
'other_email': impersonated.email
|
||||
})
|
||||
return redirect(reverse('control:index'))
|
||||
|
||||
|
||||
class UserCreateView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, CreateView):
|
||||
template_name = 'pretixcontrol/users/create.html'
|
||||
context_object_name = 'user'
|
||||
form_class = UserEditForm
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
f = super().get_form(form_class)
|
||||
f.fields['new_pw'].required = True
|
||||
f.fields['new_pw_repeat'].required = True
|
||||
return f
|
||||
|
||||
def get_initial(self):
|
||||
i = super().get_initial()
|
||||
i['timezone'] = settings.TIME_ZONE
|
||||
return i
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:users')
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, _('The new user has been created.'))
|
||||
return super().form_valid(form)
|
||||
@@ -5,19 +5,20 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.urlresolvers import resolve, reverse
|
||||
from django.db import transaction
|
||||
from django.db.models import Exists, OuterRef, Q, Sum
|
||||
from django.db.models import Sum
|
||||
from django.http import (
|
||||
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
|
||||
JsonResponse,
|
||||
)
|
||||
from django.utils.timezone import now
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import (
|
||||
CreateView, DeleteView, ListView, TemplateView, UpdateView, View,
|
||||
)
|
||||
|
||||
from pretix.base.models import Checkin, Voucher
|
||||
from pretix.base.models import Voucher
|
||||
from pretix.base.models.vouchers import _generate_random_code
|
||||
from pretix.control.forms.filter import VoucherFilterForm
|
||||
from pretix.control.forms.vouchers import VoucherBulkForm, VoucherForm
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.control.signals import voucher_form_class
|
||||
@@ -32,31 +33,19 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.event.vouchers.all().select_related('item', 'variation')
|
||||
if self.request.GET.get("search", "") != "":
|
||||
s = self.request.GET.get("search", "").strip()
|
||||
qs = qs.filter(Q(code__icontains=s) | Q(tag__icontains=s) | Q(comment__icontains=s))
|
||||
if self.request.GET.get("tag", "") != "":
|
||||
s = self.request.GET.get("tag", "")
|
||||
qs = qs.filter(tag__icontains=s)
|
||||
if self.request.GET.get("status", "") != "":
|
||||
s = self.request.GET.get("status", "")
|
||||
if s == 'v':
|
||||
qs = qs.filter(Q(valid_until__isnull=True) | Q(valid_until__gt=now())).filter(redeemed=0)
|
||||
elif s == 'r':
|
||||
qs = qs.filter(redeemed__gt=0)
|
||||
elif s == 'e':
|
||||
qs = qs.filter(Q(valid_until__isnull=False) & Q(valid_until__lt=now())).filter(redeemed=0)
|
||||
elif s == 'c':
|
||||
checkins = Checkin.objects.filter(
|
||||
position__voucher=OuterRef('pk')
|
||||
)
|
||||
qs = qs.annotate(has_checkin=Exists(checkins)).filter(
|
||||
redeemed__gt=0, has_checkin=True
|
||||
)
|
||||
if self.request.GET.get("subevent", "") != "":
|
||||
s = self.request.GET.get("subevent", "")
|
||||
qs = qs.filter(subevent_id=s)
|
||||
return qs
|
||||
if self.filter_form.is_valid():
|
||||
qs = self.filter_form.filter_qs(qs)
|
||||
|
||||
return qs.distinct()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['filter_form'] = self.filter_form
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
def filter_form(self):
|
||||
return VoucherFilterForm(data=self.request.GET, event=self.request.event)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if request.GET.get("download", "") == "yes":
|
||||
|
||||
9
src/pretix/helpers/dicts.py
Normal file
9
src/pretix/helpers/dicts.py
Normal file
@@ -0,0 +1,9 @@
|
||||
def merge_dicts(*dict_args):
|
||||
"""
|
||||
Given any number of dicts, shallow copy and merge into a new dict,
|
||||
precedence goes to key value pairs in latter dicts.
|
||||
"""
|
||||
result = {}
|
||||
for dictionary in dict_args:
|
||||
result.update(dictionary)
|
||||
return result
|
||||
@@ -55,6 +55,20 @@ def get_javascript_format(format_name):
|
||||
)
|
||||
|
||||
|
||||
def get_format_without_seconds(format_name):
|
||||
formats = get_format(format_name)
|
||||
formats_no_seconds = [f for f in formats if '%S' not in f]
|
||||
return formats_no_seconds[0] if formats_no_seconds else formats[0]
|
||||
|
||||
|
||||
def get_javascript_format_without_seconds(format_name):
|
||||
f = get_format_without_seconds(format_name)
|
||||
return toJavascript_re.sub(
|
||||
lambda x: date_conversion_to_moment[x.group()],
|
||||
f
|
||||
)
|
||||
|
||||
|
||||
def get_moment_locale(locale=None):
|
||||
cur_lang = locale or translation.get_language()
|
||||
if cur_lang in moment_locales:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-01-06 21:48+0000\n"
|
||||
"PO-Revision-Date: 2017-10-28 22:59+0200\n"
|
||||
"POT-Creation-Date: 2018-02-03 15:51+0000\n"
|
||||
"PO-Revision-Date: 2018-02-03 16:56+0100\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: de\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: Poedit 2.0.2\n"
|
||||
"X-Generator: Poedit 2.0.5\n"
|
||||
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
|
||||
@@ -178,11 +178,11 @@ msgstr "Generiere Nachrichten…"
|
||||
msgid "Unknown error."
|
||||
msgstr "Unbekannter Fehler."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:223
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:239
|
||||
msgid "All"
|
||||
msgstr "Alle"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:224
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:240
|
||||
msgid "None"
|
||||
msgstr "Keine"
|
||||
|
||||
@@ -295,18 +295,13 @@ msgstr ""
|
||||
"den Warenkorb zu verändern."
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:26
|
||||
#, fuzzy
|
||||
#| msgctxt "widget"
|
||||
#| msgid ""
|
||||
#| "ticketing powered by <a href=\"https://pretix.eu\" target=\"_blank"
|
||||
#| "\">pretix</a>"
|
||||
msgctxt "widget"
|
||||
msgid ""
|
||||
"ticketing powered by <a href=\"https://pretix.eu\" target=\"_blank\" rel="
|
||||
"\"noopener\">pretix</a>"
|
||||
msgstr ""
|
||||
"ticketing powered by <a href=\"https://pretix.eu\" target=\"_blank\">pretix</"
|
||||
"a>"
|
||||
"ticketing powered by <a href=\"https://pretix.eu\" target=\"_blank\" rel="
|
||||
"\"noopener\">pretix</a>"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:27
|
||||
msgctxt "widget"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-01-06 21:48+0000\n"
|
||||
"PO-Revision-Date: 2017-10-28 22:59+0200\n"
|
||||
"POT-Creation-Date: 2018-02-03 15:51+0000\n"
|
||||
"PO-Revision-Date: 2018-02-03 16:56+0100\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: de\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: Poedit 2.0.2\n"
|
||||
"X-Generator: Poedit 2.0.5\n"
|
||||
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
|
||||
@@ -133,10 +133,6 @@ msgstr ""
|
||||
"nach Größe der Veranstaltung kann dies einige Minuten dauern."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:65
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "We currenctly cannot reach the server, but we keep trying. Last error "
|
||||
#| "code: {code}"
|
||||
msgid ""
|
||||
"We currently cannot reach the server, but we keep trying. Last error code: "
|
||||
"{code}"
|
||||
@@ -182,11 +178,11 @@ msgstr "Generiere Nachrichten…"
|
||||
msgid "Unknown error."
|
||||
msgstr "Unbekannter Fehler."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:223
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:239
|
||||
msgid "All"
|
||||
msgstr "Alle"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:224
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:240
|
||||
msgid "None"
|
||||
msgstr "Keine"
|
||||
|
||||
@@ -299,18 +295,13 @@ msgstr ""
|
||||
"Warenkorb zu verändern."
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:26
|
||||
#, fuzzy
|
||||
#| msgctxt "widget"
|
||||
#| msgid ""
|
||||
#| "ticketing powered by <a href=\"https://pretix.eu\" target=\"_blank"
|
||||
#| "\">pretix</a>"
|
||||
msgctxt "widget"
|
||||
msgid ""
|
||||
"ticketing powered by <a href=\"https://pretix.eu\" target=\"_blank\" rel="
|
||||
"\"noopener\">pretix</a>"
|
||||
msgstr ""
|
||||
"ticketing powered by <a href=\"https://pretix.eu\" target=\"_blank\">pretix</"
|
||||
"a>"
|
||||
"ticketing powered by <a href=\"https://pretix.eu\" target=\"_blank\" rel="
|
||||
"\"noopener\">pretix</a>"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:27
|
||||
msgctxt "widget"
|
||||
|
||||
@@ -94,9 +94,9 @@ class ActionView(View):
|
||||
def _assign(self, trans, code):
|
||||
try:
|
||||
if '-' in code:
|
||||
trans.order = self.order_qs().get(code=code.split('-')[1], event__slug__iexact=code.split('-')[0])
|
||||
trans.order = self.order_qs().get(code=code.rsplit('-', 1)[1], event__slug__iexact=code.rsplit('-', 1)[0])
|
||||
else:
|
||||
trans.order = self.order_qs().get(code=code.split('-')[-1])
|
||||
trans.order = self.order_qs().get(code=code.rsplit('-', 1)[-1])
|
||||
except Order.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import io
|
||||
from collections import OrderedDict
|
||||
|
||||
import dateutil.parser
|
||||
from defusedcsv import csv
|
||||
from django import forms
|
||||
from django.db.models import Max, OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.formats import localize
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.timezone import is_aware, make_aware
|
||||
from django.utils.translation import pgettext, ugettext as _, ugettext_lazy
|
||||
from pytz import UTC
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import Flowable, Paragraph, Spacer, Table, TableStyle
|
||||
|
||||
@@ -235,8 +238,17 @@ class CSVCheckinList(BaseCheckinList):
|
||||
cl = self.event.checkin_lists.get(pk=form_data['list'])
|
||||
|
||||
questions = list(Question.objects.filter(event=self.event, id__in=form_data['questions']))
|
||||
|
||||
cqs = Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=cl.pk
|
||||
).order_by().values('position_id').annotate(
|
||||
m=Max('datetime')
|
||||
).values('m')
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.event,
|
||||
).annotate(
|
||||
last_checked_in=Subquery(cqs)
|
||||
).prefetch_related(
|
||||
'answers', 'answers__question'
|
||||
).select_related('order', 'item', 'variation', 'addon_to')
|
||||
@@ -253,7 +265,7 @@ class CSVCheckinList(BaseCheckinList):
|
||||
qs = qs.order_by('order__code')
|
||||
|
||||
headers = [
|
||||
_('Order code'), _('Attendee name'), _('Product'), _('Price')
|
||||
_('Order code'), _('Attendee name'), _('Product'), _('Price'), _('Checked in')
|
||||
]
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
@@ -276,11 +288,20 @@ class CSVCheckinList(BaseCheckinList):
|
||||
writer.writerow(headers)
|
||||
|
||||
for op in qs:
|
||||
last_checked_in = None
|
||||
if isinstance(op.last_checked_in, str): # SQLite
|
||||
last_checked_in = dateutil.parser.parse(op.last_checked_in)
|
||||
elif op.last_checked_in:
|
||||
last_checked_in = op.last_checked_in
|
||||
if last_checked_in and not is_aware(last_checked_in):
|
||||
last_checked_in = make_aware(last_checked_in, UTC)
|
||||
row = [
|
||||
op.order.code,
|
||||
op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''),
|
||||
str(op.item.name) + (" – " + str(op.variation.value) if op.variation else ""),
|
||||
op.price,
|
||||
date_format(last_checked_in.astimezone(self.event.timezone), 'SHORT_DATETIME_FORMAT')
|
||||
if last_checked_in else ''
|
||||
]
|
||||
if not form_data['paid_only']:
|
||||
row.append(_('Yes') if op.order.status == Order.STATUS_PAID else _('No'))
|
||||
|
||||
@@ -7,17 +7,6 @@ import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def assign_checkin_lists(apps, schema_editor):
|
||||
AppConfiguration = apps.get_model('pretixdroid', 'AppConfiguration')
|
||||
|
||||
for ac in AppConfiguration.objects.all():
|
||||
cl = ac.event.checkin_lists.get_or_create(subevent=ac.subevent, all_products=True, defaults={
|
||||
'name': ac.subevent.name if ac.subevent else 'Default'
|
||||
})[0]
|
||||
ac.list = cl
|
||||
ac.save()
|
||||
|
||||
|
||||
def runfwd(app, schema_editor):
|
||||
EventSettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
|
||||
AppConfiguration = app.get_model('pretixdroid', 'AppConfiguration')
|
||||
@@ -30,6 +19,7 @@ def runfwd(app, schema_editor):
|
||||
)
|
||||
setting.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('pretixdroid', '0003_appconfiguration'), ('pretixdroid', '0004_auto_20171124_1657'), ('pretixdroid', '0005_auto_20180106_2122')]
|
||||
@@ -51,7 +41,6 @@ class Migration(migrations.Migration):
|
||||
('show_info', models.BooleanField(default=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')),
|
||||
('items', models.ManyToManyField(blank=True, to='pretixbase.Item')),
|
||||
('subevent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent')),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
@@ -83,14 +72,6 @@ class Migration(migrations.Migration):
|
||||
name='show_info',
|
||||
field=models.BooleanField(default=True, help_text='If disabled, the device can not see how many tickets exist and how many are already scanned. pretixdroid 1.6 or newer only.', verbose_name='Show information'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=assign_checkin_lists,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='appconfiguration',
|
||||
name='subevent',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='appconfiguration',
|
||||
name='list',
|
||||
|
||||
@@ -4,8 +4,9 @@ import urllib.parse
|
||||
|
||||
import dateutil.parser
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Max, OuterRef, Q, Subquery
|
||||
from django.db.models import Count, Max, OuterRef, Prefetch, Q, Subquery
|
||||
from django.http import (
|
||||
HttpResponseForbidden, HttpResponseNotFound, JsonResponse,
|
||||
)
|
||||
@@ -18,7 +19,9 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import TemplateView, View
|
||||
|
||||
from pretix.base.models import Checkin, Event, Order, OrderPosition
|
||||
from pretix.base.models import (
|
||||
Checkin, Event, Order, OrderPosition, Question, QuestionOption,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
@@ -153,6 +156,40 @@ class ApiView(View):
|
||||
|
||||
|
||||
class ApiRedeemView(ApiView):
|
||||
|
||||
def _save_answers(self, op, answers, given_answers):
|
||||
for q, a in given_answers.items():
|
||||
if not a:
|
||||
if q in answers:
|
||||
answers[q].delete()
|
||||
else:
|
||||
continue
|
||||
if isinstance(a, QuestionOption):
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
qa.answer = str(a.answer)
|
||||
qa.save()
|
||||
qa.options.clear()
|
||||
else:
|
||||
qa = op.answers.create(question=q, answer=str(a.answer))
|
||||
qa.options.add(a)
|
||||
elif isinstance(a, list):
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
qa.answer = ", ".join([str(o) for o in a])
|
||||
qa.save()
|
||||
qa.options.clear()
|
||||
else:
|
||||
qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a]))
|
||||
qa.options.add(*a)
|
||||
else:
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
qa.answer = str(a)
|
||||
qa.save()
|
||||
else:
|
||||
op.answers.create(question=q, answer=str(a))
|
||||
|
||||
def post(self, request, **kwargs):
|
||||
secret = request.POST.get('secret', '!INVALID!')
|
||||
force = request.POST.get('force', 'false') in ('true', 'True')
|
||||
@@ -169,15 +206,46 @@ class ApiRedeemView(ApiView):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
created = False
|
||||
op = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').get(
|
||||
op = OrderPosition.objects.select_related(
|
||||
'item', 'variation', 'order', 'addon_to'
|
||||
).prefetch_related(
|
||||
'item__questions',
|
||||
Prefetch(
|
||||
'item__questions',
|
||||
queryset=Question.objects.filter(ask_during_checkin=True),
|
||||
to_attr='checkin_questions'
|
||||
),
|
||||
'answers'
|
||||
).get(
|
||||
order__event=self.event, secret=secret, subevent=self.subevent
|
||||
)
|
||||
answers = {a.question: a for a in op.answers.all()}
|
||||
require_answers = []
|
||||
given_answers = {}
|
||||
for q in op.item.checkin_questions:
|
||||
if 'answer_{}'.format(q.pk) in request.POST:
|
||||
try:
|
||||
given_answers[q] = q.clean_answer(request.POST.get('answer_{}'.format(q.pk)))
|
||||
continue
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
if q in answers:
|
||||
continue
|
||||
|
||||
require_answers.append(serialize_question(q))
|
||||
|
||||
self._save_answers(op, answers, given_answers)
|
||||
|
||||
if not self.config.list.all_products and op.item_id not in [i.pk for i in self.config.list.limit_products.all()]:
|
||||
response['status'] = 'error'
|
||||
response['reason'] = 'product'
|
||||
if not self.config.all_items and op.item_id not in [i.pk for i in self.config.items.all()]:
|
||||
elif not self.config.all_items and op.item_id not in [i.pk for i in self.config.items.all()]:
|
||||
response['status'] = 'error'
|
||||
response['reason'] = 'product'
|
||||
elif require_answers and not force and request.POST.get('questions_supported'):
|
||||
response['status'] = 'incomplete'
|
||||
response['questions'] = require_answers
|
||||
elif op.order.status == Order.STATUS_PAID or force:
|
||||
ci, created = Checkin.objects.get_or_create(position=op, list=self.config.list, defaults={
|
||||
'datetime': dt,
|
||||
@@ -223,6 +291,25 @@ class ApiRedeemView(ApiView):
|
||||
return JsonResponse(response)
|
||||
|
||||
|
||||
def serialize_question(q, items=False):
|
||||
d = {
|
||||
'id': q.pk,
|
||||
'type': q.type,
|
||||
'question': str(q.question),
|
||||
'required': q.required,
|
||||
'position': q.position,
|
||||
'options': [
|
||||
{
|
||||
'id': o.pk,
|
||||
'answer': str(o.answer)
|
||||
} for o in q.options.all()
|
||||
] if q.type in ('C', 'M') else []
|
||||
}
|
||||
if items:
|
||||
d['items'] = [i.pk for i in q.items.all()]
|
||||
return d
|
||||
|
||||
|
||||
def serialize_op(op, redeemed):
|
||||
name = op.attendee_name
|
||||
if not name and op.addon_to:
|
||||
@@ -319,6 +406,9 @@ class ApiDownloadView(ApiView):
|
||||
qs = qs.filter(item__in=self.config.items.all())
|
||||
|
||||
response['results'] = [serialize_op(op, bool(op.last_checked_in)) for op in qs]
|
||||
|
||||
questions = self.event.questions.filter(ask_during_checkin=True).prefetch_related('items', 'options')
|
||||
response['questions'] = [serialize_question(q, items=True) for q in questions]
|
||||
return JsonResponse(response)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django import forms
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||
|
||||
from pretix.base.forms import PlaceholderValidator
|
||||
from pretix.base.models import Item, Order, SubEvent
|
||||
from pretix.control.forms.widgets import Select2
|
||||
|
||||
|
||||
class MailForm(forms.Form):
|
||||
@@ -57,5 +59,16 @@ class MailForm(forms.Form):
|
||||
self.fields['item'].queryset = event.items.all()
|
||||
if event.has_subevents:
|
||||
self.fields['subevent'].queryset = event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'Date')
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
else:
|
||||
del self.fields['subevent']
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user