Compare commits

..

74 Commits

Author SHA1 Message Date
Raphael Michel
38fc826053 Bump version to 1.12.0 2018-02-03 16:59:30 +01:00
Raphael Michel
300578a44b Update German translation 2018-02-03 16:57:04 +01:00
Raphael Michel
dc2bcdcfbc Log impersonation actions 2018-02-03 16:50:53 +01:00
Raphael Michel
7e18e89012 Next try 2018-02-03 16:33:12 +01:00
Raphael Michel
24f47722c0 Fix careless mistake in 15dc6285 2018-02-03 16:02:56 +01:00
Raphael Michel
04b679a4a7 Fix question form handling of type H 2018-02-03 15:41:56 +01:00
Raphael Michel
f6713008aa Apparently, isort reverted their change 2018-02-03 15:01:01 +01:00
Raphael Michel
15dc62855b Fix check-in list export on non-SQLite databases 2018-02-03 14:08:03 +01:00
Raphael Michel
4ed3df2b08 Voucher list: Refactor to use filter form 2018-02-02 15:20:26 +01:00
Raphael Michel
8a3eaae29c Fix ValueError introduced in e12caf18 2018-02-02 15:07:53 +01:00
Raphael Michel
22edc016dd Add source code comment 2018-02-02 14:54:05 +01:00
Raphael Michel
5205daae6d Add check-in date column to check-in list CSV exporter 2018-02-02 14:44:14 +01:00
Raphael Michel
7ea79ebe56 Fix issue in question answer formatting 2018-02-02 14:44:05 +01:00
Raphael Michel
3bfa8bd81e Fix localization and timezone issue in widget 2018-02-02 14:43:51 +01:00
Raphael Michel
39abf63698 Additional fixes 2018-02-02 10:16:23 +01:00
Raphael Michel
f68a6d1119 Fix redirect assertions 2018-02-01 18:13:59 +01:00
Raphael Michel
1a1a02d080 Compliance with new isort version 2018-02-01 16:38:22 +01:00
Raphael Michel
dacffc5f90 Fix careless mistake 2018-02-01 16:31:27 +01:00
Raphael Michel
f2068b2663 Update German translation 2018-02-01 16:28:15 +01:00
Raphael Michel
989282ffbe Refs #765 -- Display warning if cookies are blocked 2018-02-01 16:28:15 +01:00
Raphael Michel
e469b2e6ad Add white logo to repository 2018-02-01 16:28:15 +01:00
Ture Gjørup
8eaada992f Refs #654 -- API: Writable item endpoints (#676)
* MKBDIGI-184: Basic create added for API items endpoint

* MKBDIGI-184: Starting endpoint for GET /api/v1/organizers/(organizer)/events/(event)/items/(id)/variations/

* MKBDIGI-184: endpoint for GET /api/v1/organizers/(organizer)/events/(event)/items/(id)/variations/

* MKBDIGI-184: Completed endpoint for variations

* MKBDIGI-184: Added endpoint for addons

* MKBDIGI-184: Added Item validation

* MKBDIGI-184: Added check for order/cart positions on item variation destroy.

* MKBDIGI-184: Fixed check for order/cart positions on item variation destroy.

* MKBDIGI-184: Updated tests, validation for addons

* MKBDIGI-184: Documentation feedback corrections

* MKBDIGI-184: Added documentation for item add-ons

* MKBDIGI-184: Code formatting fixes

* MKBDIGI-184: Feedback fixes

* MKBDIGI-184: Updated tests for delete item

* MKBDIGI-184: Cleaned up tests

* MKBDIGI-184: Added additional test URLs

* MKBDIGI-184: Documentation fixes

* MKBDIGI-184: Fixed read-only fields/Documentation

* MKBDIGI-184: Documentation fixes

* MKBDIGI-184: Added helper for dict merge for 3.4 compatibility

* MKBDIGI-184: Validation updates

* MKBDIGI-184: Fixed permissions test error. Changed to HTTP 404 for POST to addons endpoint

* MKBDIGI-184: Implemented nested variations and add-ons for POST on the item endpoint.
2018-02-01 15:43:51 +01:00
Raphael Michel
f5dba45fa0 Fix invalid queryset 2018-02-01 15:37:34 +01:00
Raphael Michel
e72b5893c4 Minor compatibility refactoring 2018-01-31 18:46:07 +01:00
Raphael Michel
e78a176e9f CSP: Remove nonce
The nonce wasn't relied on because it broke Safari and having it in
there forbids unsafe-inline, which breaks charts.
2018-01-31 18:45:25 +01:00
Raphael Michel
8143999803 Small improvements to user list 2018-01-29 13:25:33 +01:00
Raphael Michel
219c2c94e8 Update German translation 2018-01-29 12:42:51 +01:00
Raphael Michel
37f612801f Fix #762 -- Add a note on the deletion constraints of events 2018-01-29 12:25:11 +01:00
Raphael Michel
0b12b7aa89 Refs #678 -- Allow deletion of events that do not have any orders 2018-01-29 12:25:11 +01:00
Raphael Michel
14da25bd9e Allow administrators to impersonate other users 2018-01-29 12:25:11 +01:00
Raphael Michel
3a713541a2 User management UI for system administrators 2018-01-29 12:25:11 +01:00
Raphael Michel
c7a547a875 Fix encoding of error messages 2018-01-29 10:41:52 +01:00
Raphael Michel
e12caf186c Use Select2 for subevent and other long selections (#763)
* Use Select2 for subevent and other long selections

* Minor correction
2018-01-26 16:47:33 +01:00
Raphael Michel
1ee6e31538 Fix #190 and #472 -- Change of questions within pretix control 2018-01-26 12:43:47 +01:00
Raphael Michel
083c94403b Fix #400 -- Automatically create cancellations for invoices on expiry (#760) 2018-01-26 09:09:04 +01:00
Raphael Michel
67121decbf Copy some frontend styles to the backend 2018-01-24 19:13:57 +01:00
Felix Rindt
fcd6bb1084 Fix register exporter signal name in doc (#759)
The signal is defined at 
353dce789d/src/pretix/base/signals.py (L143)
and ends with an s.
2018-01-24 17:48:25 +01:00
Raphael Michel
a81a4b895a Fix waiting list processing with infinite-size quotas 2018-01-24 15:04:23 +01:00
Raphael Michel
c50c5177b8 Widget checkout: Fixed links to modify order details
Thanks @codingjoe for reporting!
2018-01-24 13:18:31 +01:00
Raphael Michel
30eefe57ef Add word to typo whitelist 2018-01-23 15:02:15 +01:00
Raphael Michel
ce33cce5a9 Update German translation 2018-01-22 22:59:00 +01:00
Raphael Michel
d0dfde382c Questions at check-in time (#745)
Questions at check-in time
2018-01-22 22:55:54 +01:00
Raphael Michel
7fb2d0526e Updated German translation 2018-01-22 22:54:35 +01:00
Raphael Michel
fb34467cba Invoice renderer: Add quantity column 2018-01-22 22:54:35 +01:00
Raphael Michel
7e62cddb97 PDF ticket output: Add item category variable 2018-01-22 22:54:35 +01:00
Felix Rindt
78b31149b5 Fix #751 -- calculate payment fees in OrderChangeManager (#752)
* check for payment method instead of order total

* incorporate payment fee diff in totaldiff at oder change

* use fee from model and the correct order total

* add error handling

* do not change paid orders

* OrderChangedManager can only be committed once

* remove prints of stripe secrets

* add tests

* an OrderChangeManager must not be committed multiple times
* A pending free order stays pending after being changed

* comments on paid_to_free logic
2018-01-22 12:53:46 +01:00
Raphael Michel
817038563f Detect more invalid placeholder specs 2018-01-22 09:02:57 +01:00
Felix Rindt
56ca2305bd Payment Docs: Fix arrow and link to pretix website (#755) 2018-01-19 11:07:13 +01:00
Felix Rindt
fc7bafe3d9 Fix italics underscores in markdown doc (Fix #748) (#750)
let's just get this out of the way ^^ There are more important issues...
2018-01-17 12:15:14 +01:00
Felix Rindt
d622f38e1d Fix #747 -- Logging of download reminders (#749)
Fix #747 -- Logging of download reminders
2018-01-17 12:15:00 +01:00
Felix Rindt
139810c8a5 fix typo in docstring (#746) 2018-01-16 12:36:29 +01:00
Raphael Michel
f8cc332ed7 Use "cancel" method instead of "refund" for free orders (#743)
* Use "cancel" method instead of "refund" for free orders

* Adjust API
2018-01-15 21:46:16 +01:00
Mohit Jindal
db24bd4d78 Fix #674 -- Assigning bank transactions with a dash in the event slug (#744) 2018-01-15 14:10:53 +01:00
Raphael Michel
d056013296 Fix failing test on CI 2018-01-15 13:06:24 +01:00
Raphael Michel
7e647f7085 Fix logic bug 2018-01-15 12:38:12 +01:00
Raphael Michel
322068b5e0 Update German translation 2018-01-15 11:34:48 +01:00
Raphael Michel
96247d5fa0 Shorter and more useful global dashboard 2018-01-15 11:32:30 +01:00
Raphael Michel
6b7338aff0 Improve performance of global order search 2018-01-15 10:55:26 +01:00
Raphael Michel
59d85cc218 Query optimization experiments 2018-01-14 21:15:42 +01:00
Raphael Michel
7f90fdedf1 Update German translation 2018-01-14 18:32:52 +01:00
Raphael Michel
7723c956bc Do not disable migrations on Travis 2018-01-14 18:23:51 +01:00
Raphael Michel
d0c10a8f72 Fix broken squashed migration 2018-01-14 18:22:45 +01:00
Raphael Michel
c56dd52bd6 Invoices: Hide all tax-related info if there are no taxes involved (#742) 2018-01-14 18:04:06 +01:00
Raphael Michel
a7374f5bbd Code style fixes 2018-01-14 15:17:16 +01:00
Felix Rindt
251d62f3c4 Fix #732 -- Add date and time question types (#732)
* [WIP] add date/time question type

* Date/time questions python classes, types and form handling

* use own timepicker

* Fix argument naming

* Add css and js for datetimepickers

* remove not needed str call

* seperate splitdatetime widget template and fix date/time questions

* change date placeholder to dec 31

* do not show seconds in presale time pickers

* improve codestyle

* add new question types to api doc

* add test

* expand test to datetime question

* add new questiontypes to changelog

remove duplicate parens

* remove timezone from time only question answers

* improve codestyle

* Fix date and time formatting in control question overview
2018-01-14 14:29:38 +01:00
Raphael Michel
b8c041d0d6 Fix #712 -- by default show answers by paid and pending orders 2018-01-14 14:21:26 +01:00
Aiman Parvaiz
dd42037f21 Fix #634 -- Do not allow deleting the last date of an event series (#675)
* Checking for the last date in the event series before deleting a date. Last date in a event series should never be delted.

* Adding check to ensure that last date in a event series is not deleted. Editing unit test around deleting subevent to assert on alert-danger

* Increasing the scope of test_delete. We are now creating 2 subevents and testing deleting one and ensuring that the last one is not deleted

* Fixing alert text. Removing a redundant if condition for checking subevent count

* Adding assert for second event to ensure its not deleted

* Minor fixes and rebase
2018-01-14 13:54:22 +01:00
Raphael Michel
50575d45c1 Fix failing mail tests 2018-01-10 23:04:57 +01:00
Raphael Michel
7268c7fb70 Waiting list: Fix availability calculation 2018-01-10 22:00:07 +01:00
Raphael Michel
83572960d5 Clear combined ticket cache after order information change 2018-01-09 00:03:48 +01:00
Raphael Michel
39f22fa314 Set event name as sender name in emails
(thanks @luto for the suggestion)
2018-01-08 16:16:44 +01:00
Tobias Kunze
69ab5d8c2e Fix typo in 2FA view (#739) 2018-01-08 13:49:46 +01:00
Raphael Michel
58111465bc Widget: Number input field should always have english decimal separator 2018-01-07 19:35:19 +01:00
Raphael Michel
697e56962a Bump to 1.12.0.dev0 2018-01-06 23:42:17 +01:00
207 changed files with 16172 additions and 3087 deletions

1
.gitattributes vendored
View File

@@ -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

View File

@@ -10,6 +10,8 @@ Resources and endpoints
taxrules
categories
items
item_variations
item_add-ons
questions
quotas
orders

View 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.

View 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.

View File

@@ -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.

View File

@@ -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": [
{

View File

@@ -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

View File

@@ -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",

View File

@@ -101,6 +101,7 @@ unprefixed
untrusted
username
url
versa
viewset
viewsets
webhook

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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

View File

@@ -1 +1 @@
__version__ = "1.11.0"
__version__ = "1.12.0"

View File

@@ -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):

View File

@@ -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)),
]

View File

@@ -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:

View File

@@ -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),
)

View 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

View File

@@ -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:

View 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)

View File

@@ -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))

View File

@@ -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']

View 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
)
]

View File

@@ -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'),
),
]

View File

@@ -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

View File

@@ -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:

View File

@@ -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):

View File

@@ -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')

View File

@@ -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)

View File

@@ -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
"""

View File

@@ -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.'))

View File

@@ -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

View File

@@ -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:

View File

@@ -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))

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
<div class="splitdatetimerow">
{% include 'django/forms/widgets/multiwidget.html' %}
</div>

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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']

View File

@@ -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

View File

@@ -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

View File

@@ -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']

View 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)

View 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

View File

@@ -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'])

View File

@@ -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" %}"

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View 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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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" %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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> &middot;
<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>

View File

@@ -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>

View File

@@ -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>&laquo;</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>&raquo;</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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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>

View 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 %}

View 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 %}

View 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 %}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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(),

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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(

View File

@@ -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'

View File

@@ -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'
)

View File

@@ -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()

View File

@@ -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)

View 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)

View File

@@ -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":

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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',

View File

@@ -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'))

View File

@@ -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',

View File

@@ -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)

View File

@@ -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