Compare commits

...

135 Commits

Author SHA1 Message Date
Raphael Michel
c68b6116a2 Bump version to 1.13.0 2018-03-03 21:38:07 +01:00
Raphael Michel
f0db879c9c Update docs and German translation 2018-03-03 21:16:17 +01:00
Felix Rindt
07d8a3d765 Fix #774 -- Make question options sortable (#786)
* add position field

* add question option sorting logic
* add meta class to question option for sorting
* regenerate migration
* add template content and view mechanics

* Rename migration after rebase & update dependency
2018-03-03 20:36:30 +01:00
Raphael Michel
e35e264d81 Improve voucher redemption filter (#792) 2018-03-03 11:58:59 +01:00
Raphael Michel
d537e6a869 Order confirmation: Add e-mail to contact information box 2018-03-03 11:56:33 +01:00
Leonardo
d4dd1861a9 Fix #740 -- Date picker: Fix line height for decade span (#761)
* Fix line height for decade span

* Move to own file
2018-03-03 11:31:23 +01:00
Mohit Jindal
3019a31fbb Fix #735 -- Display of event series on public organizer page (#753) 2018-03-03 11:24:07 +01:00
Raphael Michel
303b9912ff Add „button“ operation mode of the widget (#778) 2018-03-03 11:20:41 +01:00
Raphael Michel
0259b2e5b9 Update paypal documentation 2018-03-02 22:55:37 +01:00
Raphael Michel
5c7e8029f4 Fix incorrect test case 2018-03-02 22:05:56 +01:00
Raphael Michel
08e3fd3141 Fix spelling 2018-03-02 21:54:36 +01:00
Raphael Michel
30123fd6ff Add currency property to subevent 2018-03-02 21:54:08 +01:00
Raphael Michel
3955299983 Catch VAT WebServiceError 2018-03-01 09:21:21 +01:00
Raphael Michel
b5d0df3ca7 Fix determination of VAT ID validation 2018-03-01 09:19:04 +01:00
Raphael Michel
22c65da9d1 Fix invalid use of money_filter 2018-03-01 09:17:59 +01:00
Raphael Michel
578c1ecfaf Add support for custom taxation rules 2018-02-28 23:03:25 +01:00
Raphael Michel
d8d00a7e26 Add total argument to fee calculation signals 2018-02-28 21:03:38 +01:00
Raphael Michel
37f0f7a138 Add service fees as a first-level fee type 2018-02-27 22:39:07 +01:00
Raphael Michel
f61e9367ec Update German translation 2018-02-26 10:51:44 +01:00
Raphael Michel
3c3e59e932 Refs #99 -- Improve support for currencies with less than 2 decimal places (#783)
* Refs #99 -- Fix stripe support for zero-decimal currencies

* Add new money formatting method

* Force decimal places in many places

* Locale-aware currency rendering

* Fix currencies in more places

* More currency fixes
2018-02-26 10:46:07 +01:00
Raphael Michel
29e22a0c6c Fix check-in of unpaid orders in web check-in list 2018-02-26 10:42:58 +01:00
Raphael Michel
0d1f424425 Improve performance of voucher bulk creation 2018-02-26 10:42:58 +01:00
Tim Freund
1c01e23867 Name presale index + unit test for URL names (#784)
* Name the default URL

If metrics collection is enabled, the index page of the site will fail
to load: without a name, the metrics middleware throws a TypeError.

* Test for names on all URLs

This test passes if all URLs have names. Without names, URLs will cause
the optional metrics middleware to throw a TypeError.
2018-02-26 10:17:42 +01:00
Felix Rindt
f763a8694b Fix #779: add form field for unpaid option of checkin lists in subevent detail view (#781)
* add form field for unpaid option of checkin lists in subevent detail view

* change order of include_pending field

* also change the order in new check in lists
2018-02-26 10:17:28 +01:00
Raphael Michel
675b853b29 Remove organizer property from ICalendar files as we used it not as it is intended to be used. 2018-02-23 10:51:32 +01:00
Raphael Michel
2434bf14d5 Add checkin_attetion field to Order model 2018-02-22 13:25:26 +01:00
Felix Rindt
70fbbfe2a0 Refs #757: show voucher input for subevents only if subevent is selected (#777)
* show voucher input for subevents only if subevent is selected

* move logic to python
2018-02-22 09:44:53 +01:00
Raphael Michel
e096898a05 Update German translation 2018-02-21 16:17:06 +01:00
Raphael Michel
3fbccf3f64 Allow check-in lists to include unpaid orders 2018-02-21 16:17:06 +01:00
Raphael Michel
36585395f1 Voucher list: add more filters 2018-02-21 16:17:06 +01:00
Felix Rindt
e4b0a1613f Refs #754 -- check item tax_rule is not none (#776) 2018-02-21 12:51:50 +01:00
Raphael Michel
1192e474c5 Prevent duplicate All/None links 2018-02-20 10:20:24 +01:00
Raphael Michel
e48ea99e48 Fix datetime in check-in list on MySQL 2018-02-20 10:19:55 +01:00
Raphael Michel
072f2a0ee9 Pin sessions to the user agent in use 2018-02-19 13:02:55 +01:00
Tim Freund
aecb536a34 Use config.getboolean to get metrics enabled value (#770)
Given the following configuration:

[metrics]
enabled=False

Using config.get results in a METRICS_ENABLED value that always
evaluates to True. This PR switches to config.getboolean so that metrics
can be disabled without deleting the configuration values.
2018-02-18 17:40:13 +01:00
Tim Freund
a68686cb06 Docs: Fix link to the Celery configuration documentation (#771) 2018-02-18 17:39:51 +01:00
Tim Freund
ba8cf3e01e Replace PREFIX_CONFIG_FILE with PRETIX_CONFIG_FILE (#769)
The code looks for PRETIX_CONFIG_FILE in src/pretix/settings.py.
This change updates the documentation to match.
2018-02-18 17:39:34 +01:00
Raphael Michel
b0c5189c4b Fix timezone for footer of printed exports 2018-02-14 11:50:24 +01:00
Raphael Michel
d44eb67dec Allow http: forms during testing 2018-02-14 11:50:10 +01:00
Raphael Michel
58d36b08e2 Pin Sphinx version 2018-02-14 11:49:50 +01:00
Raphael Michel
98906731e3 Move plugin list to website 2018-02-14 11:49:44 +01:00
Raphael Michel
035a4b0928 Add next parameter to logout view 2018-02-14 11:49:16 +01:00
Raphael Michel
85fbe666ea Order modification page: Make cancel button more useful 2018-02-12 12:38:30 +01:00
Tobias Kunze
741d0bc686 Put event slugs in export filenames (#768) 2018-02-12 12:30:13 +01:00
Raphael Michel
ded539ce7a Ignore event end date for subevents 2018-02-07 13:51:22 +01:00
Raphael Michel
c53fd25d1c Use a consistant CSS compression method 2018-02-05 13:48:47 +01:00
Raphael Michel
da32621c55 Add "is_implicit" attribute to payment providers 2018-02-04 23:14:18 +01:00
Raphael Michel
4ccf33af03 Add support for orders without email addresses 2018-02-04 22:42:41 +01:00
Raphael Michel
a5af7a70f3 Add support for iframeResizer 2018-02-04 22:42:04 +01:00
Raphael Michel
16ab0d29d6 Add request argument to contact_form_fields signal 2018-02-04 22:15:58 +01:00
Raphael Michel
05ad9022c0 Always use full width when used in an iframe 2018-02-04 22:02:54 +01:00
Raphael Michel
fef211b220 Change typeahead.css and morris.css to scss files 2018-02-04 21:06:44 +01:00
Raphael Michel
6aee1ee41f Stip HTML from text in PDFs except for <br>, make <br> not break things 2018-02-04 19:45:00 +01:00
Raphael Michel
bab7f9b1f3 Notification view: use select2 event selection 2018-02-04 19:09:22 +01:00
Raphael Michel
340e7afd06 Fix bug that lead to notifications being sent for all events 2018-02-04 18:53:56 +01:00
Raphael Michel
cb83c9cff2 Add a short system check before publishing packages 2018-02-04 18:33:50 +01:00
Raphael Michel
911a8fed06 Fix waiting list test 2018-02-04 18:28:29 +01:00
Raphael Michel
eb8b43fe36 Add missing __init__.py file 2018-02-04 18:27:45 +01:00
Raphael Michel
2a15dc57d8 Waiting list: Do not send out for disabled events 2018-02-04 14:24:53 +01:00
Raphael Michel
67678e35bb Disable shop and waiting list after end of event 2018-02-04 14:14:49 +01:00
Raphael Michel
2f00db8081 Bump version to 1.13.0.dev0 2018-02-03 17:00:40 +01:00
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
291 changed files with 22009 additions and 4759 deletions

2
.gitattributes vendored
View File

@@ -6,7 +6,9 @@ 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/static/iframeresizer/* linguist-vendored
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/fabric.* linguist-vendored
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/pdf.* linguist-vendored

View File

@@ -19,6 +19,10 @@ pypi:
- pip install -U pip wheel setuptools
- XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt
- cd src
- python setup.py sdist
- pip install dist/pretix-*.tar.gz
- python -m pretix migrate
- python -m pretix check
- python setup.py sdist upload
- python setup.py bdist_wheel upload
tags:

View File

@@ -12,7 +12,7 @@ at the following locations. It will try to read the file from the specified path
the following order. The file that is found *last* will override the settings from
the files found before.
1. ``PREFIX_CONFIG_FILE`` environment variable
1. ``PRETIX_CONFIG_FILE`` environment variable
2. ``/etc/pretix/pretix.cfg``
3. ``~/.pretix.cfg``
4. ``pretix.cfg`` in the current working directory
@@ -288,4 +288,4 @@ various places like order codes, secrets in the ticket QR codes, etc. Example::
voucher_code=16
.. _Python documentation: https://docs.python.org/3/library/configparser.html?highlight=configparser#supported-ini-file-structure
.. _Celery documentation: http://docs.celeryproject.org/en/latest/configuration.html
.. _Celery documentation: http://docs.celeryproject.org/en/latest/userguide/configuration.html

View File

@@ -21,11 +21,12 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the check-in list
name string The internal name of the check-in list
all_products boolean If ``True``, the check-in lists contains tickets of all products in this event. The ``limit_products`` field is ignored in this case.
all_products boolean If ``true``, the check-in lists contains tickets of all products in this event. The ``limit_products`` field is ignored in this case.
limit_products list of integers List of item IDs to include in this list.
subevent integer ID of the date inside an event series this list belongs to (or ``null``).
position_count integer Number of tickets that match this list (read-only).
checkin_count integer Number of check-ins performed on this list (read-only).
include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state.
===================================== ========================== =======================================================
.. versionchanged:: 1.10
@@ -36,6 +37,10 @@ checkin_count integer Number of check
The ``positions`` endpoints have been added.
.. versionchanged:: 1.13
The ``include_pending`` field has been added.
Endpoints
---------
@@ -71,6 +76,7 @@ Endpoints
"position_count": 456,
"all_products": true,
"limit_products": [],
"include_pending": false,
"subevent": null
}
]
@@ -111,6 +117,7 @@ Endpoints
"position_count": 456,
"all_products": true,
"limit_products": [],
"include_pending": false,
"subevent": null
}
@@ -156,6 +163,7 @@ Endpoints
"position_count": 0,
"all_products": false,
"limit_products": [1, 2],
"include_pending": false,
"subevent": null
}
@@ -204,6 +212,7 @@ Endpoints
"position_count": 42,
"all_products": false,
"limit_products": [1, 2],
"include_pending": false,
"subevent": null
}

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

@@ -34,6 +34,9 @@ payment_fee_tax_value money (string) Tax value inclu
payment_fee_tax_rule integer The ID of the used tax rule (or ``null``)
total money (string) Total value of this order
comment string Internal comment on this order
checkin_attention boolean If ``True``, the check-in app should show a warning
that this ticket requires special attention if a ticket
of this order is scanned.
invoice_address object Invoice address information (can be ``null``)
├ last_modified datetime Last modification date of the address
├ company string Customer company name
@@ -88,6 +91,10 @@ downloads list of objects List of ticket
First write operations (``…/mark_paid/``, ``…/mark_pending/``, ``…/mark_canceled/``, ``…/mark_expired/``) have been added.
The attribute ``invoice_address.internal_reference`` has been added.
.. versionchanged:: 1.13
The field ``checkin_attention`` has been added.
.. _order-position-resource:
Order position resource
@@ -175,6 +182,7 @@ Order endpoints
"fees": [],
"total": "23.00",
"comment": "",
"checkin_attention": false,
"invoice_address": {
"last_modified": "2017-12-01T10:00:00Z",
"is_business": True,
@@ -282,6 +290,7 @@ Order endpoints
"fees": [],
"total": "23.00",
"comment": "",
"checkin_attention": false,
"invoice_address": {
"last_modified": "2017-12-01T10:00:00Z",
"company": "Sample company",

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

@@ -4,7 +4,8 @@ Tax rules
Resource description
--------------------
Tax rules specify how tax should be calculated for specific products.
Tax rules specify how tax should be calculated for specific products. Custom taxation rule sets are currently to
available via the API.
.. rst-class:: rest-resource-table

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

@@ -102,6 +102,8 @@ The provider class
.. automethod:: order_control_refund_perform
.. automethod:: is_implicit
Additional views
----------------

View File

@@ -4,56 +4,7 @@
List of plugins
===============
The following plugins are shipped with pretix and are supported in the same
ways that pretix itself is:
A detailed list of plugins that are available for pretix can be found on the
`project website`_.
* Bank transfer
* PayPal
* Stripe
* Check-in lists
* pretixdroid
* Report exporter
* Send out emails
* Statistics
* PDF ticket output
The following plugins are not shipped with pretix but are maintained by the
same team. We update them regularly to make them compatible with the latest
pretix releases:
* `SEPA direct debit`_
* `Wirecard payment`_
* `Pages`_
* `Passbook/Wallet ticket output`_
* `Cartshare`_
* `Fontpack Free fonts`_
* `Mailing list subscription`_
The following closed-source plugins are available to customers of the hosted pretix.eu platform.
Please get in touch with the pretix team if you want to have them for your self-hosted
pretix installation:
* Campaign tracking
* Integration with Google Analytics and Facebook Pixel
* Integration with Slack
* Integration with MailChimp
The following plugins are from independent third-party authors, so we can make
no statements about their functionality, security, stability or compatibility:
* `esPass ticket output`_
* `IcePay integration`_
* `Average price chart`_
* `Pay in cash upon arrival`_
.. _SEPA direct debit: https://github.com/pretix/pretix-sepadebit
.. _Passbook/Wallet ticket output: https://github.com/pretix/pretix-passbook
.. _Cartshare: https://github.com/pretix/pretix-cartshare
.. _Pages: https://github.com/pretix/pretix-pages
.. _esPass ticket output: https://github.com/esPass/pretix-espass
.. _IcePay integration: https://github.com/chotee/pretix-icepay
.. _Fontpack Free fonts: https://github.com/pretix/pretix-fontpack-free
.. _Wirecard payment: https://github.com/pretix/pretix-wirecard
.. _Mailing list subscription: https://github.com/pretix/pretix-newsletter-ml
.. _Average price chart: https://github.com/rixx/pretix-avgchart
.. _Pay in cash upon arrival: https://github.com/pc-coholic/pretix-cashpayment
.. _project website: https://pretix.eu/about/en/plugins

View File

@@ -9,6 +9,17 @@ 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.
.. versionchanged:: 1.13
Support for checking in unpaid tickets has been added.
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
Redeems a ticket, i.e. checks the user in.
@@ -22,18 +33,33 @@ 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.
You **may** set the additional parameter ``ignore_unpaid`` to indicate that the check-in should be performed even
if the order is in pending state.
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 +69,68 @@ 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,
"checkin_allowed": 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,
"checkin_allowed": 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 +140,40 @@ 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,
"checkin_allowed": 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
@@ -100,11 +211,12 @@ uses to communicate with the pretix server.
"attendee_name": "Peter Higgs",
"redeemed": false,
"attention": false,
"checkin_allowed": true,
"paid": true
},
...
],
"version": 2
"version": 3
}
:query query: Search query
@@ -133,6 +245,7 @@ uses to communicate with the pretix server.
Content-Type: text/json
{
"version": 3,
"results": [
{
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
@@ -142,11 +255,31 @@ uses to communicate with the pretix server.
"attendee_name": "Peter Higgs",
"redeemed": false,
"attention": false,
"checkin_allowed": true,
"paid": true
},
...
],
"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 +310,7 @@ uses to communicate with the pretix server.
{
"checkins": 17,
"total": 42,
"version": 2,
"version": 3,
"event": {
"name": "Demo Converence",
"slug": "democon",

View File

@@ -1,5 +1,5 @@
-r ../src/requirements.txt
sphinx
sphinx==1.6.*
sphinx-rtd-theme
sphinxcontrib-httpdomain
sphinxcontrib-images

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

@@ -100,6 +100,16 @@ taxes" at the end of the page.
errors of usually up to one cent from the intended price. This is unavoidable due to the
flexible nature in which prices are being calculated.
Custom tax rules
----------------
If you have very special requirements for the conditions in which VAT will or will not be charged, you can use the
"Custom tax rules" section instead of the options listed above. Here, you can create a set of rules consisting of
conditions (i.e. a country or a type of customer) and actions (i.e. do or do not charge VAT).
The rules will then be checked from top to bottom and the first matching rule will be used to decide if VAT will be
charged to the user.
Taxation of payment fees
------------------------

View File

@@ -101,4 +101,43 @@ voucher's settings.
</div>
</noscript>
pretix Button
-------------
Instead of a product list, you can also display just a single button. When pressed, the button will add a number of
products associated with the button to the cart and will immediately proceed to checkout if the operation succeeded.
You can try out this behavior here:
.. raw:: html
<pretix-button event="https://pretix.eu/demo/democon/" items="item_6424=1">Buy ticket!</pretix-button>
<noscript>
<div class="pretix-widget">
<div class="pretix-widget-info-message">
JavaScript is disabled in your browser. To access our ticket shop without javascript, please <a target="_blank" href="https://pretix.eu/demo/democon/">click here</a>.
</div>
</div>
</noscript>
<br><br>
You can embed the pretix Button just like the pretix Widget. Just like above, first embed the CSS and JavaScript
resources. Then, instead of the ``pretix-widget`` tag, use the ``pretix-button`` tag::
<pretix-button event="https://pretix.eu/demo/democon/" items="item_6424=1">
Buy ticket!
</pretix-button>
As you can see, the ``pretix-button`` element takes an additional ``items`` attribute that specifies the items that
should be added to the cart. The syntax of this attribute is ``item_ITEMID=1,item_ITEMID=2,variation_ITEMID_VARID=4``
where ``ITEMID`` are the internal IDs of items to be added and ``VARID`` are the internal IDs of variations of those
items, if the items have variations.
Just as the widget, the button supports the optional attributes ``voucher`` and ``skip-ssl-check``.
You can style the button using the ``pretix-button`` CSS class.
.. versionchanged:: 1.13
The pretix Button has been added in version 1.13.
.. _Let's Encrypt: https://letsencrypt.org/

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

@@ -12,6 +12,12 @@ If you look into pretix' settings, you are required to fill in two keys:
Unfortunately, it is not straightforward how to get those keys from PayPal's website. In order to do so, you
need to go to `developer.paypal.com`_ to link the account to your pretix event.
.. warning::
Unfortunately, PayPal tries to confuse you by having multiple APIs with different keys. You really need to
go to https://developer.paypal.com for the API we use, not to your normal account settings!
Click on "Log In" in the top-right corner and log in with your PayPal account.
.. image:: img/paypal2.png
@@ -46,8 +52,8 @@ webhooks. To create one, scroll a bit down and click "Add Webhook".
.. image:: img/paypal7.png
:class: screenshot
Then, enter the webhook URL that you find on the pretix settings page. It should look similar to the one in the
screenshot but contain your event name. Tick the box "All events" and save.
Then, enter the webhook URL that you find on the pretix settings page. If you use pretix Hosted, this is always ``https://pretix.eu/_paypal/webhook/``.
Tick the box "All events" and save.
.. image:: img/paypal8.png
:class: screenshot

View File

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

View File

@@ -1,12 +1,11 @@
import time
from django.conf import settings
from django.contrib.auth import logout
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import SAFE_METHODS, BasePermission
from pretix.base.models import Event
from pretix.base.models.organizer import Organizer, TeamAPIToken
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
)
class EventPermission(BasePermission):
@@ -24,16 +23,13 @@ class EventPermission(BasePermission):
required_permission = None
if request.user.is_authenticated:
# If this logic is updated, make sure to also update the logic in pretix/control/middleware.py
if not settings.PRETIX_LONG_SESSIONS or not request.session.get('pretix_auth_long_session', False):
last_used = request.session.get('pretix_auth_last_used', time.time())
if time.time() - request.session.get('pretix_auth_login_time', time.time()) > settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE:
logout(request)
request.session['pretix_auth_login_time'] = 0
return False
if time.time() - last_used > settings.PRETIX_SESSION_TIMEOUT_RELATIVE:
return False
request.session['pretix_auth_last_used'] = int(time.time())
try:
# If this logic is updated, make sure to also update the logic in pretix/control/middleware.py
assert_session_valid(request)
except SessionInvalid:
return False
except SessionReauthRequired:
return False
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken)
else request.user)

View File

@@ -12,7 +12,8 @@ class CheckinListSerializer(I18nAwareModelSerializer):
class Meta:
model = CheckinList
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count')
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
'include_pending')
def validate(self, data):
data = super().validate(data)

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

@@ -135,7 +135,7 @@ class OrderSerializer(I18nAwareModelSerializer):
model = Order
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'payment_fee', 'payment_fee_tax_rate', 'payment_fee_tax_value')
'payment_fee', 'payment_fee_tax_rate', 'payment_fee_tax_value', 'checkin_attention')
class InlineInvoiceLineSerializer(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

@@ -126,7 +126,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
qs = OrderPosition.objects.filter(
order__event=self.request.event,
order__status=Order.STATUS_PAID,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.checkinlist.include_pending else [Order.STATUS_PAID],
subevent=self.checkinlist.subevent
).annotate(
last_checked_in=Subquery(cqs)

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

@@ -1,5 +1,12 @@
from decimal import ROUND_HALF_UP, Decimal
from django.conf import settings
def round_decimal(dec):
def round_decimal(dec, currency=None):
if currency:
places = settings.CURRENCY_PLACES.get(currency, 2)
return Decimal(dec).quantize(
Decimal('1') / 10 ** places, ROUND_HALF_UP
)
return Decimal(dec).quantize(Decimal('0.01'), ROUND_HALF_UP)

View File

@@ -55,7 +55,7 @@ class AnswerFilesExporter(BaseExporter):
i.file.close()
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return 'answers.zip', 'application/zip', zipf.read()
return '{}_answers.zip'.format(self.event.slug), 'application/zip', zipf.read()
@receiver(register_data_exporters, dispatch_uid="exporter_answers")

View File

@@ -21,7 +21,7 @@ class MailExporter(BaseExporter):
pos = OrderPosition.objects.filter(
order__event=self.event, order__status__in=form_data['status']
).values('attendee_email')
data = "\r\n".join(set(a['email'] for a in addrs)
data = "\r\n".join(set(a['email'] for a in addrs if a['email'])
| set(a['attendee_email'] for a in pos if a['attendee_email']))
return '{}_pretixemails.txt'.format(self.event.slug), 'text/plain', data.encode("utf-8")

View File

@@ -140,7 +140,7 @@ class OrderListExporter(BaseExporter):
row.append(', '.join([i.number for i in order.invoices.all()]))
writer.writerow(row)
return 'orders.csv', 'text/csv', output.getvalue().encode("utf-8")
return '{}_orders.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
class QuotaListExporter(BaseExporter):

View File

@@ -11,16 +11,16 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from .validators import PlaceholderValidator # NOQA
logger = logging.getLogger('pretix.plugins.ticketoutputpdf')
logger = logging.getLogger(__name__)
class BaseI18nModelForm(i18nfield.forms.BaseI18nModelForm):
# compatibility shim for django-i18nfield library
def __init__(self, *args, **kwargs):
event = kwargs.pop('event', None)
if event:
kwargs['locales'] = event.settings.get('locales')
self.event = kwargs.pop('event', None)
if self.event:
kwargs['locales'] = self.event.settings.get('locales')
super().__init__(*args, **kwargs)
@@ -32,9 +32,9 @@ class I18nFormSet(i18nfield.forms.I18nModelFormSet):
# compatibility shim for django-i18nfield library
def __init__(self, *args, **kwargs):
event = kwargs.pop('event', None)
if event:
kwargs['locales'] = event.settings.get('locales')
self.event = kwargs.pop('event', None)
if self.event:
kwargs['locales'] = self.event.settings.get('locales')
super().__init__(*args, **kwargs)

View File

@@ -0,0 +1,244 @@
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.'))
except vat_moss.errors.WebServiceError:
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 returned an incorrect result. We will therefore '
'need to charge VAT on your invoice. Please contact support to '
'resolve this manually.'))
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

@@ -12,6 +12,8 @@ from i18nfield.forms import I18nFormField # noqa
from i18nfield.strings import LazyI18nString # noqa
from i18nfield.utils import I18nJSONEncoder # noqa
from pretix.base.templatetags.money import money_filter
class LazyDate:
def __init__(self, value):
@@ -24,6 +26,18 @@ class LazyDate:
return date_format(self.value, "SHORT_DATE_FORMAT")
class LazyCurrencyNumber:
def __init__(self, value, currency):
self.value = value
self.currency = currency
def __format__(self, format_spec):
return self.__str__()
def __str__(self):
return money_filter(self.value, self.currency)
class LazyNumber:
def __init__(self, value, decimal_pos=2):
self.value = value

View File

@@ -24,6 +24,7 @@ from reportlab.platypus import (
from pretix.base.decimal import round_decimal
from pretix.base.models import Event, Invoice
from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter
class BaseInvoiceRenderer:
@@ -321,6 +322,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 +355,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) + " %",
money_filter(line.net_value, self.invoice.event.currency),
money_filter(line.gross_value, self.invoice.event.currency),
))
else:
tdata.append((
Paragraph(line.description, self.stylesheet['Normal']),
"1",
money_filter(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'), '', '', '', money_filter(total, self.invoice.event.currency)
])
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else:
tdata.append([
pgettext('invoice', 'Invoice total'), '', money_filter(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)
@@ -410,9 +437,9 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
tax = taxvalue_map[idx]
tdata.append([
localize(rate) + " % " + name,
localize(gross - tax) + " " + self.invoice.event.currency,
localize(gross) + " " + self.invoice.event.currency,
localize(tax) + " " + self.invoice.event.currency,
money_filter(gross - tax, self.invoice.event.currency),
money_filter(gross, self.invoice.event.currency),
money_filter(tax, self.invoice.event.currency),
''
])
@@ -422,7 +449,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}"],
@@ -191,7 +187,7 @@ class SecurityMiddleware(MiddlewareMixin):
# form-actions redirect to. In the context of e.g. payment providers or
# single-sign-on this can be nearly anything so we cannot really restrict
# this. However, we'll restrict it to HTTPS.
'form-action': ["{dynamic}", "https:"],
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
'report-uri': ["/csp_report/"],
}
if 'Content-Security-Policy' in resp:
@@ -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

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.10 on 2018-02-20 10:31
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0080_question_ask_during_checkin'),
]
operations = [
migrations.AddField(
model_name='checkinlist',
name='include_pending',
field=models.BooleanField(default=False, verbose_name='Include pending orders'),
),
migrations.AlterField(
model_name='event',
name='presale_end',
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold after this date. If you do not set this value, the presale will end after the end date of your event.', null=True, verbose_name='End of presale'),
),
migrations.AlterField(
model_name='logentry',
name='event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='pretixbase.Event'),
),
migrations.AlterField(
model_name='question',
name='ask_during_checkin',
field=models.BooleanField(default=False, help_text='This will only work if you handle your check-in with pretixdroid 1.8 or newer or pretixdesk 0.2 or newer.', verbose_name='Ask during check-in instead of in the ticket buying process'),
),
migrations.AlterField(
model_name='subevent',
name='presale_end',
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold after this date. If you do not set this value, the presale will end after the end date of your event.', null=True, verbose_name='End of presale'),
),
migrations.AlterField(
model_name='user',
name='require_2fa',
field=models.BooleanField(default=False, verbose_name='Two-factor authentification is required to log in'),
),
]

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.10 on 2018-02-22 09:38
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0081_auto_20180220_1031'),
]
operations = [
migrations.AddField(
model_name='order',
name='checkin_attention',
field=models.BooleanField(default=False, help_text='If you set this, the check-in app will show a visible warning that tickets of this order require special attention. This will not show any details or custom message, so you need to brief your check-in staff how to handle these cases.', verbose_name='Requires special attention'),
),
migrations.AlterField(
model_name='checkinlist',
name='include_pending',
field=models.BooleanField(default=False, help_text='With this option, people will be able to check in even if the order have not been paid. This only works with pretixdesk 0.3.0 or newer or pretixdroid 1.9 or newer.', verbose_name='Include pending orders'),
),
]

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.10 on 2018-02-28 21:02
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0082_auto_20180222_0938'),
]
operations = [
migrations.AddField(
model_name='taxrule',
name='custom_rules',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='orderfee',
name='fee_type',
field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('other', 'Other fees')], max_length=100),
),
]

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-03-03 16:41
from __future__ import unicode_literals
from django.db import migrations, models
def set_position(apps, schema_editor):
Question = apps.get_model('pretixbase', 'Question')
for q in Question.objects.all():
for i, option in enumerate(q.options.all()):
option.position = i
option.save()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0083_auto_20180228_2102'),
]
operations = [
migrations.AlterModelOptions(
name='questionoption',
options={'ordering': ('position', 'id'), 'verbose_name': 'Question option', 'verbose_name_plural': 'Question options'},
),
migrations.AddField(
model_name='questionoption',
name='position',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='question',
name='position',
field=models.PositiveIntegerField(default=0, verbose_name='Position'),
),
migrations.RunPython(
set_position,
reverse_code=migrations.RunPython.noop,
),
]

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

@@ -36,7 +36,7 @@ def cached_file_delete(sender, instance, **kwargs):
class LoggingMixin:
def log_action(self, action, data=None, user=None, api_token=None):
def log_action(self, action, data=None, user=None, api_token=None, save=True):
"""
Create a LogEntry object that is related to this object.
See the LogEntry documentation for details.
@@ -60,10 +60,12 @@ class LoggingMixin:
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, api_token=api_token)
if data:
logentry.data = json.dumps(data, cls=CustomJSONEncoder)
logentry.save()
if save:
logentry.save()
if action in get_all_notification_types():
notify.apply_async(args=(logentry.pk,))
if action in get_all_notification_types():
notify.apply_async(args=(logentry.pk,))
return logentry
class LoggedModel(models.Model, LoggingMixin):

View File

@@ -14,6 +14,11 @@ class CheckinList(LoggedModel):
limit_products = models.ManyToManyField('Item', verbose_name=_("Limit to products"), blank=True)
subevent = models.ForeignKey('SubEvent', null=True, blank=True,
verbose_name=pgettext_lazy('subevent', 'Date'))
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
default=False,
help_text=_('With this option, people will be able to check in even if the '
'order have not been paid. This only works with pretixdesk '
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
@staticmethod
def annotate_with_numbers(qs, event):
@@ -29,7 +34,7 @@ class CheckinList(LoggedModel):
# position and to the list in question. Then, we check that it also belongs to the
# correct subevent (just to be sure) and aggregate over lists (so, over everything,
# since we filtered by lists).
cqs = Checkin.objects.filter(
cqs_paid = Checkin.objects.filter(
position__order__event=event,
position__order__status=Order.STATUS_PAID,
list=OuterRef('pk')
@@ -41,12 +46,24 @@ class CheckinList(LoggedModel):
).order_by().values('list').annotate(
c=Count('*')
).values('c')
cqs_paid_and_pending = Checkin.objects.filter(
position__order__event=event,
position__order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
list=OuterRef('pk')
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(position__subevent=OuterRef('subevent'))
| (Q(position__subevent__isnull=True))
).order_by().values('list').annotate(
c=Count('*')
).values('c')
# Now for the hard part: getting all order positions that contribute to this list. This
# requires us to use TWO subqueries. The first one, pqs_all, will only be used for check-in
# lists that contain all the products of the event. This is the simpler one, it basically
# looks like the check-in counter above.
pqs_all = OrderPosition.objects.filter(
pqs_all_paid = OrderPosition.objects.filter(
order__event=event,
order__status=Order.STATUS_PAID,
).filter(
@@ -57,13 +74,24 @@ class CheckinList(LoggedModel):
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
pqs_all_paid_and_pending = OrderPosition.objects.filter(
order__event=event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING]
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(subevent=OuterRef('subevent'))
| (Q(subevent__isnull=True))
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
# Now we need a subquery for the case of checkin lists that are limited to certain
# products. We cannot use OuterRef("limit_products") since that would do a cross-product
# with the products table and we'd get duplicate rows in the output with different annotations
# on them, which isn't useful at all. Therefore, we need to add a second layer of subqueries
# to retrieve all of those items and then check if the item_id is IN this subquery result.
pqs_limited = OrderPosition.objects.filter(
pqs_limited_paid = OrderPosition.objects.filter(
order__event=event,
order__status=Order.STATUS_PAID,
item_id__in=Subquery(Item.objects.filter(checkinlist__pk=OuterRef(OuterRef('pk'))).values('pk'))
@@ -75,17 +103,44 @@ class CheckinList(LoggedModel):
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
pqs_limited_paid_and_pending = OrderPosition.objects.filter(
order__event=event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
item_id__in=Subquery(Item.objects.filter(checkinlist__pk=OuterRef(OuterRef('pk'))).values('pk'))
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(subevent=OuterRef('subevent'))
| (Q(subevent__isnull=True))
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
# Finally, we put all of this together. We force empty subquery aggregates to 0 by using Coalesce()
# and decide which subquery to use for this row. In the end, we compute an integer percentage in case
# we want to display a progress bar.
return qs.annotate(
checkin_count=Coalesce(Subquery(cqs, output_field=models.IntegerField()), 0),
position_count=Coalesce(Case(
When(all_products=True, then=Subquery(pqs_all, output_field=models.IntegerField())),
default=Subquery(pqs_limited, output_field=models.IntegerField()),
output_field=models.IntegerField()
), 0)
checkin_count=Coalesce(
Case(
When(include_pending=True, then=Subquery(cqs_paid_and_pending, output_field=models.IntegerField())),
default=Subquery(cqs_paid, output_field=models.IntegerField()),
output_field=models.IntegerField()
),
0
),
position_count=Coalesce(
Case(
When(all_products=True, include_pending=False,
then=Subquery(pqs_all_paid, output_field=models.IntegerField())),
When(all_products=True, include_pending=True,
then=Subquery(pqs_all_paid_and_pending, output_field=models.IntegerField())),
When(all_products=False, include_pending=False,
then=Subquery(pqs_limited_paid, output_field=models.IntegerField())),
default=Subquery(pqs_limited_paid_and_pending, output_field=models.IntegerField()),
output_field=models.IntegerField()
),
0
)
).annotate(
percent=Case(
When(position_count__gt=0, then=F('checkin_count') * 100 / F('position_count')),

View File

@@ -43,7 +43,7 @@ class EventMixin:
Returns a shorter formatted string containing the start date of the event with respect
to the current locale and to the ``show_times`` setting.
"""
tz = tz or pytz.timezone(self.settings.timezone)
tz = tz or self.timezone
return _date(
self.date_from.astimezone(tz),
"SHORT_DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT"
@@ -55,7 +55,7 @@ class EventMixin:
to the current locale and to the ``show_times`` setting. Returns an empty string
if ``show_date_to`` is ``False``.
"""
tz = tz or pytz.timezone(self.settings.timezone)
tz = tz or self.timezone
if not self.settings.show_date_to or not self.date_to:
return ""
return _date(
@@ -68,7 +68,7 @@ class EventMixin:
Returns a formatted string containing the start date of the event with respect
to the current locale and to the ``show_times`` setting.
"""
tz = tz or pytz.timezone(self.settings.timezone)
tz = tz or self.timezone
return _date(
self.date_from.astimezone(tz),
"DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT"
@@ -79,7 +79,7 @@ class EventMixin:
Returns a formatted string containing the start time of the event, ignoring
the ``show_times`` setting.
"""
tz = tz or pytz.timezone(self.settings.timezone)
tz = tz or self.timezone
return _date(
self.date_from.astimezone(tz), "TIME_FORMAT"
)
@@ -90,7 +90,7 @@ class EventMixin:
to the current locale and to the ``show_times`` setting. Returns an empty string
if ``show_date_to`` is ``False``.
"""
tz = tz or pytz.timezone(self.settings.timezone)
tz = tz or self.timezone
if not self.settings.show_date_to or not self.date_to:
return ""
return _date(
@@ -100,23 +100,30 @@ class EventMixin:
def get_date_range_display(self, tz=None) -> str:
"""
Returns a formatted string containing the start date and the event date
Returns a formatted string containing the start date and the end date
of the event with respect to the current locale and to the ``show_times`` and
``show_date_to`` settings.
"""
tz = tz or pytz.timezone(self.settings.timezone)
tz = tz or self.timezone
if not self.settings.show_date_to or not self.date_to:
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
@property
def timezone(self):
return pytz.timezone(self.settings.timezone)
@property
def presale_has_ended(self):
"""
Is true, when ``presale_end`` is set and in the past.
"""
if self.presale_end and now() > self.presale_end:
return True
return False
if self.presale_end:
return now() > self.presale_end
elif self.date_to:
return now() > self.date_to
else:
return now().astimezone(self.timezone).date() > self.date_from.astimezone(self.timezone).date()
@property
def presale_is_running(self):
@@ -126,9 +133,7 @@ class EventMixin:
"""
if self.presale_start and now() < self.presale_start:
return False
if self.presale_end and now() > self.presale_end:
return False
return True
return not self.presale_has_ended
@property
def event_microdata(self):
@@ -229,7 +234,8 @@ class Event(EventMixin, LoggedModel):
presale_end = models.DateTimeField(
null=True, blank=True,
verbose_name=_("End of presale"),
help_text=_("Optional. No products will be sold after this date."),
help_text=_("Optional. No products will be sold after this date. If you do not set this value, the presale "
"will end after the end date of your event."),
)
presale_start = models.DateTimeField(
null=True, blank=True,
@@ -262,6 +268,13 @@ class Event(EventMixin, LoggedModel):
def __str__(self):
return str(self.name)
@property
def presale_has_ended(self):
if self.has_subevents:
return self.presale_end and now() > self.presale_end
else:
return super().presale_has_ended
def save(self, *args, **kwargs):
obj = super().save(*args, **kwargs)
self.cache.clear()
@@ -544,6 +557,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):
"""
@@ -583,7 +599,8 @@ class SubEvent(EventMixin, LoggedModel):
presale_end = models.DateTimeField(
null=True, blank=True,
verbose_name=_("End of presale"),
help_text=_("Optional. No products will be sold after this date."),
help_text=_("Optional. No products will be sold after this date. If you do not set this value, the presale "
"will end after the end date of your event."),
)
presale_start = models.DateTimeField(
null=True, blank=True,
@@ -639,6 +656,13 @@ class SubEvent(EventMixin, LoggedModel):
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
return data
@property
def currency(self):
return self.event.currency
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()
@@ -80,6 +83,7 @@ class Invoice(models.Model):
foreign_currency_display = models.CharField(max_length=50, null=True, blank=True)
foreign_currency_rate = models.DecimalField(decimal_places=4, max_digits=10, null=True, blank=True)
foreign_currency_rate_date = models.DateField(null=True, blank=True)
file = models.FileField(null=True, blank=True, upload_to=invoice_filename)
internal_reference = models.TextField(blank=True)
@@ -122,6 +126,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(
@@ -600,8 +682,15 @@ class Question(LoggedModel):
blank=True,
help_text=_('This question will be asked to buyers of the selected products')
)
position = models.IntegerField(
default=0
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
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:
@@ -629,14 +718,78 @@ 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')
answer = I18nCharField(verbose_name=_('Answer'))
position = models.IntegerField(default=0)
def __str__(self):
return str(self.answer)
class Meta:
verbose_name = _("Question option")
verbose_name_plural = _("Question options")
ordering = ('position', 'id')
class Quota(LoggedModel):
"""
@@ -653,7 +806,7 @@ class Quota(LoggedModel):
Please read the documentation section on quotas carefully before doing
anything with quotas. This might confuse you otherwise.
http://docs.pretix.eu/en/latest/development/concepts.html#restriction-by-number
https://docs.pretix.eu/en/latest/development/concepts.html#quotas
The AVAILABILITY_* constants represent various states of a quota allowing
its items/variations to be up for sale.

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 _
@@ -160,6 +162,13 @@ class Order(LoggedModel):
help_text=_("The text entered in this field will not be visible to the user and is available for your "
"convenience.")
)
checkin_attention = models.BooleanField(
verbose_name=_('Requires special attention'),
default=False,
help_text=_('If you set this, the check-in app will show a visible warning that tickets of this order require '
'special attention. This will not show any details or custom message, so you need to brief your '
'check-in staff how to handle these cases.')
)
expiry_reminder_sent = models.BooleanField(
default=False
)
@@ -211,6 +220,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 +285,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):
@@ -387,6 +402,9 @@ class Order(LoggedModel):
"""
from pretix.base.services.mail import SendMailException, mail, render_mail
if not self.email:
return
with language(self.locale):
recipient = self.email
try:
@@ -498,6 +516,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 +624,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 +637,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]
@@ -623,10 +668,12 @@ class OrderFee(models.Model):
"""
FEE_TYPE_PAYMENT = "payment"
FEE_TYPE_SHIPPING = "shipping"
FEE_TYPE_SERVICE = "service"
FEE_TYPE_OTHER = "other"
FEE_TYPES = (
(FEE_TYPE_PAYMENT, _("Payment fee")),
(FEE_TYPE_SHIPPING, _("Shipping fee")),
(FEE_TYPE_SERVICE, _("Service fee")),
(FEE_TYPE_OTHER, _("Other fees")),
)
@@ -824,7 +871,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

@@ -1,3 +1,4 @@
import json
from decimal import Decimal
from django.db import models
@@ -8,6 +9,7 @@ from i18nfield.fields import I18nCharField
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
from pretix.base.templatetags.money import money_filter
class TaxedPrice:
@@ -23,6 +25,13 @@ class TaxedPrice:
def __repr__(self):
return '{} + {}% = {}'.format(localize(self.net), localize(self.rate), localize(self.gross))
def print(self, currency):
return '{} + {}% = {}'.format(
money_filter(self.net, currency),
localize(self.rate),
money_filter(self.gross, currency)
)
TAXED_ZERO = TaxedPrice(
gross=Decimal('0.00'),
@@ -80,6 +89,7 @@ class TaxRule(LoggedModel):
help_text=_('Your country of residence. This is the country the EU reverse charge rule will not apply in, '
'if configured above.'),
)
custom_rules = models.TextField(blank=True, null=True)
def allow_delete(self):
from pretix.base.models.orders import OrderFee, OrderPosition
@@ -129,10 +139,12 @@ class TaxRule(LoggedModel):
if base_price_is == 'gross':
gross = base_price
net = gross - round_decimal(base_price * (1 - 100 / (100 + self.rate)))
net = round_decimal(gross - (base_price * (1 - 100 / (100 + self.rate))),
self.event.currency if self.event else None)
elif base_price_is == 'net':
net = base_price
gross = round_decimal(net * (1 + self.rate / 100))
gross = round_decimal((net * (1 + self.rate / 100)),
self.event.currency if self.event else None)
else:
raise ValueError('Unknown base price type: {}'.format(base_price_is))
@@ -141,7 +153,27 @@ class TaxRule(LoggedModel):
rate=self.rate, name=self.name
)
def get_matching_rule(self, invoice_address):
rules = json.loads(self.custom_rules)
for r in rules:
if r['country'] == 'EU' and str(invoice_address.country) not in EU_COUNTRIES:
continue
if r['country'] not in ('ZZ', 'EU') and r['country'] != str(invoice_address.country):
continue
if r['address_type'] == 'individual' and invoice_address.is_business:
continue
if r['address_type'] in ('business', 'business_vat_id') and not invoice_address.is_business:
continue
if r['address_type'] == 'business_vat_id' and (not invoice_address.vat_id or not invoice_address.vat_id_validated):
continue
return r
return {'action': 'vat'}
def is_reverse_charge(self, invoice_address):
if self.custom_rules:
rule = self.get_matching_rule(invoice_address)
return rule['action'] == 'reverse'
if not self.eu_reverse_charge:
return False
@@ -160,6 +192,10 @@ class TaxRule(LoggedModel):
return False
def tax_applicable(self, invoice_address):
if self.custom_rules:
rule = self.get_matching_rule(invoice_address)
return rule.get('action', 'vat') == 'vat'
if not self.eu_reverse_charge:
# No reverse charge rules? Always apply VAT!
return True

View File

@@ -1,4 +1,4 @@
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -42,7 +42,7 @@ class Voucher(LoggedModel):
:param max_usages: The number of times this voucher can be redeemed
:type max_usages: int
:param redeemed: The number of times this voucher already has been redeemed
:type redeemed: bool
:type redeemed: int
:param valid_until: The expiration date of this voucher (optional)
:type valid_until: datetime
:param block_quota: If set to true, this voucher will reserve quota for its holder
@@ -368,9 +368,15 @@ class Voucher(LoggedModel):
"""
if self.value is not None:
if self.price_mode == 'set':
return self.value
p = self.value
elif self.price_mode == 'subtract':
return max(original_price - self.value, Decimal('0.00'))
p = max(original_price - self.value, Decimal('0.00'))
elif self.price_mode == 'percent':
return round_decimal(original_price * (Decimal('100.00') - self.value) / Decimal('100.00'))
p = round_decimal(original_price * (Decimal('100.00') - self.value) / Decimal('100.00'))
else:
p = original_price
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
if places < 2:
return p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
return p
return original_price

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

@@ -2,11 +2,12 @@ import logging
from collections import OrderedDict, namedtuple
from django.dispatch import receiver
from django.utils.formats import date_format, localize
from django.utils.formats import date_format
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Event, LogEntry
from pretix.base.signals import register_notification_types
from pretix.base.templatetags.money import money_filter
from pretix.helpers.urls import build_absolute_uri
logger = logging.getLogger(__name__)
@@ -174,7 +175,7 @@ class ParametrizedOrderNotificationType(NotificationType):
url=order_url
)
n.add_attribute(_('Order code'), order.code)
n.add_attribute(_('Order total'), '{} {}'.format(localize(order.total), logentry.event.currency))
n.add_attribute(_('Order total'), money_filter(order.total, logentry.event.currency))
n.add_attribute(_('Order date'), date_format(order.datetime, 'SHORT_DATETIME_FORMAT'))
n.add_attribute(_('Order status'), order.get_status_display())
n.add_attribute(_('Order positions'), str(order.positions.count()))

View File

@@ -1,10 +1,11 @@
import logging
from collections import OrderedDict
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Union
import pytz
from django import forms
from django.conf import settings
from django.contrib import messages
from django.dispatch import receiver
from django.forms import Form
@@ -15,11 +16,11 @@ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea
from i18nfield.strings import LazyI18nString
from pretix.base.decimal import round_decimal
from pretix.base.models import CartPosition, Event, Order, Quota
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
from pretix.base.settings import SettingsSandbox
from pretix.base.signals import register_payment_providers
from pretix.helpers.money import DecimalTextInput
from pretix.presale.views import get_cart_total
from pretix.presale.views.cart import get_or_create_cart_id
@@ -50,6 +51,16 @@ class BasePaymentProvider:
def __str__(self):
return self.identifier
@property
def is_implicit(self) -> bool:
"""
Returns whether or whether not this payment provider is an "implicit" payment provider that will
*always* and unconditionally be used if is_allowed() returns True and does not require any input.
This is intended to be used by the FreePaymentProvider, which skips the payment choice page.
By default, this returns ``False``. Please do not set this if you don't know exactly what you are doing.
"""
return False
@property
def is_meta(self) -> bool:
"""
@@ -81,10 +92,15 @@ class BasePaymentProvider:
fee_abs = self.settings.get('_fee_abs', as_type=Decimal, default=0)
fee_percent = self.settings.get('_fee_percent', as_type=Decimal, default=0)
fee_reverse_calc = self.settings.get('_fee_reverse_calc', as_type=bool, default=True)
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
if fee_reverse_calc:
return round_decimal((price + fee_abs) * (1 / (1 - fee_percent / 100)) - price)
return ((price + fee_abs) * (1 / (1 - fee_percent / 100)) - price).quantize(
Decimal('1') / 10 ** places, ROUND_HALF_UP
)
else:
return round_decimal(price * fee_percent / 100) + fee_abs
return (price * fee_percent / 100 + fee_abs).quantize(
Decimal('1') / 10 ** places, ROUND_HALF_UP
)
@property
def verbose_name(self) -> str:
@@ -146,6 +162,7 @@ class BasePaymentProvider:
.. WARNING:: It is highly discouraged to alter the ``_enabled`` field of the default
implementation.
"""
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
return OrderedDict([
('_enabled',
forms.BooleanField(
@@ -156,7 +173,10 @@ class BasePaymentProvider:
forms.DecimalField(
label=_('Additional fee'),
help_text=_('Absolute value'),
required=False
localize=True,
required=False,
decimal_places=places,
widget=DecimalTextInput(places=places)
)),
('_fee_percent',
forms.DecimalField(
@@ -164,7 +184,8 @@ class BasePaymentProvider:
help_text=_('Percentage of the order total. Note that this percentage will currently only '
'be calculated on the summed price of sold tickets, not on other fees like e.g. shipping '
'fees, if there are any.'),
required=False
localize=True,
required=False,
)),
('_availability_date',
RelativeDateField(
@@ -552,6 +573,10 @@ class PaymentException(Exception):
class FreeOrderProvider(BasePaymentProvider):
@property
def is_implicit(self) -> bool:
return True
@property
def is_enabled(self) -> bool:
return True

View File

@@ -112,7 +112,7 @@ class CartManager:
def _check_presale_dates(self):
if self.event.presale_start and self.now_dt < self.event.presale_start:
raise CartError(error_messages['not_started'])
if self.event.presale_end and self.now_dt > self.event.presale_end:
if self.event.presale_has_ended:
raise CartError(error_messages['ended'])
def _extend_expiry_of_valid_existing_positions(self):
@@ -188,7 +188,7 @@ class CartManager:
if op.subevent and op.subevent.presale_start and self.now_dt < op.subevent.presale_start:
raise CartError(error_messages['not_started'])
if op.subevent and op.subevent.presale_end and self.now_dt > op.subevent.presale_end:
if op.subevent and op.subevent.presale_has_ended:
raise CartError(error_messages['ended'])
if isinstance(op, self.AddOperation):
@@ -667,7 +667,8 @@ def get_fees(event, request, total, invoice_address, provider):
tax_rule=payment_fee_tax_rule
))
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address):
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
total=total):
fees += resp
return fees

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

@@ -39,6 +39,7 @@ def notify(logentry_id: int):
notify_global = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event__isnull=True,
action_type=logentry.action_type,
user__pk__in=users.values_list('pk', flat=True)
)

View File

@@ -10,14 +10,14 @@ 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
from django.utils.translation import ugettext as _
from pretix.base.i18n import (
LazyDate, LazyLocaleException, LazyNumber, language,
LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language,
)
from pretix.base.models import (
CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota,
@@ -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()
@@ -289,7 +321,7 @@ class OrderError(LazyLocaleException):
def _check_date(event: Event, now_dt: datetime):
if event.presale_start and now_dt < event.presale_start:
raise OrderError(error_messages['not_started'])
if event.presale_end and now_dt > event.presale_end:
if event.presale_has_ended:
raise OrderError(error_messages['ended'])
@@ -329,7 +361,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
cp.delete()
break
if cp.subevent and cp.subevent.presale_end and now_dt > cp.subevent.presale_end:
if cp.subevent and cp.subevent.presale_has_ended:
err = err or error_messages['some_subevent_ended']
cp.delete()
break
@@ -407,8 +439,8 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
fees.append(OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=payment_provider.identifier))
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address,
meta_info=meta_info, posiitons=positions):
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total,
meta_info=meta_info, positions=positions):
fees += resp
return fees
@@ -472,6 +504,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
for fee in fees:
fee.order = order
fee._calculate_tax()
if not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
fee.save()
OrderPosition.transform_cart_positions(positions, order)
@@ -489,6 +523,9 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
if not pprov:
raise OrderError(error_messages['internal'])
if email == settings.PRETIX_EMAIL_NONE_VALUE:
email = None
addr = None
if address is not None:
try:
@@ -510,44 +547,49 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
invoice = order.invoices.last() # Might be generated by plugin already
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
if not invoice:
invoice = generate_invoice(order, trigger_pdf=not event.settings.invoice_email_attachment)
invoice = generate_invoice(
order,
trigger_pdf=not event.settings.invoice_email_attachment or not order.email
)
# send_mail will trigger PDF generation later
if order.total == Decimal('0.00'):
email_template = event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free'
else:
email_template = event.settings.mail_text_order_placed
log_entry = 'pretix.event.order.email.order_placed'
if order.email:
if order.payment_provider == 'free':
email_template = event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free'
else:
email_template = event.settings.mail_text_order_placed
log_entry = 'pretix.event.order.email.order_placed'
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_context = {
'total': LazyNumber(order.total),
'currency': event.currency,
'date': LazyDate(order.expires),
'event': event.name,
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}),
'payment_info': str(pprov.order_pending_mail_render(order)),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else []
)
except SendMailException:
logger.exception('Order received email could not be sent')
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_context = {
'total': LazyNumber(order.total),
'currency': event.currency,
'total_with_currency': LazyCurrencyNumber(order.total, event.currency),
'date': LazyDate(order.expires),
'event': event.name,
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}),
'payment_info': str(pprov.order_pending_mail_render(order)),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else []
)
except SendMailException:
logger.exception('Order received email could not be sent')
return order.id
@@ -562,9 +604,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 +686,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 +720,7 @@ class OrderChangeManager:
self.order = order
self.user = user
self.split_order = None
self._committed = False
self._totaldiff = 0
self._quotadiff = Counter()
self._operations = []
@@ -774,7 +815,7 @@ class OrderChangeManager:
if price is None:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
else:
if item.tax_rule.tax_applicable(self._invoice_address):
if item.tax_rule and item.tax_rule.tax_applicable(self._invoice_address):
price = item.tax(price, base_price_is='gross')
else:
price = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
@@ -824,7 +865,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 +1059,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 +1116,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 +1141,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,5 +1,6 @@
from decimal import Decimal
from pretix.base.decimal import round_decimal
from pretix.base.models import (
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Voucher,
)
@@ -59,4 +60,8 @@ def get_price(item: Item, variation: ItemVariation = None,
price.gross = price.net
price.name = ''
price.gross = round_decimal(price.gross, item.event.currency)
price.net = round_decimal(price.net, item.event.currency)
price.tax = price.gross - price.net
return price

View File

@@ -1,3 +1,5 @@
import sys
from django.dispatch import receiver
from pretix.base.models import Event, User, WaitingListEntry
@@ -33,6 +35,10 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
if (wle.item, wle.variation) in gone:
continue
ev = (wle.subevent or event)
if not ev.presale_is_running or (wle.subevent and not wle.subevent.active):
continue
quotas = (wle.variation.quotas.filter(subevent=wle.subevent)
if wle.variation
else wle.item.quotas.filter(subevent=wle.subevent))
@@ -41,7 +47,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 +58,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))
@@ -62,7 +68,9 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
@receiver(signal=periodic_task)
def process_waitinglist(sender, **kwargs):
qs = Event.objects.prefetch_related('_settings_objects', 'organizer___settings_objects').select_related('organizer')
qs = Event.objects.filter(
live=True
).prefetch_related('_settings_objects', 'organizer___settings_objects').select_related('organizer')
for e in qs:
if e.settings.waiting_list_enabled and e.settings.waiting_list_auto:
if e.settings.waiting_list_enabled and e.settings.waiting_list_auto and e.presale_is_running:
assign_automatically.apply_async(args=(e.pk,))

View File

@@ -276,7 +276,7 @@ Your {event} team"""))
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
we successfully received your order for {event} with a total value
of {total} {currency}. Please complete your payment before {date}.
of {total_with_currency}. Please complete your payment before {date}.
{payment_info}

View File

@@ -291,7 +291,7 @@ an OrderedDict of (setting name, form field).
"""
order_fee_calculation = EventPluginSignal(
providing_args=['request']
providing_args=['positions', 'invoice_address', 'meta_info', 'total']
)
"""
This signals allows you to add fees to an order while it is being created. You are expected to
@@ -300,7 +300,9 @@ return a list of ``OrderFee`` objects that are not yet saved to the database
As with all plugin signals, the ``sender`` keyword argument will contain the event. A ``positions``
argument will contain the cart positions and ``invoice_address`` the invoice address (useful for
tax calculation). The argument ``meta_info`` contains the order's meta dictionary.
tax calculation). The argument ``meta_info`` contains the order's meta dictionary. The ``total``
keyword argument will contain the total cart sum without any fees. You should not rely on this
``total`` value for fee calculations as other fees might interfere.
"""
order_fee_type_name = EventPluginSignal(

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,55 @@
from decimal import ROUND_HALF_UP, Decimal
from babel.numbers import format_currency
from django import template
from django.conf import settings
from django.template.defaultfilters import floatformat
from django.utils import translation
register = template.Library()
@register.filter("money")
def money_filter(value: Decimal, arg='', hide_currency=False):
if isinstance(value, float) or isinstance(value, int):
value = Decimal(value)
if not isinstance(value, Decimal):
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
if not arg:
raise ValueError("No currency passed.")
places = settings.CURRENCY_PLACES.get(arg, 2)
rounded = value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
if places < 2 and rounded != value:
places = 2
if hide_currency:
return floatformat(value, places)
try:
if rounded != value:
# We display decimal places even if we shouldn't for this currency if rounding
# would make the numbers incorrect. If this branch executes, it's likely a bug in
# pretix, but we won't show wrong numbers!
return '{} {}'.format(
arg,
floatformat(value, 2)
)
return format_currency(value, arg, locale=translation.get_language())
except:
return '{} {}'.format(
arg,
floatformat(value, places)
)
@register.filter("money_numberfield")
def money_numberfield_filter(value: Decimal, arg=''):
if isinstance(value, float) or isinstance(value, int):
value = Decimal(value)
if not isinstance(value, Decimal):
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
if not arg:
raise ValueError("No currency passed.")
places = settings.CURRENCY_PLACES.get(arg, 2)
return str(value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP))

View File

@@ -1,3 +1,4 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.deconstruct import deconstructible
from django.utils.translation import ugettext_lazy as _
@@ -11,7 +12,7 @@ class BlacklistValidator:
# Validation logic
if value in self.blacklist:
raise ValidationError(
_('This slug has an invalid value: %(value)s.'),
_('This field has an invalid value: %(value)s.'),
code='invalid',
params={'value': value},
)
@@ -56,3 +57,11 @@ class OrganizerSlugBlacklistValidator(BlacklistValidator):
'csp_report',
'widget',
]
@deconstructible
class EmailBlacklistValidator(BlacklistValidator):
blacklist = [
settings.PRETIX_EMAIL_NONE_VALUE,
]

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']
@@ -22,7 +36,8 @@ class CheckinListForm(forms.ModelForm):
'name',
'all_products',
'limit_products',
'subevent'
'subevent',
'include_pending'
]
widgets = {
'limit_products': forms.CheckboxSelectMultiple(attrs={

View File

@@ -1,10 +1,14 @@
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
from django.forms import formset_factory
from django.utils.timezone import get_current_timezone_name
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries import Countries
from django_countries.fields import LazyTypedChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea
from pytz import common_timezones, timezone
@@ -665,10 +669,10 @@ class MailSettingsForm(SettingsForm):
label=_("Text"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {total}, {currency}, {date}, {payment_info}, {url}, "
"{invoice_name}, {invoice_company}"),
validators=[PlaceholderValidator(['{event}', '{total}', '{currency}', '{date}', '{payment_info}',
'{url}', '{invoice_name}', '{invoice_company}'])]
help_text=_("Available placeholders: {event}, {total_with_currency}, {total}, {currency}, {date}, "
"{payment_info}, {url}, {invoice_name}, {invoice_company}"),
validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}',
'{payment_info}', '{url}', '{invoice_name}', '{invoice_company}'])]
)
mail_text_order_paid = I18nFormField(
label=_("Text"),
@@ -906,6 +910,43 @@ class CommentForm(I18nModelForm):
}
class CountriesAndEU(Countries):
override = {
'ZZ': _('Any country'),
'EU': _('European Union')
}
first = ['ZZ', 'EU']
class TaxRuleLineForm(forms.Form):
country = LazyTypedChoiceField(
choices=CountriesAndEU(),
required=False
)
address_type = forms.ChoiceField(
choices=[
('', _('Any customer')),
('individual', _('Individual')),
('business', _('Business')),
('business_vat_id', _('Business with valid VAT ID')),
],
required=False
)
action = forms.ChoiceField(
choices=[
('vat', _('Charge VAT')),
('reverse', _('Reverse charge')),
('no', _('No VAT')),
],
)
TaxRuleLineFormSet = formset_factory(
TaxRuleLineForm,
can_order=False, can_delete=True, extra=0
)
class TaxRuleForm(I18nModelForm):
class Meta:
model = TaxRule
@@ -951,3 +992,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,200 @@ 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')),
('u', _('Unredeemed')),
('r', _('Redeemed at least once')),
('f', _('Fully redeemed')),
('e', _('Expired')),
('c', _('Redeemed and checked in with ticket')),
),
required=False
)
qm = forms.ChoiceField(
label=_('Quota handling'),
choices=(
('', _('All')),
('b', _('Reserve ticket from quota')),
('i', _('Allow to ignore quota')),
),
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')
)
itemvar = forms.ChoiceField(
label=_("Product"),
required=False
)
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']
choices = [('', _('All products'))]
for i in self.event.items.prefetch_related('variations').all():
variations = list(i.variations.all())
if variations:
choices.append((str(i.pk), _('{product} Any variation').format(product=i.name)))
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (i.name, v.value)))
else:
choices.append((str(i.pk), i.name))
for q in self.event.quotas.all():
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
self.fields['itemvar'].choices = choices
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('qm'):
s = fdata.get('qm')
if s == 'b':
qs = qs.filter(block_quota=True)
elif s == 'i':
qs = qs.filter(allow_ignore_quota=True)
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__lt=F('max_usages'))
elif s == 'r':
qs = qs.filter(redeemed__gt=0)
elif s == 'u':
qs = qs.filter(redeemed=0)
elif s == 'f':
qs = qs.filter(redeemed__gte=F('max_usages'))
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('itemvar'):
if fdata.get('itemvar').startswith('q-'):
qs = qs.filter(quota_id=fdata.get('itemvar').split('-')[1])
elif '-' in fdata.get('itemvar'):
qs = qs.filter(item_id=fdata.get('itemvar').split('-')[0],
variation_id=fdata.get('itemvar').split('-')[1])
else:
qs = qs.filter(item_id=fdata.get('itemvar'))
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,8 @@ 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
from pretix.helpers.money import change_decimal_field
class CategoryForm(I18nModelForm):
@@ -45,6 +50,7 @@ class QuestionForm(I18nModelForm):
'help_text',
'type',
'required',
'ask_during_checkin',
'items'
]
widgets = {
@@ -94,6 +100,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']
@@ -142,6 +160,7 @@ class ItemCreateForm(I18nModelForm):
self.fields['category'].queryset = self.instance.event.categories.all()
self.fields['tax_rule'].queryset = self.instance.event.tax_rules.all()
change_decimal_field(self.fields['default_price'], self.instance.event.currency)
self.fields['tax_rule'].empty_label = _('No taxation')
self.fields['copy_from'] = forms.ModelChoiceField(
label=_("Copy product information"),
@@ -275,6 +294,7 @@ class ItemUpdateForm(I18nModelForm):
'over 65. This ticket includes access to all parts of the event, except the VIP '
'area.'
)
change_decimal_field(self.fields['default_price'], self.event.currency)
class Meta:
model = Item
@@ -328,8 +348,29 @@ class ItemVariationsFormSet(I18nFormSet):
return False
return form.cleaned_data.get(DELETION_FIELD_NAME, False)
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
return super()._construct_form(i, **kwargs)
@property
def empty_form(self):
self.is_valid()
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
locales=self.locales,
event=self.event
)
self.add_fields(form, None)
return form
class ItemVariationForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['default_price'], self.event.currency)
class Meta:
model = ItemVariation
localized_fields = '__all__'
@@ -382,7 +423,6 @@ class ItemAddOnsFormSet(I18nFormSet):
class ItemAddOnForm(I18nModelForm):
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.fields['addon_category'].queryset = self.event.categories.all()

View File

@@ -2,7 +2,6 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.formats import localize
from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
@@ -12,6 +11,7 @@ from pretix.base.models import (
)
from pretix.base.models.event import SubEvent
from pretix.base.services.pricing import get_price
from pretix.helpers.money import change_decimal_field
class ExtendForm(I18nModelForm):
@@ -62,7 +62,7 @@ class ExporterForm(forms.Form):
class CommentForm(I18nModelForm):
class Meta:
model = Order
fields = ['comment']
fields = ['comment', 'checkin_attention']
widgets = {
'comment': forms.Textarea(attrs={
'rows': 3,
@@ -76,8 +76,8 @@ class SubEventChoiceField(forms.ModelChoiceField):
p = get_price(self.instance.item, self.instance.variation,
voucher=self.instance.voucher,
subevent=obj)
return '{} {} ({} {})'.format(obj.name, obj.get_date_range_display(),
p, self.instance.order.event.currency)
return '{} {} ({})'.format(obj.name, obj.get_date_range_display(),
p.print(self.instance.order.event.currency))
class OtherOperationsForm(forms.Form):
@@ -120,6 +120,7 @@ class OrderPositionAddForm(forms.Form):
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
localize=True,
label=_('Gross price'),
help_text=_("Including taxes, if any. Keep empty for the product's default price")
)
@@ -149,10 +150,10 @@ class OrderPositionAddForm(forms.Form):
for v in variations:
p = get_price(i, v, invoice_address=ia)
choices.append(('%d-%d' % (i.pk, v.pk),
'%s %s (%s %s)' % (pname, v.value, p, order.event.currency)))
'%s %s (%s)' % (pname, v.value, p.print(order.event.currency))))
else:
p = get_price(i, invoice_address=ia)
choices.append((str(i.pk), '%s (%s %s)' % (pname, p, order.event.currency)))
choices.append((str(i.pk), '%s (%s)' % (pname, p.print(order.event.currency))))
self.fields['itemvar'].choices = choices
if ItemAddOn.objects.filter(base_item__event=order.event).exists():
self.fields['addon_to'].queryset = order.positions.filter(addon_to__isnull=True).select_related(
@@ -165,6 +166,7 @@ class OrderPositionAddForm(forms.Form):
self.fields['subevent'].queryset = order.event.subevents.all()
else:
del self.fields['subevent']
change_decimal_field(self.fields['price'], order.event.currency)
class OrderPositionChangeForm(forms.Form):
@@ -178,6 +180,7 @@ class OrderPositionChangeForm(forms.Form):
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
localize=True,
label=_('New price (gross)')
)
operation = forms.ChoiceField(
@@ -236,14 +239,13 @@ class OrderPositionChangeForm(forms.Form):
p = get_price(i, v, voucher=instance.voucher, subevent=instance.subevent,
invoice_address=ia)
choices.append(('%d-%d' % (i.pk, v.pk),
'%s %s (%s %s)' % (pname, v.value, localize(p),
instance.order.event.currency)))
'%s %s (%s)' % (pname, v.value, p.print(instance.order.event.currency))))
else:
p = get_price(i, None, voucher=instance.voucher, subevent=instance.subevent,
invoice_address=ia)
choices.append((str(i.pk), '%s (%s %s)' % (pname, localize(p),
instance.order.event.currency)))
choices.append((str(i.pk), '%s (%s)' % (pname, p.print(instance.order.event.currency))))
self.fields['itemvar'].choices = choices
change_decimal_field(self.fields['price'], instance.order.event.currency)
def clean(self):
if self.cleaned_data.get('operation') == 'price' and not self.cleaned_data.get('price', '') != '':

View File

@@ -5,7 +5,9 @@ from i18nfield.forms import I18nInlineFormSet
from pretix.base.forms import I18nModelForm
from pretix.base.models.event import SubEvent, SubEventMetaValue
from pretix.base.models.items import SubEventItem
from pretix.base.templatetags.money import money_filter
from pretix.control.forms import SplitDateTimePickerWidget
from pretix.helpers.money import change_decimal_field
class SubEventForm(I18nModelForm):
@@ -49,32 +51,35 @@ class SubEventItemOrVariationFormMixin:
self.item = kwargs.pop('item')
self.variation = kwargs.pop('variation', None)
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['price'], self.item.event.currency)
class SubEventItemForm(SubEventItemOrVariationFormMixin, forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['price'].widget.attrs['placeholder'] = '{} {}'.format(
self.item.default_price, self.item.event.currency
)
self.fields['price'].widget.attrs['placeholder'] = money_filter(self.item.default_price, self.item.event.currency, hide_currency=True)
self.fields['price'].label = str(self.item.name)
class Meta:
model = SubEventItem
fields = ['price']
widgets = {
'price': forms.TextInput
}
class SubEventItemVariationForm(SubEventItemOrVariationFormMixin, forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['price'].widget.attrs['placeholder'] = '{} {}'.format(
self.variation.price, self.item.event.currency
)
self.fields['price'].widget.attrs['placeholder'] = money_filter(self.variation.price, self.item.event.currency, hide_currency=True)
self.fields['price'].label = '{} {}'.format(str(self.item.name), self.variation.value)
class Meta:
model = SubEventItem
fields = ['price']
widgets = {
'price': forms.TextInput
}
class QuotaFormSet(I18nInlineFormSet):

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

@@ -179,6 +179,6 @@ class VoucherBulkForm(VoucherForm):
data['code'] = code
data['bulk'] = True
del data['codes']
obj.save()
objs.append(obj)
Voucher.objects.bulk_create(objs)
return objs

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

@@ -4,7 +4,6 @@ from decimal import Decimal
import dateutil.parser
import pytz
from django.dispatch import receiver
from django.utils import formats
from django.utils.formats import date_format
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.strings import LazyI18nString
@@ -13,6 +12,7 @@ from pretix.base.models import (
CheckinList, Event, ItemVariation, LogEntry, OrderPosition,
)
from pretix.base.signals import logentry_display
from pretix.base.templatetags.money import money_filter
OVERVIEW_BLACKLIST = [
'pretix.plugins.sendmail.order.email.sent'
@@ -30,42 +30,38 @@ def _display_order_changed(event: Event, logentry: LogEntry):
new_item = str(event.items.get(pk=data['new_item']))
if data['new_variation']:
new_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['new_variation']))
return text + ' ' + _('Position #{posid}: {old_item} ({old_price} {currency}) changed '
'to {new_item} ({new_price} {currency}).').format(
return text + ' ' + _('Position #{posid}: {old_item} ({old_price}) changed '
'to {new_item} ({new_price}).').format(
posid=data.get('positionid', '?'),
old_item=old_item, new_item=new_item,
old_price=formats.localize(Decimal(data['old_price'])),
new_price=formats.localize(Decimal(data['new_price'])),
currency=event.currency
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.subevent':
old_se = str(event.subevents.get(pk=data['old_subevent']))
new_se = str(event.subevents.get(pk=data['new_subevent']))
return text + ' ' + _('Position #{posid}: Event date "{old_event}" ({old_price} {currency}) changed '
'to "{new_event}" ({new_price} {currency}).').format(
return text + ' ' + _('Position #{posid}: Event date "{old_event}" ({old_price}) changed '
'to "{new_event}" ({new_price}).').format(
posid=data.get('positionid', '?'),
old_event=old_se, new_event=new_se,
old_price=formats.localize(Decimal(data['old_price'])),
new_price=formats.localize(Decimal(data['new_price'])),
currency=event.currency
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.price':
return text + ' ' + _('Price of position #{posid} changed from {old_price} {currency} '
'to {new_price} {currency}.').format(
return text + ' ' + _('Price of position #{posid} changed from {old_price} '
'to {new_price}.').format(
posid=data.get('positionid', '?'),
old_price=formats.localize(Decimal(data['old_price'])),
new_price=formats.localize(Decimal(data['new_price'])),
currency=event.currency
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.cancel':
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
return text + ' ' + _('Position #{posid} ({old_item}, {old_price} {currency}) removed.').format(
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) removed.').format(
posid=data.get('positionid', '?'),
old_item=old_item,
old_price=formats.localize(Decimal(data['old_price'])),
currency=event.currency
old_price=money_filter(Decimal(data['old_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.add':
item = str(event.items.get(pk=data['item']))
@@ -73,30 +69,27 @@ def _display_order_changed(event: Event, logentry: LogEntry):
item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['variation']))
if data['addon_to']:
addon_to = OrderPosition.objects.get(order__event=event, pk=data['addon_to'])
return text + ' ' + _('Position #{posid} created: {item} ({price} {currency}) as an add-on to '
return text + ' ' + _('Position #{posid} created: {item} ({price}) as an add-on to '
'position #{addon_to}.').format(
posid=data.get('positionid', '?'),
item=item, addon_to=addon_to.positionid,
price=formats.localize(Decimal(data['price'])),
currency=event.currency
price=money_filter(Decimal(data['price']), event.currency),
)
else:
return text + ' ' + _('Position #{posid} created: {item} ({price} {currency}).').format(
return text + ' ' + _('Position #{posid} created: {item} ({price}).').format(
posid=data.get('positionid', '?'),
item=item,
price=formats.localize(Decimal(data['price'])),
currency=event.currency
price=money_filter(Decimal(data['price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.split':
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
return text + ' ' + _('Position #{posid} ({old_item}, {old_price} {currency}) split into new order: {order}').format(
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) split into new order: {order}').format(
old_item=old_item,
posid=data.get('positionid', '?'),
order=data['new_order'],
old_price=formats.localize(Decimal(data['old_price'])),
currency=event.currency
old_price=money_filter(Decimal(data['old_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.split_from':
return _('This order has been created by splitting the order {order}').format(
@@ -124,9 +117,13 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'),
'pretix.event.order.invoice.reissued': _('The invoice has been reissued.'),
'pretix.event.order.comment': _('The order\'s internal comment has been updated.'),
'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been '
'toggled.'),
'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 +132,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 +278,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

@@ -1,4 +1,3 @@
import time
from urllib.parse import quote, urljoin, urlparse
from django.conf import settings
@@ -11,6 +10,9 @@ from django.utils.encoding import force_str
from django.utils.translation import ugettext as _
from pretix.base.models import Event, Organizer
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
)
class PermissionMiddleware(MiddlewareMixin):
@@ -64,18 +66,15 @@ class PermissionMiddleware(MiddlewareMixin):
if not request.user.is_authenticated:
return self._login_redirect(request)
if not settings.PRETIX_LONG_SESSIONS or not request.session.get('pretix_auth_long_session', False):
try:
# If this logic is updated, make sure to also update the logic in pretix/api/auth/permission.py
last_used = request.session.get('pretix_auth_last_used', time.time())
if time.time() - request.session.get('pretix_auth_login_time', time.time()) > settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE:
logout(request)
request.session['pretix_auth_login_time'] = 0
return self._login_redirect(request)
assert_session_valid(request)
except SessionInvalid:
logout(request)
return self._login_redirect(request)
except SessionReauthRequired:
if url_name != 'user.reauth':
if time.time() - last_used > settings.PRETIX_SESSION_TIMEOUT_RELATIVE:
return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
request.session['pretix_auth_last_used'] = int(time.time())
return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
if 'event' in url.kwargs and 'organizer' in url.kwargs:
request.event = Event.objects.filter(

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

@@ -86,6 +86,9 @@
</td>
<td>
<strong><a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=e.order.code %}">{{ e.order.code }}</a></strong>
{% if e.order.status == "n" %}
<span class="label label-warning">{% trans "unpaid" %}</span>
{% endif %}
</td>
<td>{{ e.item.name }}{% if e.variation %} {{ e.variation }}{% endif %}</td>
<td>{{ e.order.email }}</td>

View File

@@ -23,6 +23,7 @@
{% if form.subevent %}
{% bootstrap_field form.subevent layout="control" %}
{% endif %}
{% bootstrap_field form.include_pending layout="control" %}
<legend>{% trans "Products" %}</legend>
<p>
{% blocktrans trimmed %}

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

@@ -1,5 +1,6 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load formset_tags %}
{% load bootstrap3 %}
{% block title %}
{% if rule %}
@@ -21,7 +22,7 @@
{% bootstrap_field form.rate addon_after="%" layout="control" %}
<legend>{% trans "Advanced settings" %}</legend>
<div class="alert alert-warning">
<span class="fa fa-w fa-legal fa-4x pull-left"></span>
<span class="fa fa-fw fa-legal fa-4x pull-left"></span>
{% blocktrans trimmed with docs="https://docs.pretix.eu/en/latest/user/events/taxes.html" %}
These settings are intended for advanced users. See the <a href="{{ docs }}">documentation</a>
for more information. Note that we are not responsible for the correct handling
@@ -32,6 +33,75 @@
{% bootstrap_field form.price_includes_tax layout="control" %}
{% bootstrap_field form.eu_reverse_charge layout="control" %}
{% bootstrap_field form.home_country layout="control" %}
<legend>{% trans "Custom taxation rules" %}</legend>
<div class="alert alert-warning">
<span class="fa fa-fw fa-exclamation-circle fa-4x pull-left"></span>
{% blocktrans trimmed %}
These settings are intended for professional users with very specific taxation situations.
If you create any rule here, the reverse charge settings above will be ignored. The rules will be
checked in order and once the first rule matches the order, it will be used and all further rules will
be ignored. If no rule matches, tax will be charged.
{% endblocktrans %}
<div class="clearfix"></div>
</div>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
{% bootstrap_form_errors form %}
<div class="row" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-sm-4">
{% bootstrap_field form.country layout='inline' form_group_class="" %}
</div>
<div class="col-sm-3">
{% bootstrap_field form.address_type layout='inline' form_group_class="" %}
</div>
<div class="col-sm-3">
{% bootstrap_field form.action layout='inline' form_group_class="" %}
</div>
<div class="col-sm-2 text-right">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-sm-4">
{% bootstrap_field formset.empty_form.country layout='inline' form_group_class="" %}
</div>
<div class="col-sm-3">
{% bootstrap_field formset.empty_form.address_type layout='inline' form_group_class="" %}
</div>
<div class="col-sm-3">
{% bootstrap_field formset.empty_form.action layout='inline' form_group_class="" %}
</div>
<div class="col-sm-2 text-right">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new rule" %}</button>
</p>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}

Some files were not shown because too many files have changed in this diff Show More