Compare commits

...

122 Commits

Author SHA1 Message Date
Raphael Michel
db67959abf Add a custom manager 2017-05-19 10:31:02 +02:00
Knuth
f29f615aec added a missing string and translation for it 2017-05-18 11:57:36 +02:00
Raphael Michel
f1300badb8 Bump version 2017-05-18 11:17:06 +02:00
Raphael Michel
88a1cddb23 Add release config for GitLab CI 2017-05-18 09:50:51 +02:00
Raphael Michel
d53fbc52d1 Fix #322 -- Do not use ManifestStaticFileStorage during testing 2017-05-17 18:20:13 +02:00
Raphael Michel
ec9511c17a Smaller documentation updates 2017-05-17 18:19:28 +02:00
Raphael Michel
3f2bb3beae Add date to the metadata section 2017-05-17 18:06:19 +02:00
Raphael Michel
c8b96696b0 Update/fix German translation 2017-05-17 18:06:19 +02:00
Raphael Michel
0bb6b53ebc Change client-side TIME_FORMAT for locale en as well 2017-05-17 18:06:19 +02:00
Raphael Michel
7400c2373b Remove debugging output 2017-05-17 18:06:19 +02:00
Knuth
635329f102 added missing spaces in DE and DE_informal 2017-05-17 17:05:49 +02:00
scabux
b5a2123d1c Added configurable text for organizer page (#494) 2017-05-17 15:53:30 +02:00
Raphael Michel
396c558b25 Fix critical bug in item creation 2017-05-17 15:47:35 +02:00
Ian Williams
60e178d821 Fix #443 -- Show private key in 2FA wizard (#490)
* Syncing fork to upstream (#1)

Sync master with master of pretix/pretix@300f8f6

* Automatically sort new products to the end

* Drop "squash your commits" from the dev guide

* Add variation descriptions and allow to order addons

* Link to Django's runserver options in dev docs

* Allow <br> tags in rich text

* Copy from event: deal with deleted items

* Make validate_cart useful together with addons

* Fix collapsing panels in the addon choice step

* Button text change if addons are present

* Update translations

* Squash migrations and bump version

* Ticket PDFs: Do not hide attendee name if code is hidden

* Add a user guide on payments

* Link PayPal and Stripe documentation in the respective forms

* Hide payment fees if they are all equal to 0.00

* Refs #39 -- New concept of "teams" (#478)

* New models

* CRUD UI

* UI for adding/removing team members

* Log display for teams

* Fix invitations, move frontend

* Drop old models (incomplete)

* Drop more old stuff

* Drop even more old stuff

* Fix tests

* Fix permission test

* flake8 fix

* Add tests fore the new code

* Rebase migrations

* Fix typo in method name

* Update translations

* Force ordering of events on dashboard

* Fix typos in events

* Prepare the pretixdroid API for an async mode in the app

* Pretixdroid tests: Ignore microseconds (chopped by mysql)

* pretixdroid API: Add related lookups

* Add idempotenty nonces to pretixdroid API

* pretixdroid: force-accepting unpaids and time display

* Marked webfonts as binary files (#487)

Webfonts now listed as binary in `.gitattributes`.
Works on pretix/pretix#486

* Fix #456 -- Allow products to be excluded from ticket-generation (#483)

* Added non-admission setting to event

`ticket_download_nonadm` now setting in storage. Still need logic for
order page/PDF generation.
Works on pretix/pretix#456.

* Download button considers `ticket_download_nonadm`

Modified Django tags to look at item admission attribute and
`ticket_download_nonadm` setting.
Works on pretix/pretix#456.

* Ticket output for non-admission disabled

PDFs/etc. will only be permitted/generated for items with the
`admission` attribute, or if the `ticket_download_nonadm` event setting
is true. Applies to single and whole-order ticket downloads.
Works on pretix/pretix#456.

* Fixed product exclusion in PDF output

Forgot PDF output was a plugin, now includes same check as base
`BaseTicketOutput.generate_order`.
Works on pretix/pretix#456

* Mail signature (#485)

* added signature field -- no function yet

* added mail signature feature

* fixed style issue

* fixed problem with signature default

* added unit test for mail signatures

* added unit test for mail signatures

* [WIP] Fix #447 -- Sendmail plugin: Create new mail based on an old one (#476)

* send old email content to the new one

* error key event

* test commit

* query bad ID

* query bad ID

* query bad ID

* query bad ID

* Update pretixdroid API version

* Refs #447 -- Extend copying old mails to subject and receipients

* Fixed bugs and added test for date range rendering (#488)

* fixed bug for same dates, added unit check for daterange

* fixed local language override in unit test

* Fix #297 -- pretixdroid: Show metrics in the control panel (#481)

* add checkin status page

add dashboard widget
add checkin page under orders

* modify checkin logic

added new fields in checkin page
added filter items

* add tests for checkins & minor improvement

* support addin_product & noadm setting logic

* remove name ordering check test case

* Fix #379 -- Add logo to event organizers (#431)

* [WIP] Add logo to event organizers.

* Fix indentation issues.

* Refactor code

Refactor code

Refactor code

* Add new migration

* Take files into account for organizer sform (settings form)

* Fix grammer

* Make bootstrap form errors specific to each fieldset

* Display logo on organizer's page

* Fix PR issues

Fix PR issues

Fix PR issues

* Reorder imports

* Remove conflicting migration

* Fix rebase conflict

* Fix #41 -- Drag-and-drop ticket editor

Undo/redo

Useful toolbox

Font selection

Add text content

Use hex for colors

JS-side dump and load

Save

Load layout, proper undo/redo

First steps to Python rendering

More PDF rendering

Copy and paste

Buttons for keyboard actions

Splash Screen

Block unbeforeunload in dirty state

Remove debugging output

Preview

Upload new PDFs via the editor

Fix bugs during PDF reload, link in settings form

New default ticket

Add OpenSans BI

Custom fonts, fix tests

* Added bootstrap-colorpicker

* Allow inline PDF display in CSP header

* Add fontpack to list of plugins

* Update German translation

* Add ticketoutputpdf's assets to MANIFEST.i

* Fix migration of old ticket styles

* Fix iCal download URL

* Multi-line location field, new field for admission time

* Admission date and time in editor

* Remove icon from "add to calendar"

* Try to fix PDF display problems in Safari

* Proxy cachedfiles that are used as editor previews

* Check Event.presale_is_running in more places

* Fix CSS generation with an empty color field

* Fix missing placeholders and reformat the sendmail view

* Fix bug that lead to wrong payment amount when switching payment method to PayPal later

* Update translation

* Revert "Syncing fork to upstream (#1)"

This reverts commit 847d409a00.
Merged wrong, my bad.

* Formatted OTP secret

New variable `secretGrouped` in `2fa_confirm_totp.html`, user-friendly
version of OTP secret (split every 4 characters).
Works on pretix/pretix#443.

* Adds manual secret entry OTP setup screen

`secretGrouped` exposed in user-friendly fashion. Includes short
instructions, copy-to-clipboard button, and js to hide instructions
unless user clicks on "Can't scan the barcode?" link.
Works on pretix/pretix#443.

* Minor indentation issuer

Fixed indentation issue (L40).
Works on pretix/pretix#443.

* Minor spacing issues

L265 of `user.py` failing flake8 tests, minor spacing fixes.

* Fixes indentation in `2fa_confirm_totp.html`

Per https://github.com/pretix/pretix/pull/490#discussion_r116165041,
fixes an issue with indentation.
Works on pretix/pretix#443, member of pretix/pretix#490.

* Removed `aria-*` attributes

Per https://github.com/pretix/pretix/pull/490#discussion_r116165115,
removes `aria` attributes from sub-tutorial.
Works on pretix/pretix#443, member of pretix/pretix#490.

* Pretix capitalization issue

Per https://github.com/pretix/pretix/pull/490#discussion_r116165193,
fixes an issue with capitalization of pretix.
Works on pretix/pretix#443, member of pretix/pretix#490.
2017-05-17 09:31:28 +02:00
Raphael Michel
7769aaccea Fix #492 -- Force deterministic ordering of some signals 2017-05-15 13:09:23 +02:00
Raphael Michel
5ff6d0b014 Auto-detect common typos in email addresses 2017-05-15 11:33:18 +02:00
Raphael Michel
7d9a1b5e0c Update translation 2017-05-11 12:15:23 +02:00
Raphael Michel
127086a50e Fix bug that lead to wrong payment amount when switching payment method to PayPal later 2017-05-10 19:12:55 +02:00
Raphael Michel
90d14c004f Fix missing placeholders and reformat the sendmail view 2017-05-10 15:14:23 +02:00
Raphael Michel
8c457c057e Fix CSS generation with an empty color field 2017-05-10 15:09:07 +02:00
Raphael Michel
36215a989a Check Event.presale_is_running in more places 2017-05-10 15:04:01 +02:00
Raphael Michel
74259bd4c6 Proxy cachedfiles that are used as editor previews 2017-05-10 15:02:54 +02:00
Raphael Michel
4561b7dce2 Try to fix PDF display problems in Safari 2017-05-10 15:02:28 +02:00
Raphael Michel
89d6166dbe Remove icon from "add to calendar" 2017-05-10 13:28:07 +02:00
Raphael Michel
f451977a04 Admission date and time in editor 2017-05-10 13:16:39 +02:00
Raphael Michel
aac05727ed Multi-line location field, new field for admission time 2017-05-10 12:58:57 +02:00
Raphael Michel
c9ae65a9a8 Fix iCal download URL 2017-05-10 12:57:59 +02:00
Raphael Michel
f4a9dbb546 Fix migration of old ticket styles 2017-05-09 17:23:17 +02:00
Raphael Michel
3eec791584 Add ticketoutputpdf's assets to MANIFEST.i 2017-05-09 11:55:25 +02:00
Raphael Michel
57473da182 Update German translation 2017-05-09 11:21:42 +02:00
Raphael Michel
fd90752a12 Add fontpack to list of plugins 2017-05-09 11:10:10 +02:00
Raphael Michel
0d3f5e0c32 Fix #41 -- Drag-and-drop ticket editor
Undo/redo

Useful toolbox

Font selection

Add text content

Use hex for colors

JS-side dump and load

Save

Load layout, proper undo/redo

First steps to Python rendering

More PDF rendering

Copy and paste

Buttons for keyboard actions

Splash Screen

Block unbeforeunload in dirty state

Remove debugging output

Preview

Upload new PDFs via the editor

Fix bugs during PDF reload, link in settings form

New default ticket

Add OpenSans BI

Custom fonts, fix tests
2017-05-09 11:10:10 +02:00
Raphael Michel
c98b0aac90 Added bootstrap-colorpicker 2017-05-09 11:10:10 +02:00
Raphael Michel
3de85975d6 Allow inline PDF display in CSP header 2017-05-09 11:10:10 +02:00
Bolutife Lawrence
962cdef69a Fix #379 -- Add logo to event organizers (#431)
* [WIP] Add logo to event organizers.

* Fix indentation issues.

* Refactor code

Refactor code

Refactor code

* Add new migration

* Take files into account for organizer sform (settings form)

* Fix grammer

* Make bootstrap form errors specific to each fieldset

* Display logo on organizer's page

* Fix PR issues

Fix PR issues

Fix PR issues

* Reorder imports

* Remove conflicting migration

* Fix rebase conflict
2017-05-08 17:46:35 +02:00
jlwt90
b301d20488 Fix #297 -- pretixdroid: Show metrics in the control panel (#481)
* add checkin status page

add dashboard widget
add checkin page under orders

* modify checkin logic

added new fields in checkin page
added filter items

* add tests for checkins & minor improvement

* support addin_product & noadm setting logic

* remove name ordering check test case
2017-05-08 17:31:37 +02:00
scabux
1b2895b0ca Fixed bugs and added test for date range rendering (#488)
* fixed bug for same dates, added unit check for daterange

* fixed local language override in unit test
2017-05-07 10:40:36 +02:00
Raphael Michel
d6943bb1dd Refs #447 -- Extend copying old mails to subject and receipients 2017-05-06 15:13:53 +02:00
Raphael Michel
51d74503ae Update pretixdroid API version 2017-05-06 14:40:28 +02:00
asv-hungvt
5eac3cf9cf [WIP] Fix #447 -- Sendmail plugin: Create new mail based on an old one (#476)
* send old email content to the new one

* error key event

* test commit

* query bad ID

* query bad ID

* query bad ID

* query bad ID
2017-05-06 14:39:48 +02:00
scabux
27d6f24c3c Mail signature (#485)
* added signature field -- no function yet

* added mail signature feature

* fixed style issue

* fixed problem with signature default

* added unit test for mail signatures

* added unit test for mail signatures
2017-05-06 11:12:38 +02:00
Ian Williams
d929b163db Fix #456 -- Allow products to be excluded from ticket-generation (#483)
* Added non-admission setting to event

`ticket_download_nonadm` now setting in storage. Still need logic for
order page/PDF generation.
Works on pretix/pretix#456.

* Download button considers `ticket_download_nonadm`

Modified Django tags to look at item admission attribute and
`ticket_download_nonadm` setting.
Works on pretix/pretix#456.

* Ticket output for non-admission disabled

PDFs/etc. will only be permitted/generated for items with the
`admission` attribute, or if the `ticket_download_nonadm` event setting
is true. Applies to single and whole-order ticket downloads.
Works on pretix/pretix#456.

* Fixed product exclusion in PDF output

Forgot PDF output was a plugin, now includes same check as base
`BaseTicketOutput.generate_order`.
Works on pretix/pretix#456
2017-05-06 11:11:11 +02:00
Ian Williams
571b3fbfa8 Marked webfonts as binary files (#487)
Webfonts now listed as binary in `.gitattributes`.
Works on pretix/pretix#486
2017-05-06 10:50:47 +02:00
Raphael Michel
db05f8eaa3 pretixdroid: force-accepting unpaids and time display 2017-05-04 11:57:29 +02:00
Raphael Michel
d8eba81efc Add idempotenty nonces to pretixdroid API 2017-05-04 09:32:36 +02:00
Raphael Michel
429ef67bbf pretixdroid API: Add related lookups 2017-05-03 19:17:37 +02:00
Raphael Michel
e5d9e69e3e Pretixdroid tests: Ignore microseconds (chopped by mysql) 2017-05-03 18:51:16 +02:00
Raphael Michel
dc32bdc474 Prepare the pretixdroid API for an async mode in the app 2017-05-03 18:06:32 +02:00
Raphael Michel
c45b709d54 Fix typos in events 2017-05-03 18:04:41 +02:00
Raphael Michel
e1be084d98 Force ordering of events on dashboard 2017-05-03 18:02:10 +02:00
Raphael Michel
20b8aafe11 Update translations 2017-05-03 17:23:44 +02:00
Raphael Michel
c719adf235 Fix typo in method name 2017-05-03 17:11:55 +02:00
Raphael Michel
d08a0bdb00 Refs #39 -- New concept of "teams" (#478)
* New models

* CRUD UI

* UI for adding/removing team members

* Log display for teams

* Fix invitations, move frontend

* Drop old models (incomplete)

* Drop more old stuff

* Drop even more old stuff

* Fix tests

* Fix permission test

* flake8 fix

* Add tests fore the new code

* Rebase migrations
2017-05-03 16:55:37 +02:00
Raphael Michel
8294391ebc Hide payment fees if they are all equal to 0.00 2017-05-02 19:12:42 +02:00
Raphael Michel
394c206133 Link PayPal and Stripe documentation in the respective forms 2017-05-02 19:07:19 +02:00
Raphael Michel
2fcd561ff7 Add a user guide on payments 2017-05-02 18:59:18 +02:00
Raphael Michel
f95b77fd7a Ticket PDFs: Do not hide attendee name if code is hidden 2017-05-02 17:07:09 +02:00
Raphael Michel
7509bf69ca Squash migrations and bump version 2017-05-02 11:07:15 +02:00
Raphael Michel
d9adec88c8 Update translations 2017-05-02 11:01:48 +02:00
Raphael Michel
938a1bca0d Button text change if addons are present 2017-05-02 10:57:40 +02:00
Raphael Michel
ab757c502c Fix collapsing panels in the addon choice step 2017-05-02 10:51:43 +02:00
Raphael Michel
6b17388bd8 Make validate_cart useful together with addons 2017-05-02 10:20:28 +02:00
Raphael Michel
48a933b757 Copy from event: deal with deleted items 2017-05-02 09:58:26 +02:00
Raphael Michel
6c02bf73b5 Allow <br> tags in rich text 2017-05-02 09:52:46 +02:00
Raphael Michel
960d0bcdf2 Link to Django's runserver options in dev docs 2017-05-02 00:15:39 +02:00
Raphael Michel
d389e4390f Add variation descriptions and allow to order addons 2017-05-02 00:12:22 +02:00
Raphael Michel
55ce83a642 Drop "squash your commits" from the dev guide 2017-05-02 00:04:38 +02:00
Raphael Michel
300f8f666d Automatically sort new products to the end 2017-05-01 22:57:29 +02:00
Raphael Michel
5d6083dce4 Add-On product refinements 2017-04-30 13:23:03 +02:00
Raphael Michel
82f9f5027f Fix incorrect heading of CSV file 2017-04-27 18:23:16 +02:00
Raphael Michel
4f015f1d96 Replace organizer_edit_tabs by nav_organizer 2017-04-27 10:00:09 +02:00
Raphael Michel
bbe272c35c Fix #372 -- Plugin hook for "Copy from event" 2017-04-26 15:24:16 +02:00
Raphael Michel
39513448f3 Add signal nav_global 2017-04-26 14:34:48 +02:00
Raphael Michel
bee61bf398 Allow creating KnownDomains in the interface 2017-04-26 14:34:48 +02:00
Tobias Kunze
010c31cf10 Fix type annotation 2017-04-25 09:10:45 +02:00
Raphael Michel
d1643b4506 Refs #471 -- Additional event filter on quota calculation 2017-04-22 11:47:25 +02:00
Raphael Michel
623307b348 Do not override the selected category when copying from a different product 2017-04-22 10:37:28 +02:00
Raphael Michel
09e8fca132 Do not allow adding add-ons to add-ons 2017-04-21 15:12:16 +02:00
Raphael Michel
2c96a26d91 Fix missing attributes in copying products 2017-04-21 15:07:32 +02:00
Raphael Michel
f639d2aa57 Include category in ItemCreateForm 2017-04-21 14:35:45 +02:00
Raphael Michel
5a68eb345f Fix broken language field filtering in payment settings 2017-04-21 14:26:19 +02:00
Raphael Michel
603a3d78fc Properly initialize lightbox 2017-04-19 17:10:32 +02:00
Raphael Michel
cafc6a7226 Add the new widget dependencies to the event creation form 2017-04-18 20:32:12 +02:00
Raphael Michel
0b068f6d79 Copy add-ons during event cloning 2017-04-18 20:31:16 +02:00
Raphael Michel
ec73c916b7 Change style of admin log entries 2017-04-17 22:04:25 +02:00
Raphael Michel
110ccb5587 Update FontAwesome 2017-04-17 22:00:58 +02:00
Raphael Michel
d224ae3eb0 Fix broken aggregation in orders per product statistics 2017-04-17 21:52:17 +02:00
Raphael Michel
dd9c0b3a01 Add dependencies between form fields 2017-04-17 21:37:25 +02:00
Raphael Michel
d2d711c1f8 Fix datetimepicker annoyances 2017-04-17 21:12:52 +02:00
Raphael Michel
3dd2492926 Fix a broken import 2017-04-17 17:13:18 +02:00
Raphael Michel
bc1520ec35 Even more wording corrections 2017-04-17 17:10:47 +02:00
Raphael Michel
3033a82c92 Update wording and translation 2017-04-17 16:34:46 +02:00
Raphael Michel
bb75be7e8e Update docs and version number 2017-04-17 15:19:45 +02:00
Raphael Michel
b52f2f5a9e Improve add-on products 2017-04-17 14:54:15 +02:00
Raphael Michel
5bcfb958f0 Simpler API for cart removal 2017-04-17 14:54:15 +02:00
Raphael Michel
5f52963ce0 Add add-on products 2017-04-17 14:54:15 +02:00
Raphael Michel
3f76be2287 Fix docker plugin installation documentation 2017-04-17 13:27:49 +02:00
Raphael Michel
92aa65a839 Small refinements on the previous commit 2017-04-14 18:05:02 +02:00
Alexey Kislitsin
bd5337a2c2 Fix #448 -- Add PlaceholderValidator (#465)
* Integrated PlaceholderValidator to MailForm at plugins/sendmail

* Integrated PlaceholderValidator to MailForm and MailSettingsForm

* Typo
2017-04-14 18:04:30 +02:00
Raphael Michel
990d5815f2 Fix #468 -- Long event slugs on invoices 2017-04-14 18:00:20 +02:00
Raphael Michel
c1d51cc196 Improve help text 2017-04-14 17:37:38 +02:00
Raphael Michel
f5b871f8f5 Unify design of the different mail previews 2017-04-14 11:51:27 +02:00
jlwt90
bc6b84f900 Fix #308 -- Preview for email templates (#438)
* add ajax and dummy api response

* add preview <p> blocks

* finalise order_placed setup

- use <pre> for mail preview
- create dummy data in backend

* fix i18n text conversion

* create fragment template for mail preview

* support i18n in mail preview view

* apply mail fragment in all mail settings

fix mistake in input[lang=en] flag style
add dummy data for all placeholders
apply fragment template to all fields
add exclude option to fragment template

* remove migration file

* add translation mapping & fix field label

remove hardcoded field label
add transblock for translation file

* add test for mail setting preview

* fix code style in preview class

* bug fix in mail preview view

- fixed localised date values
- added locale index mapping
- added tests on multi-language event
- enhanced dummy data
2017-04-14 11:19:58 +02:00
Raphael Michel
5ee79c8148 Update German translations 2017-04-13 23:16:03 +02:00
Raphael Michel
e4706dd3ba Add attendee email field (#466)
* Add attendee email field

* exports, tests
2017-04-13 22:59:54 +02:00
Raphael Michel
3c59a870e7 Add new option Item.min_per_order 2017-04-13 14:16:23 +02:00
Raphael Michel
ae6ad8870d Fix order view in test cases 2017-04-11 14:25:13 +02:00
Raphael Michel
07fed0acce Use async actions for order export 2017-04-11 12:12:40 +02:00
Raphael Michel
7dd99f3d18 Fix locale formatting in PDF exporter 2017-04-11 11:38:49 +02:00
Raphael Michel
03d8cfb401 Cosmetic changes to locale change form 2017-04-11 10:54:12 +02:00
Jahongir
ccb981e6ce Issue #449: Display and change order locale (#459)
* Add more security headers (#458)

* Include some missing security headers

This change adds the following security headers:
* X-Content-Type-Options to prevent content type sniffing
* Referrer-Policy to prevent leaking referrer information when navigating away from the instance

* Migrate from Docker sample to manual configuration

Migrate the additional security headers from the Docker configuration sample to the manual configuration guide.

Add DS_Store to gitingore

* Show order locale in order details

* Add OrderLocaleChange view and OrderLocaleForm

Refactor OrderLocaleForm. Add test
2017-04-11 10:45:46 +02:00
Raphael Michel
984d5c716b Integrate hierarkey package (#460) 2017-04-10 18:11:21 +02:00
Raphael Michel
43121a08bd Add consistent ordering to pretixdroid API 2017-04-10 16:34:58 +02:00
Jan Felix Wiebe
54c7f16c4c Added missing semicolon to docker nginx config (#462) 2017-04-10 15:11:40 +02:00
Jan Felix Wiebe
6cd2674f2a Switched checkbox order (#461)
The boxes vor adding and editing vouchers were switched for new users.
2017-04-10 10:25:21 +02:00
BenBE
602947a3d7 Add more security headers (#458)
* Include some missing security headers

This change adds the following security headers:
* X-Content-Type-Options to prevent content type sniffing
* Referrer-Policy to prevent leaking referrer information when navigating away from the instance

* Migrate from Docker sample to manual configuration

Migrate the additional security headers from the Docker configuration sample to the manual configuration guide.
2017-04-06 17:30:26 +02:00
Raphael Michel
5048963aa2 Fix trailing whitespace 2017-04-06 15:04:31 +02:00
morrme
8d16e2b59b Fix #444 -- Add alternative text to the top-right navigation (#457)
* Update base.html

* Update base.html

* Update signals.py
2017-04-06 14:13:12 +02:00
Matthew Emerson
4accbef6a9 Fix #446 -- Choices for Event.currenxy attribute (#452)
* Change event currency to a choice attribute

* Added pycountry to requirements for currency list

* Fixed issues from flake8

* Added tests for event currency and added pycountry to setup.py

* Removed whitespace from test/control/test_events.py
2017-04-06 12:08:55 +02:00
Raphael Michel
2e9d95b96a Update minimal django version 2017-04-05 11:10:49 +02:00
Raphael Michel
03dfd1b96f Ignore database errors during rebuild 2017-04-02 22:27:53 +02:00
Raphael Michel
ee1ccb7f01 Do not actually call the pretix server during tests 2017-04-02 22:12:14 +02:00
276 changed files with 107321 additions and 5683 deletions

14
.gitattributes vendored
View File

@@ -3,4 +3,18 @@ src/static/lightbox/* linguist-vendored
src/static/typeahead/* linguist-vendored
src/static/moment/* linguist-vendored
src/static/datetimepicker/* linguist-vendored
src/static/colorpicker/* linguist-vendored
src/static/fileupload/* linguist-vendored
src/static/charts/* linguist-vendored
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/fabric.* linguist-vendored
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/pdf.* linguist-vendored
# Denote all files that are truly binary and should not be modified.
*.eot binary
*.otf binary
*.ttf binary
*.woff binary
*.zip binary
*.png binary
*.gif binary
*.jpg binary

2
.gitignore vendored
View File

@@ -21,4 +21,6 @@ pretixeu/
local/
.project
.pydevproject
.DS_Store

View File

@@ -2,11 +2,32 @@ before_script:
tests:
stage: test
script:
- virtualenv-3.4 env
- virtualenv env
- source env/bin/activate
- pip install -U pip wheel setuptools
- XDG_CACHE_HOME=/cache bash .travis.sh style
- XDG_CACHE_HOME=/cache bash .travis.sh tests
- XDG_CACHE_HOME=/cache bash .travis.sh doctests
tags:
- python3
pypi:
stage: release
script:
- cp /keys/.pypirc ~/.pypirc
- virtualenv env
- source env/bin/activate
- 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 upload
- python setup.py bdist_wheel upload
tags:
- python3
only:
- release
artifacts:
paths:
- src/dist/
stages:
- test
- build
- release

View File

@@ -22,9 +22,11 @@ http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff;
access_log /var/log/nginx/access.log private;
error_log /var/log/nginx/error.log;
add_header Referrer-Policy same-origin;
gzip on;
gzip_disable "msie6";

View File

@@ -246,11 +246,11 @@ To install a plugin, you need to build your own docker image. To do so, create a
named ``Dockerfile`` in it. The Dockerfile could look like this (replace ``pretix-passbook`` with the plugins of your
choice)::
FROM pretix/standalone
FROM pretix/standalone:stable
USER root
RUN pip3 install pretix-passbook
USER pretixuser
RUN make production
RUN cd /pretix/src && make production
Then, go to that directory and build the image::

View File

@@ -213,6 +213,9 @@ The following snippet is an example on how to configure a nginx proxy for pretix
ssl_certificate /path/to/cert.chain.pem;
ssl_certificate_key /path/to/key.pem;
add_header Referrer-Options same-origin;
add_header X-Content-Type-Options nosniff;
location / {
proxy_pass http://localhost:8345/;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -55,16 +55,17 @@ master_doc = 'index'
# General information about the project.
project = 'pretix'
copyright = '2014-2016, Raphael Michel'
copyright = '2014-2017, Raphael Michel'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.0.0'
from pretix import __version__
version = '.'.join(__version__.split('.')[:2])
# The full version, including alpha/beta/rc tags.
release = '0.0.0'
release = __version__
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.

View File

@@ -11,7 +11,7 @@ Core
----
.. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues
:members: periodic_task, event_live_issues, event_copy_data
Order events
""""""""""""
@@ -47,7 +47,7 @@ Backend
-------
.. automodule:: pretix.control.signals
:members: nav_event, html_head, quota_detail_html, nav_topbar, organizer_edit_tabs
:members: nav_event, html_head, quota_detail_html, nav_topbar, nav_global, nav_organizer
.. automodule:: pretix.base.signals

View File

@@ -18,8 +18,9 @@ If you improved pretix in any way, we'd be very happy if you contribute it
back to the main code base! The easiest way to do so is to `create a pull request`_
on our `GitHub repository`_.
Before you do so, please `squash all your changes`_ into one single commit. Please
use the test suite to check whether your changes break any existing features and run
We recommend that you create a feature branch for every issue you work on so the changes can
be reviewed individually.
Please use the test suite to check whether your changes break any existing features and run
the code style checks to confirm you are consistent with pretix's coding style. You'll
find instructions on this in the :ref:`checksandtests` section of the development setup guide.
@@ -34,4 +35,3 @@ Again: If you get stuck, do not hesitate to contact any of us, or Raphael person
.. _create a pull request: https://help.github.com/articles/creating-a-pull-request/
.. _GitHub repository: https://github.com/pretix/pretix
.. _squash all your changes: https://davidwalsh.name/squash-commits-git

View File

@@ -20,13 +20,10 @@ Organizers and events
.. autoclass:: pretix.base.models.Organizer
:members:
.. autoclass:: pretix.base.models.OrganizerPermission
:members:
.. autoclass:: pretix.base.models.Event
:members:
.. autoclass:: pretix.base.models.EventPermission
.. autoclass:: pretix.base.models.Team
:members:
.. autoclass:: pretix.base.models.RequiredAction
@@ -67,7 +64,7 @@ Carts and Orders
:members:
.. autoclass:: pretix.base.models.QuestionAnswer
:members:
:members:
.. autoclass:: pretix.base.models.Checkin
:members:
@@ -92,5 +89,3 @@ Vouchers
.. autoclass:: pretix.base.models.Voucher
:members:
.. _cleanerversion: https://github.com/swisscom/cleanerversion

View File

@@ -2,7 +2,10 @@ Settings storage
================
pretix is highly configurable and therefore needs to store a lot of per-event and per-organizer settings.
Those settings are stored in the database and accessed through a ``SettingsProxy`` instance. You can obtain
For this purpose, we use `django-hierarkey`_ which started out as part of pretix and then got refactored into
its own library. It has a comprehensive `documentation`_ which you should read if you work with settings in pretix.
The settings are stored in the database and accessed through a ``HierarkeyProxy`` instance. You can obtain
such an instance from any event or organizer model instance by just accessing ``event.settings`` or
``organizer.settings``, respectively.
@@ -17,12 +20,10 @@ includes serializers for serializing the following types:
In code, we recommend to always use the ``.get()`` method on the settings object to access a value, but for
convenience in templates you can also access settings values at ``settings[name]`` and ``settings.name``.
.. autoclass:: pretix.base.settings.SettingsProxy
:members: get, set, delete, freeze
See the hierarkey `documentation`_ for more information.
To avoid naming conflicts, plugins are requested to prefix all settings they use with the name of the plugin
or something unique, e.g. ``payment.paypal.api_key``. To reduce redundant typing of this prefix, we provide
or something unique, e.g. ``payment_paypal_api_key``. To reduce redundant typing of this prefix, we provide
another helper class:
.. autoclass:: pretix.base.settings.SettingsSandbox
@@ -33,10 +34,10 @@ you will just be passed a sandbox object with a prefix generated from your provi
Forms
-----
We also provide a base class for forms that allow the modification of settings:
Hierarkey also provides a base class for forms that allow the modification of settings. pretix contains a
subclass that also adds suport for internationalized fields:
.. autoclass:: pretix.base.forms.SettingsForm
:members: save
You can simply use it like this::
@@ -51,3 +52,17 @@ You can simply use it like this::
help_text=_("The number of days after placing an order the user has to pay to "
"preserve his reservation."),
)
Defaults in plugins
-------------------
Plugins can add custom hardcoded defaults in the following way::
from pretix.base.settings import settings_hierarkey
settings_hierarkey.add_default('key', 'value', type)
Make sure that you include this code in a module that is imported at app loading time.
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
.. _documentation: https://django-hierarkey.readthedocs.io/en/latest/

View File

@@ -83,6 +83,10 @@ As we did not implement an overall front page yet, you need to go directly to
http://localhost:8000/control/ for the admin view or, if you imported the test
data as suggested above, to the event page at http://localhost:8000/bigevents/2017/
.. note:: If you want the development server to listen on a different interface or
port (for example because you develop on `pretixdroid`_), you can check
`Django's documentation`_ for more options.
.. _`checksandtests`:
Code checks and unit tests
@@ -148,3 +152,7 @@ To build the documentation, run the following command from the ``doc/`` director
make html
You will now find the generated documentation in the ``doc/_build/html/`` subdirectory.
.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
.. _pretixdroid: https://github.com/pretix/pretixdroid

View File

@@ -26,8 +26,11 @@ pretix/
multidomain/
Additional code implementing our customized :ref:`URL handling <urlconf>`.
static/
Contains all static files (CSS, JavaScript, images)
static/
Contains all static files (CSS, JavaScript, images)
static/
Contains some pretix plugins that ship with pretix itself
tests/
This is the root directory for all test codes. It includes subdirectories ``base``,
@@ -37,7 +40,8 @@ tests/
Language files
--------------
The language files live in ``locale/*/LC_MESSAGES/``.
The language files live in ``pretix/locale/*/LC_MESSAGES/``.
Static files
------------
@@ -49,27 +53,14 @@ We use libsass as a preprocessor for CSS. Our own sass code is built in the same
step as Bootstrap and FontAwesome, so their mixins etc. are fully available.
pretix.control
pretixcontrol has two main SCSS files, ``pretix/control/static/pretixcontrol/scss/main.scss`` and
``pretix/control/static/pretixcontrol/scss/auth.scss``, importing everything else.
pretixcontrol has two main SCSS files, ``pretix/static/pretixcontrol/scss/main.scss`` and
``pretix/static/pretixcontrol/scss/auth.scss``, importing everything else.
pretix.presale
pretixpresale has one main SCSS files, ``pretix/control/static/pretix/presale/scss/main.scss``,
pretixpresale has one main SCSS files, ``pretix/pretixpresale/scss/main.scss``,
importing everything else.
3rd-party assets
^^^^^^^^^^^^^^^^
Bootstrap
Bootstrap lives vendored at ``static/bootstrap/``
Font Awesome
Font Awesome lives vendored at ``static/fontawesome/``
jQuery
jQuery lives as a single JavaScript file in ``static/jquery/js/``
jQuery plugin: Django formsets
Our own modified version of `django-formset-js`_ is available as an independent
django app and installed via ``pip``.
.. _django-formset-js: https://github.com/pretix/django-formset-js
Most client-side 3rd-party assets are vendored in various subdirectories of ``pretix/static``.

View File

@@ -6,6 +6,7 @@ Contents:
.. toctree::
:maxdepth: 2
user/index
admin/index
development/index
plugins/index

View File

@@ -21,6 +21,7 @@ same team:
* `Pages`_
* `Passbook/Wallet ticket output`_
* `Cartshare`_
* `Fontpack Free fonts`_
The following plugins are from independent third-party authors, so we can make
no statements about their stability:
@@ -34,3 +35,4 @@ no statements about their stability:
.. _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

View File

@@ -4,6 +4,10 @@ pretixdroid HTTP API
The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_
uses to communicate with the pretix server.
.. warning:: This API is intended **only** to serve the pretixdroid Android app. There are no backwards compatibility
guarantees on this API. We will not add features that are not required for the Android App. There will be
a proper general-use API for pretix at a later point in time.
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
Redeems a ticket, i.e. checks the user in.
@@ -19,6 +23,16 @@ uses to communicate with the pretix server.
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3
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 can optionally include 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.
You can optionally include 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.
**Example successful response**:
.. sourcecode:: http
@@ -51,9 +65,9 @@ uses to communicate with the pretix server.
* ``unknown_ticket`` - Secret does not match a ticket in the database
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/search/
@@ -97,6 +111,46 @@ uses to communicate with the pretix server.
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/download/
Download data for all tickets.
**Example request**:
.. sourcecode:: http
GET /pretixdroid/api/demoorga/democon/download/?key=ABCDEF HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"results": [
{
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCE6",
"item": "Standard ticket",
"variation": null,
"attendee_name": "Peter Higgs",
"redeemed": false,
"paid": true
},
...
],
"version": 2
}
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/status/
Returns status information, such as the total number of tickets and the

9
doc/user/index.rst Normal file
View File

@@ -0,0 +1,9 @@
User Guide
==========
Contents:
.. toctree::
:maxdepth: 2
payments/index

View File

@@ -0,0 +1,31 @@
.. _`banktransfer`:
Bank transfer
=============
To accept payments with bank transfer, you only need to fill one important field in pretix' settings: In "Bank
account details" you should specify everything one needs to know to transfer money to you, e.g. your IBAN and BIC,
the name of your bank and for international transfers, preferably also your address and the bank's address.
pretix will automatically tell the user to include the order code in the payment reference so incoming transfers can
automatically be matched to payments.
Importing payment data
----------------------
The easiest way to import payment data is to download a CSV file from your online banking. Most banks provide a CSV
export of some sort. You can go to "Import bank data" in pretix to upload a new file:
.. image:: img/bank1.png
If you upload a file for the first time, pretix will not know what information is contained in which column as every
bank builds completely different CSV files. Therefore, pretix will ask you for that information. It will show you the
data of the file you imported and ask you to define the column's meanings. You can select one column that contains
the payment date and one that contains the paid amount. You can select multiple columns that contain information
about the payer or the payment reference. All other columns will be ignored.
Once you continue, pretix will try to match the payments to the respective orders automatically. It will tell you how
many orders could be processed correctly and how many could not. You can then go back to the upload page to see all
transfers from your bank statement that are not yet matched to an order. Using the input field and the buttons on the
left of each transaction, you can manually enter an order code to match it to or just discard it from the list, e.g.
if the transaction is not related to the event at all.

View File

@@ -0,0 +1,52 @@
Payment method fees
===================
Most external payment providers like PayPal or Stripe charge substantial fees for your service. In general, you have
two options to deal with this:
1. Pay the fees yourself
2. Add the fees to your customer's total
The choice totally depends on you and what your customers expect from you. Option two might be appropriate if you
offer different payment methods and want to encourage your customers to use the ones that come you cheaper, but you
might also decide to go for option one to make it easier for customers who don't have the option.
If you go for the second option, you can configure pretix to charge the payment method fees to your user. You can
define both an absolute fee as well as a percental fee based on the order total. If you do so, there are two
different ways in which pretix can calculate the fee. Normally, it is fine to just go with the default setting, but
in case you are interested, here are all the details:
Payment fee calculation
-----------------------
If you configure a fee for a payment method, there are two possible ways for us to calculate this. Let's
assume that your payment provider, e.g. PayPal, charges you 5 % fees and you want to charge your users the
same 5 %, such that for a ticket with a list price of 100 € you will get your full 100 €.
**Method A: Calculate the fee from the subtotal and add it to the bill.**
For a ticket price of 100 €, this will lead to the following calculation:
============================================== ============
Ticket price 100.00 €
pretix calculates the fee as 5 % of 100 € +5.00 €
Subtotal that will be paid by the customer 105.00 €
PayPal calculates its fee as 5 % of 105 € -5.25 €
End total that is on your bank account **99.75 €**
============================================== ============
**Method B (default): Calculate the fee from the total value including the fee.**
For a ticket price of 100 €, this will lead to the following calculation:
===================================================== =============
Ticket price 100.00 €
pretix calculates the fee as 100/(100 - 5) % of 100 € +5.26 €
Subtotal that will be paid by the customer 105.26 €
PayPal calculates its fee as 5 % of 105 € -5.26 €
End total that is on your bank account **100.00 €**
===================================================== =============
Due to the various rounding steps performed by pretix and by the payment provider, the end total on
your bank account might stil vary by one cent.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -0,0 +1,14 @@
Accepting payments
==================
Contents:
.. toctree::
:maxdepth: 2
overview
fees
paypal
stripe
banktransfer

View File

@@ -0,0 +1,34 @@
Payment method overview
=======================
pretix allows you to accept payments using a variety of payment methods to fit the needs of very different events.
This page gives you a short overview over them and links to more detailled 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".
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.
:ref:`stripe`
Stripe is a US-based company that offers you an easy way to accept credit card payments from all over the world.
To accept payments with Stripe, you need to have a Stripe merchant account that is easy to create. Click on the link
above to get more details about the Stripe integration into pretix.
:ref:`paypal`
If you want to accept online payments via PayPal, you can do so using pretix. You will need a PayPal merchant
account and it is a little bit complicated to obtain the required technical details, but we've got you covered.
Click on the link above to learn more.
:ref:`banktransfer`
Classical IBAN wire transfers are a common payment method in central Europe that has the large benefit that it
often does not cause any additional fees. However, it requires you to invest some more effort as you need to
check your bank account for incoming payments regularly. We provide some tools to make this easier for you.
SEPA debit
In some Europen countries, a very popular online payment method is SEPA direct debit. If you want to offer this
option in your pretix ticket shop, we provide a convenient plugin that allows users to enter their SEPA bank
account details and issue a SEPA mandate. You will then need to regularly download a SEPA XML file from pretix
and upload it to your bank's interface to actually perform the debits.

View File

@@ -0,0 +1,50 @@
.. _`paypal`:
PayPal
======
To integrate PayPal with pretix, you first need to have an active PayPal merchant account. If you do not already have a
PayPal account, you can create one on `paypal.com`_.
If you look into pretix' settings, you are required to fill in two keys:
.. image:: img/paypal_pretix.png
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.
Click on "Log In" in the top-right corner and log in with your PayPal account.
.. image:: img/paypal2.png
Then, click on "Dashboard" in the top-right corner.
.. image:: img/paypal3.png
In the dashboard, scroll down until you see the headline "REST API Apps". Click "Create App".
.. image:: img/paypal4.png
Enter any name for the application that helps you to identify it later. Then confirm with "Create App".
.. image:: img/paypal5.png
On the next page, before you do anything else, switch the mode on the right to "Live" to get the correct keys.
Then, copy the "Client ID" and the "Secret" and enter them into the appropriate fields in the payment settings in
pretix.
.. image:: img/paypal6.png
Finally, we need to create a webhook. The webhook tells PayPal to notify pretix e.g. if a payment gets cancelled so
pretix can cancel the ticket as well. If you have multiple events connected to your PayPal account, you need multiple
webhooks. To create one, scroll a bit down and click "Add Webhook".
.. image:: img/paypal7.png
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.
.. image:: img/paypal8.png
That's it, you are ready to go!
.. _paypal.com: https://www.paypal.com/webapps/mpp/account-selection
.. _developer.paypal.com: https://developer.paypal.com/

View File

@@ -0,0 +1,28 @@
.. _stripe:
Stripe
======
To integrate Stripe with pretix, you first need to have an active Stripe merchant account. If you do not already have a
Stripe account, you can create one on `stripe.com`_. Then, click on "API" in the left navigation of the Stripe
Dashboard. As you can see in the following screenshot, you will be presented with two sets of API keys, one for test
and one for live payments. In each set, there is a secret and a publishable keys.
.. image:: img/stripe1.png
Choose one of the two sets and copy the two keys to the appropriate fields in pretix' settings. To perform actual
payments, you will need to use the live keys, but you can use the test keys to test the payment flow before you go live.
In test mode, you cannot use your real credit card, but only `test cards`_ like ``4242424242424242`` that you can
find in Stripe's documentation.
If you want Stripe to notify pretix automatically once a payment gets cancelled, so pretix can cancel the ticket as
well, you need to create a so-called webhook. To do so, click "Webhooks" on top of the page in the Stripe dashboard
that you are currently on. Then, click "Add endpoint" and enter the URL that you find directly below the key
configuration in pretix' settings.
.. image:: img/stripe2.png
Again, you can choose between live mode and test mode here.
.. _stripe.com: https://dashboard.stripe.com/register
.. _test cards: https://stripe.com/docs/testing#cards

View File

@@ -16,3 +16,5 @@ recursive-include pretix/plugins/statistics/templates *
recursive-include pretix/plugins/statistics/static *
recursive-include pretix/plugins/stripe/templates *
recursive-include pretix/plugins/stripe/static *
recursive-include pretix/plugins/ticketoutputpdf/templates *
recursive-include pretix/plugins/ticketoutputpdf/static *

View File

@@ -23,9 +23,6 @@ user.save()
organizer = Organizer.objects.create(
name='BigEvents LLC', slug='bigevents'
)
OrganizerPermission.objects.get_or_create(
organizer=organizer, user=user
)
year = now().year + 1
event = Event.objects.create(
organizer=organizer, name='Demo Conference {}'.format(year),
@@ -33,13 +30,17 @@ event = Event.objects.create(
date_from=datetime(year, 9, 4, 17, 0, 0),
date_to=datetime(year, 9, 6, 17, 0, 0),
)
EventPermission.objects.get_or_create(
event=event, user=user
t = Team.objects.get_or_create(
organizer=organizer, name='Admin Team',
all_events=True, can_create_events=True, can_change_teams=True,
can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True,
can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True
)
cat_tickets = ItemCategory.objects.create(
t.members.add(user)
cat_tickets = ItemCategory.all.create(
event=event, name='Tickets'
)
cat_merch = ItemCategory.objects.create(
cat_merch = ItemCategory.all.create(
event=event, name='Merchandise'
)
question = Question.objects.create(

View File

@@ -1 +1 @@
__version__ = "1.2.0"
__version__ = "1.4.0-dev0"

View File

@@ -68,7 +68,9 @@ class JSONExporter(BaseExporter):
'variation': position.variation_id,
'price': position.price,
'attendee_name': position.attendee_name,
'attendee_email': position.attendee_email,
'secret': position.secret,
'addon_to': position.addon_to_id,
'answers': [
{
'question': answer.question_id,

View File

@@ -4,6 +4,8 @@ from django import forms
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import OrderPosition
from ..exporter import BaseExporter
from ..models import Order
from ..signals import register_data_exporters
@@ -16,7 +18,11 @@ class MailExporter(BaseExporter):
def render(self, form_data: dict):
qs = self.event.orders.filter(status__in=form_data['status'])
addrs = qs.values('email')
data = "\r\n".join(set(a['email'] for a in addrs))
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)
| set(a['attendee_email'] for a in pos if a['attendee_email']))
return 'pretixemails.txt', 'text/plain', data.encode("utf-8")
@property

View File

@@ -59,7 +59,7 @@ class OrderListExporter(BaseExporter):
headers = [
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
_('Payment date'), _('Payment type'), _('Payment method fee'), _('Invoice numbers')
_('Payment date'), _('Payment type'), _('Payment method fee'),
]
for tr in tax_rates:
@@ -69,6 +69,8 @@ class OrderListExporter(BaseExporter):
_('Tax value at {rate} % tax').format(rate=tr),
]
headers.append(_('Invoice numbers'))
writer.writerow(headers)
provider_names = {}

View File

@@ -1,17 +1,15 @@
import logging
import i18nfield.forms
from django import forms
from django.core.files import File
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import UploadedFile
from django.forms.models import ModelFormMetaclass
from django.utils import six
from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _
from hierarkey.forms import HierarkeyForm
from pretix.base.models import Event
from .validators import PlaceholderValidator # NOQA
logger = logging.getLogger('pretix.plugins.ticketoutputpdf')
@@ -49,67 +47,22 @@ class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
super().__init__(*args, **kwargs)
class SettingsForm(i18nfield.forms.I18nForm):
"""
This form is meant to be used for modifying EventSettings or OrganizerSettings. It takes
care of loading the current values of the fields and saving the field inputs to the
settings storage. It also deals with setting the available languages for internationalized
fields.
:param obj: The event or organizer object which should be used for the settings storage
"""
BOOL_CHOICES = (
('False', _('disabled')),
('True', _('enabled')),
)
class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
def __init__(self, *args, **kwargs):
self.obj = kwargs.pop('obj', None)
self.locales = kwargs.pop('locales', None)
kwargs['locales'] = self.obj.settings.get('locales') if self.obj else self.locales
self.obj = kwargs.get('obj', None)
self.locales = self.obj.settings.get('locales') if self.obj else kwargs.pop('locales', None)
kwargs['attribute_name'] = 'settings'
kwargs['locales'] = self.locales
kwargs['initial'] = self.obj.settings.freeze()
super().__init__(*args, **kwargs)
def save(self):
"""
Performs the save operation
"""
for name, field in self.fields.items():
value = self.cleaned_data[name]
if isinstance(value, UploadedFile):
# Delete old file
fname = self.obj.settings.get(name, as_type=File)
if fname:
try:
default_storage.delete(fname.name)
except OSError:
logger.error('Deleting file %s failed.' % fname.name)
# Create new file
nonce = get_random_string(length=8)
if isinstance(self.obj, Event):
fname = '%s/%s/%s.%s.%s' % (
self.obj.organizer.slug, self.obj.slug, name, nonce, value.name.split('.')[-1]
)
else:
fname = '%s/%s.%s.%s' % (self.obj.slug, name, nonce, value.name.split('.')[-1])
newname = default_storage.save(fname, value)
value._name = newname
self.obj.settings.set(name, value)
elif isinstance(value, File):
# file is unchanged
continue
elif isinstance(field, forms.FileField):
# file is deleted
fname = self.obj.settings.get(name, as_type=File)
if fname:
try:
default_storage.delete(fname.name)
except OSError:
logger.error('Deleting file %s failed.' % fname.name)
del self.obj.settings[name]
elif value is None:
del self.obj.settings[name]
elif self.obj.settings.get(name, as_type=type(value)) != value:
self.obj.settings.set(name, value)
def get_new_filename(self, name: str) -> str:
nonce = get_random_string(length=8)
if isinstance(self.obj, Event):
fname = '%s/%s/%s.%s.%s' % (
self.obj.organizer.slug, self.obj.slug, name, nonce, name.split('.')[-1]
)
else:
fname = '%s/%s.%s.%s' % (self.obj.slug, name, nonce, name.split('.')[-1])
return fname

View File

@@ -0,0 +1,38 @@
import re
from django.core.exceptions import ValidationError
from django.core.validators import BaseValidator
from django.utils.translation import ugettext_lazy as _
from i18nfield.strings import LazyI18nString
class PlaceholderValidator(BaseValidator):
"""
Takes list of allowed placeholders,
validates form field by checking for placeholders,
which are not presented in taken list.
"""
def __init__(self, limit_value):
super().__init__(limit_value)
self.limit_value = limit_value
def __call__(self, value):
if isinstance(value, LazyI18nString):
for l, v in value.data.items():
self.__call__(v)
return
data_placeholders = list(re.findall(r'({[\w\s]*})', value, re.X))
invalid_placeholders = []
for placeholder in data_placeholders:
if placeholder not in self.limit_value:
invalid_placeholders.append(placeholder)
if invalid_placeholders:
raise ValidationError(
_('Invalid placeholder(s): %(value)s'),
code='invalid',
params={'value': ", ".join(invalid_placeholders,)})
def clean(self, x):
return x

View File

@@ -12,7 +12,12 @@ class Command(BaseCommand):
call_command('compilejsi18n', verbosity=1, interactive=False)
call_command('collectstatic', verbosity=1, interactive=False)
call_command('compress', verbosity=1, interactive=False)
gs = GlobalSettingsObject()
del gs.settings.update_check_last
del gs.settings.update_check_result
del gs.settings.update_check_result_warning
try:
gs = GlobalSettingsObject()
del gs.settings.update_check_last
del gs.settings.update_check_result
del gs.settings.update_check_result_warning
except:
# Fails when this is executed without a valid database configuration.
# We don't care.
pass

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-04-27 09:11
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.organizer
def create_teams(apps, schema_editor):
Event = apps.get_model('pretixbase', 'Event')
Organizer = apps.get_model('pretixbase', 'Organizer')
Team = apps.get_model('pretixbase', 'Team')
TeamInvite = apps.get_model('pretixbase', 'TeamInvite')
EventPermission = apps.get_model('pretixbase', 'EventPermission')
OrganizerPermission = apps.get_model('pretixbase', 'OrganizerPermission')
for o in Organizer.objects.prefetch_related('events'):
for e in o.events.all():
teams = {}
for p in e.user_perms.all():
pkey = (p.can_change_settings, p.can_change_items, p.can_view_orders,
p.can_change_permissions, p.can_change_orders, p.can_view_vouchers,
p.can_change_vouchers)
if pkey not in teams:
team = Team()
team.can_change_event_settings = p.can_change_settings
team.can_change_items = p.can_change_items
team.can_view_orders = p.can_view_orders
team.can_change_orders = p.can_change_orders
team.can_view_vouchers = p.can_view_vouchers
team.can_change_vouchers = p.can_change_vouchers
team.organizer = o
team.name = '{} Team {}'.format(
str(e.name), len(teams) + 1
)
team.save()
team.limit_events.add(e)
teams[pkey] = team
if p.user:
teams[pkey].members.add(p.user)
else:
teams[pkey].invites.create(email=p.invite_email, token=p.invite_token)
teams = {}
for p in o.user_perms.all():
pkey = (p.can_create_events, p.can_change_permissions)
if pkey not in teams:
team = Team()
team.can_change_organizer_settings = True
team.can_create_events = p.can_create_events
team.can_change_teams = p.can_change_permissions
team.organizer = o
team.name = '{} Team {}'.format(
str(o.name), len(teams) + 1
)
team.save()
teams[pkey] = team
if p.user:
teams[pkey].members.add(p.user)
else:
teams[pkey].invites.create(email=p.invite_email, token=p.invite_token)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0051_auto_20170206_2027_squashed_0057_auto_20170501_2116'),
]
operations = [
migrations.CreateModel(
name='Team',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=190, verbose_name='Team name')),
('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')),
('can_create_events', models.BooleanField(default=False, verbose_name='Can create events')),
('can_change_teams', models.BooleanField(default=False, verbose_name='Can change permissions')),
('can_change_organizer_settings', models.BooleanField(default=False, verbose_name='Can change organizer settings')),
('can_change_event_settings', models.BooleanField(default=False, verbose_name='Can change event settings')),
('can_change_items', models.BooleanField(default=False, verbose_name='Can change product settings')),
('can_view_orders', models.BooleanField(default=False, verbose_name='Can view orders')),
('can_change_orders', models.BooleanField(default=False, verbose_name='Can change orders')),
('can_view_vouchers', models.BooleanField(default=False, verbose_name='Can view vouchers')),
('can_change_vouchers', models.BooleanField(default=False, verbose_name='Can change vouchers')),
('limit_events', models.ManyToManyField(to='pretixbase.Event', verbose_name='Limit to events')),
('members', models.ManyToManyField(related_name='teams', to=settings.AUTH_USER_MODEL, verbose_name='Team members')),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='pretixbase.Organizer')),
],
options={
'verbose_name_plural': 'Teams',
'verbose_name': 'Team',
},
),
migrations.CreateModel(
name='TeamInvite',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(blank=True, max_length=254, null=True)),
('token', models.CharField(blank=True, default=pretix.base.models.organizer.generate_invite_token, max_length=64, null=True)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='pretixbase.Team')),
],
),
migrations.RunPython(
create_teams, migrations.RunPython.noop
)
]

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-04-09 16:51
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
def migrate_global_settings(apps, schema_editor):
GlobalSetting = apps.get_model('pretixbase', 'GlobalSetting')
GlobalSettingsObject_SettingsStore = apps.get_model('pretixbase', 'GlobalSettingsObject_SettingsStore')
l = []
for s in GlobalSetting.objects.all():
l.append(GlobalSettingsObject_SettingsStore(key=s.key, value=s.value))
GlobalSettingsObject_SettingsStore.objects.bulk_create(l)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0052_auto_20170324_1506'),
]
operations = [
migrations.RenameModel(
old_name='EventSetting',
new_name='Event_SettingsStore',
),
migrations.RenameModel(
old_name='OrganizerSetting',
new_name='Organizer_SettingsStore',
),
migrations.CreateModel(
name='GlobalSettingsObject_SettingsStore',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(db_index=True, max_length=255)),
('value', models.TextField()),
],
),
migrations.RunPython(
migrate_global_settings, migrations.RunPython.noop
),
migrations.DeleteModel(
name='GlobalSetting',
),
migrations.AlterField(
model_name='event_settingsstore',
name='object',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Event'),
),
migrations.AlterField(
model_name='organizer_settingsstore',
name='object',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Organizer'),
),
]

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-04-13 15:37
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0054_auto_20170413_1050'),
]
operations = [
migrations.AddField(
model_name='cartposition',
name='attendee_email',
field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'),
),
migrations.AddField(
model_name='orderposition',
name='attendee_email',
field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'),
),
migrations.AlterField(
model_name='event_settingsstore',
name='key',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='globalsettingsobject_settingsstore',
name='key',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='organizer_settingsstore',
name='key',
field=models.CharField(max_length=255),
),
]

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-04-14 10:44
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0055_auto_20170413_1537'),
]
operations = [
migrations.CreateModel(
name='ItemAddOn',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('min_count', models.PositiveIntegerField(default=0, verbose_name='Minimum number')),
('max_count', models.PositiveIntegerField(default=1, verbose_name='Maximum number')),
],
),
migrations.AddField(
model_name='cartposition',
name='addon_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.CartPosition'),
),
migrations.AddField(
model_name='itemcategory',
name='is_addon',
field=models.BooleanField(default=False, help_text='If selected, the products belonging to this category are not for sale on their own. They can only be bought in combination with a product that has this category configured as a possible source for add-ons.', verbose_name='Products in this category are add-on products'),
),
migrations.AddField(
model_name='orderposition',
name='addon_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.OrderPosition'),
),
migrations.AlterField(
model_name='item',
name='free_price',
field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event. This is currently not supported for products that are bought as an add-on to other products.', verbose_name='Free price input'),
),
migrations.AddField(
model_name='itemaddon',
name='addon_category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addon_to', to='pretixbase.ItemCategory', verbose_name='Category'),
),
migrations.AddField(
model_name='itemaddon',
name='base_item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.Item'),
),
migrations.AlterUniqueTogether(
name='itemaddon',
unique_together=set([('base_item', 'addon_category')]),
),
]

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-05-01 21:16
from __future__ import unicode_literals
import i18nfield.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0056_auto_20170414_1044'),
]
operations = [
migrations.AlterModelOptions(
name='itemaddon',
options={'ordering': ('position', 'pk')},
),
migrations.AddField(
model_name='itemaddon',
name='position',
field=models.PositiveIntegerField(default=0, verbose_name='Position'),
),
migrations.AddField(
model_name='itemvariation',
name='description',
field=i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the variation name in lists.', null=True, verbose_name='Description'),
),
]

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-04-29 10:20
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0052_team_teaminvite'),
]
operations = [
migrations.RemoveField(
model_name='eventpermission',
name='event',
),
migrations.RemoveField(
model_name='eventpermission',
name='user',
),
migrations.RemoveField(
model_name='organizerpermission',
name='organizer',
),
migrations.RemoveField(
model_name='organizerpermission',
name='user',
),
migrations.RemoveField(
model_name='event',
name='permitted',
),
migrations.RemoveField(
model_name='organizer',
name='permitted',
),
migrations.AlterField(
model_name='team',
name='can_change_teams',
field=models.BooleanField(default=False, verbose_name='Can change teams and permissions'),
),
migrations.AlterField(
model_name='team',
name='limit_events',
field=models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events'),
),
migrations.DeleteModel(
name='EventPermission',
),
migrations.DeleteModel(
name='OrganizerPermission',
),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-05-04 07:06
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0058_auto_20170429_1020'),
]
operations = [
migrations.AddField(
model_name='checkin',
name='nonce',
field=models.CharField(blank=True, max_length=190, null=True),
),
]

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-05-10 10:27
from __future__ import unicode_literals
import i18nfield.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0059_checkin_nonce'),
]
operations = [
migrations.AddField(
model_name='event',
name='date_admission',
field=models.DateTimeField(blank=True, null=True, verbose_name='Admission time'),
),
migrations.AlterField(
model_name='event',
name='location',
field=i18nfield.fields.I18nTextField(blank=True, max_length=200, null=True, verbose_name='Location'),
),
]

View File

@@ -1,14 +1,15 @@
from ..settings import GlobalSettingsObject_SettingsStore
from .auth import U2FDevice, User
from .base import CachedFile, LoggedModel, cachedfile_name
from .checkin import Checkin
from .event import (
Event, EventLock, EventPermission, EventSetting, RequiredAction,
Event, Event_SettingsStore, EventLock, RequiredAction,
generate_invite_token,
)
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
itempicture_upload_to,
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
Quota, itempicture_upload_to,
)
from .log import LogEntry
from .orders import (
@@ -17,6 +18,6 @@ from .orders import (
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
generate_secret,
)
from .organizer import Organizer, OrganizerPermission, OrganizerSetting
from .organizer import Organizer, Organizer_SettingsStore, Team, TeamInvite
from .vouchers import Voucher
from .waitinglist import WaitingListEntry

View File

@@ -1,9 +1,12 @@
from typing import Union
from django.conf import settings
from django.contrib.auth.models import (
AbstractBaseUser, BaseUserManager, PermissionsMixin,
)
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from django_otp.models import Device
@@ -81,6 +84,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
objects = UserManager()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._teamcache = {}
class Meta:
verbose_name = _("User")
verbose_name_plural = _("Users")
@@ -147,6 +154,103 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return LogEntry.objects.filter(content_type=ContentType.objects.get_for_model(User),
object_id=self.pk)
def _get_teams_for_organizer(self, organizer):
if 'o{}'.format(organizer.pk) not in self._teamcache:
self._teamcache['o{}'.format(organizer.pk)] = list(self.teams.filter(organizer=organizer))
return self._teamcache['o{}'.format(organizer.pk)]
def _get_teams_for_event(self, organizer, event):
if 'e{}'.format(event.pk) not in self._teamcache:
self._teamcache['e{}'.format(event.pk)] = list(self.teams.filter(organizer=organizer).filter(
Q(all_events=True) | Q(limit_events=event)
))
return self._teamcache['e{}'.format(event.pk)]
class SuperuserPermissionSet:
def __contains__(self, item):
return True
def get_event_permission_set(self, organizer, event) -> Union[set, SuperuserPermissionSet]:
"""
Gets a set of permissions (as strings) that a user holds for a particular event
:param organizer: The organizer of the event
:param event: The event to check
:return: set in case of a normal user and a SuperuserPermissionSet in case of a superuser (fake object where
a in b always returns true).
"""
if self.is_superuser:
return self.SuperuserPermissionSet()
teams = self._get_teams_for_event(organizer, event)
return set.union(*[t.permission_set() for t in teams])
def get_organizer_permission_set(self, organizer) -> Union[set, SuperuserPermissionSet]:
"""
Gets a set of permissions (as strings) that a user holds for a particular organizer
:param organizer: The organizer of the event
:return: set in case of a normal user and a SuperuserPermissionSet in case of a superuser (fake object where
a in b always returns true).
"""
if self.is_superuser:
return self.SuperuserPermissionSet()
teams = self._get_teams_for_organizer(organizer)
return set.union(*[t.permission_set() for t in teams])
def has_event_permission(self, organizer, event, perm_name=None) -> bool:
"""
Checks if this user is part of any team that grants access of type ``perm_name``
to the event ``event``.
:param organizer: The organizer of the event
:param event: The event to check
:param perm_name: The permission, e.g. ``can_change_teams``
:return: bool
"""
if self.is_superuser:
return True
teams = self._get_teams_for_event(organizer, event)
if teams:
self._teamcache['e{}'.format(event.pk)] = teams
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
return True
return False
def has_organizer_permission(self, organizer, perm_name=None):
"""
Checks if this user is part of any team that grants access of type ``perm_name``
to the organizer ``organizer``.
:param organizer: The organizer to check
:param perm_name: The permission, e.g. ``can_change_teams``
:return: bool
"""
if self.is_superuser:
return True
teams = self._get_teams_for_organizer(organizer)
if teams:
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
return True
return False
def get_events_with_any_permission(self):
"""
Returns a queryset of events the user has any permissions to.
:return: Iterable of Events
"""
from .event import Event
if self.is_superuser:
return Event.objects.all()
return Event.objects.filter(
Q(organizer_id__in=self.teams.filter(all_events=True).values_list('organizer', flat=True))
| Q(id__in=self.teams.values_list('limit_events__id', flat=True))
)
class U2FDevice(Device):
json_data = models.TextField()

View File

@@ -2,6 +2,7 @@ import json
import uuid
from django.contrib.contenttypes.models import ContentType
from django.core import checks
from django.db import models
from django.db.models.signals import post_delete
from django.dispatch import receiver
@@ -34,7 +35,6 @@ def cached_file_delete(sender, instance, **kwargs):
class LoggingMixin:
def log_action(self, action, data=None, user=None):
"""
Create a LogEntry object that is related to this object.
@@ -59,7 +59,6 @@ class LoggingMixin:
class LoggedModel(models.Model, LoggingMixin):
class Meta:
abstract = True
@@ -74,3 +73,38 @@ class LoggedModel(models.Model, LoggingMixin):
return LogEntry.objects.filter(
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
).select_related('user', 'event')
class EventBoundModelMixin:
event_lookup = 'event'
@classmethod
def for_event(cls, event):
return cls.all.filter(**{
cls.event_lookup: event
})
@classmethod
def check(cls, **kwargs):
try:
errors = super(cls).check(**kwargs)
except AttributeError:
errors = []
if hasattr(cls, 'objects'):
errors.append(
checks.Error(
'Default model manager "objects" defined.',
hint='Replace the objects manager by a manager called "all".',
obj=cls,
id='pretixbase.E001',
)
)
if not hasattr(cls, 'all'):
errors.append(
checks.Error(
'Model manager "all" not defined.',
obj=cls,
id='pretixbase.E002',
)
)
return errors

View File

@@ -8,3 +8,4 @@ class Checkin(models.Model):
"""
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins')
datetime = models.DateTimeField(default=now)
nonce = models.CharField(max_length=190, null=True, blank=True)

View File

@@ -11,22 +11,20 @@ from django.core.validators import RegexValidator
from django.db import models
from django.template.defaultfilters import date as _date
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _
from i18nfield.fields import I18nCharField
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.email import CustomSMTPBackend
from pretix.base.models.base import LoggedModel
from pretix.base.settings import SettingsProxy
from pretix.base.validators import EventSlugBlacklistValidator
from pretix.helpers.daterange import daterange
from .auth import User
from ..settings import settings_hierarkey
from .organizer import Organizer
from .settings import EventSetting
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
class Event(LoggedModel):
"""
This model represents an event. An event is anything you can buy
@@ -59,6 +57,7 @@ class Event(LoggedModel):
"""
settings_namespace = 'event'
CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES]
organizer = models.ForeignKey(Organizer, related_name="events", on_delete=models.PROTECT)
name = I18nCharField(
max_length=200,
@@ -79,14 +78,15 @@ class Event(LoggedModel):
verbose_name=_("Short form"),
)
live = models.BooleanField(default=False, verbose_name=_("Shop is live"))
permitted = models.ManyToManyField(User, through='EventPermission',
related_name="events", )
currency = models.CharField(max_length=10,
verbose_name=_("Default currency"),
choices=CURRENCY_CHOICES,
default=settings.DEFAULT_CURRENCY)
date_from = models.DateTimeField(verbose_name=_("Event start time"))
date_to = models.DateTimeField(null=True, blank=True,
verbose_name=_("Event end time"))
date_admission = models.DateTimeField(null=True, blank=True,
verbose_name=_("Admission time"))
is_public = models.BooleanField(default=False,
verbose_name=_("Visible in public lists"),
help_text=_("If selected, this event may show up on the ticket system's start page "
@@ -101,7 +101,7 @@ class Event(LoggedModel):
verbose_name=_("Start of presale"),
help_text=_("No products will be sold before this date."),
)
location = I18nCharField(
location = I18nTextField(
null=True, blank=True,
max_length=200,
verbose_name=_("Location"),
@@ -139,7 +139,7 @@ class Event(LoggedModel):
return []
return self.plugins.split(",")
def get_date_from_display(self, tz=None) -> str:
def get_date_from_display(self, tz=None, show_times=True) -> str:
"""
Returns a formatted string containing the start date of the event with respect
to the current locale and to the ``show_times`` setting.
@@ -147,7 +147,17 @@ class Event(LoggedModel):
tz = tz or pytz.timezone(self.settings.timezone)
return _date(
self.date_from.astimezone(tz),
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
"DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT"
)
def get_time_from_display(self, tz=None) -> str:
"""
Returns a formatted string containing the start time of the event, ignoring
the ``show_times`` setting.
"""
tz = tz or pytz.timezone(self.settings.timezone)
return _date(
self.date_from.astimezone(tz), "TIME_FORMAT"
)
def get_date_to_display(self, tz=None) -> str:
@@ -181,17 +191,6 @@ class Event(LoggedModel):
return ObjectRelatedCache(self)
@cached_property
def settings(self) -> SettingsProxy:
"""
Returns an object representing this event's settings.
"""
try:
return SettingsProxy(self, type=EventSetting, parent=self.organizer)
except Organizer.DoesNotExist:
# Should only happen when creating new events
return SettingsProxy(self, type=EventSetting)
@property
def presale_has_ended(self):
if self.presale_end and now() > self.presale_end:
@@ -235,12 +234,14 @@ class Event(LoggedModel):
), tz)
def copy_data_from(self, other):
from . import ItemCategory, Item, Question, Quota
from . import ItemAddOn, Item, Question, Quota
from ..signals import event_copy_data
self.plugins = other.plugins
self.save()
category_map = {}
for c in ItemCategory.objects.filter(event=other):
for c in other.categories.all():
category_map[c.pk] = c
c.pk = None
c.event = self
@@ -264,6 +265,12 @@ class Event(LoggedModel):
v.item = i
v.save()
for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'):
ia.pk = None
ia.base_item = item_map[ia.base_item.pk]
ia.addon_category = category_map[ia.addon_category.pk]
ia.save()
for q in Quota.objects.filter(event=other).prefetch_related('items', 'variations'):
items = list(q.items.all())
vars = list(q.variations.all())
@@ -271,7 +278,8 @@ class Event(LoggedModel):
q.event = self
q.save()
for i in items:
q.items.add(item_map[i.pk])
if i.pk in item_map:
q.items.add(item_map[i.pk])
for v in vars:
q.variations.add(variation_map[v.pk])
@@ -288,7 +296,7 @@ class Event(LoggedModel):
o.question = q
o.save()
for s in EventSetting.objects.filter(object=other):
for s in other.settings._objects.all():
s.object = self
s.pk = None
if s.value.startswith('file://'):
@@ -301,74 +309,13 @@ class Event(LoggedModel):
s.value = 'file://' + newname
s.save()
event_copy_data.send(sender=self, other=other)
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
class EventPermission(models.Model):
"""
The relation between an Event and a User who has permissions to
access an event.
:param event: The event this permission refers to
:type event: Event
:param user: The user this permission set applies to
:type user: User
:param can_change_settings: If ``True``, the user can change all basic settings for this event.
:type can_change_settings: bool
:param can_change_items: If ``True``, the user can change and add items and related objects for this event.
:type can_change_items: bool
:param can_view_orders: If ``True``, the user can inspect details of all orders.
:type can_view_orders: bool
:param can_change_orders: If ``True``, the user can change details of orders
:type can_change_orders: bool
"""
event = models.ForeignKey(Event, related_name="user_perms", on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name="event_perms", on_delete=models.CASCADE, null=True, blank=True)
invite_email = models.EmailField(null=True, blank=True)
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
can_change_settings = models.BooleanField(
default=True,
verbose_name=_("Can change event settings")
)
can_change_items = models.BooleanField(
default=True,
verbose_name=_("Can change product settings")
)
can_view_orders = models.BooleanField(
default=True,
verbose_name=_("Can view orders")
)
can_change_permissions = models.BooleanField(
default=True,
verbose_name=_("Can change permissions")
)
can_change_orders = models.BooleanField(
default=True,
verbose_name=_("Can change orders")
)
can_view_vouchers = models.BooleanField(
default=True,
verbose_name=_("Can view vouchers")
)
can_change_vouchers = models.BooleanField(
default=True,
verbose_name=_("Can change vouchers")
)
class Meta:
verbose_name = _("Event permission")
verbose_name_plural = _("Event permissions")
def __str__(self):
return _("%(name)s on %(object)s") % {
'name': str(self.user),
'object': str(self.event),
}
class EventLock(models.Model):
event = models.CharField(max_length=36, primary_key=True)
date = models.DateTimeField(auto_now=True)

View File

@@ -5,6 +5,7 @@ from decimal import Decimal
from typing import Tuple
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.functional import cached_property
@@ -13,12 +14,12 @@ from django.utils.translation import ugettext_lazy as _
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
from pretix.base.models.base import EventBoundModelMixin, LoggedModel
from .event import Event
class ItemCategory(LoggedModel):
class ItemCategory(EventBoundModelMixin, LoggedModel):
"""
Items can be sorted into these categories.
@@ -44,6 +45,15 @@ class ItemCategory(LoggedModel):
position = models.IntegerField(
default=0
)
is_addon = models.BooleanField(
default=False,
verbose_name=_('Products in this category are add-on products'),
help_text=_('If selected, the products belonging to this category are not for sale on their own. They can '
'only be bought in combination with a product that has this category configured as a possible '
'source for add-ons.')
)
all = models.Manager()
class Meta:
verbose_name = _("Product category")
@@ -51,6 +61,8 @@ class ItemCategory(LoggedModel):
ordering = ('position', 'id')
def __str__(self):
if self.is_addon:
return _('{category} (Add-On products)').format(category=str(self.name))
return str(self.name)
def delete(self, *args, **kwargs):
@@ -113,6 +125,8 @@ class Item(LoggedModel):
:type allow_cancel: bool
:param max_per_order: Maximum number of times this item can be in an order. None for unlimited.
:type max_per_order: int
:param min_per_order: Minimum number of times this item needs to be in an order if bought at all. None for unlimited.
:type min_per_order: int
"""
event = models.ForeignKey(
@@ -153,7 +167,8 @@ class Item(LoggedModel):
verbose_name=_("Free price input"),
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
"additional donations for your event.")
"additional donations for your event. This is currently not supported for products that are "
"bought as an add-on to other products.")
)
tax_rate = models.DecimalField(
verbose_name=_("Taxes included in percent"),
@@ -205,6 +220,12 @@ class Item(LoggedModel):
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own '
'and you can cancel orders at all times, regardless of this setting')
)
min_per_order = models.IntegerField(
verbose_name=_('Minimum amount per order'),
null=True, blank=True,
help_text=_('This product can only be bought if it is added to the cart at least this many times. If you keep '
'the field empty or set it to 0, there is no special limit for this product.')
)
max_per_order = models.IntegerField(
verbose_name=_('Maximum amount per order'),
null=True, blank=True,
@@ -212,6 +233,8 @@ class Item(LoggedModel):
'empty or set it to 0, there is no special limit for this product. The limit for the maximum '
'number of items in the whole order applies regardless.')
)
# !!! Attention: If you add new fields here, also add them to the copying code in
# pretix/control/views/item.py if applicable.
class Meta:
verbose_name = _("Product")
@@ -289,6 +312,8 @@ class ItemVariation(models.Model):
:type item: Item
:param value: A string defining this variation
:type value: str
:param description: A short description
:type description: str
:param active: Whether this variation is being sold.
:type active: bool
:param default_price: This variation's default price
@@ -306,6 +331,11 @@ class ItemVariation(models.Model):
default=True,
verbose_name=_("Active"),
)
description = I18nTextField(
verbose_name=_("Description"),
help_text=_("This is shown below the variation name in lists."),
null=True, blank=True,
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
@@ -369,6 +399,43 @@ class ItemVariation(models.Model):
return self.position < other.position
class ItemAddOn(models.Model):
"""
An instance of this model indicates that buying a ticket of the time ``base_item``
allows you to add up to ``max_count`` items from the category ``addon_category``
to your order that will be associated with the base item.
"""
base_item = models.ForeignKey(
Item,
related_name='addons'
)
addon_category = models.ForeignKey(
ItemCategory,
related_name='addon_to',
verbose_name=_('Category')
)
min_count = models.PositiveIntegerField(
default=0,
verbose_name=_('Minimum number')
)
max_count = models.PositiveIntegerField(
default=1,
verbose_name=_('Maximum number')
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
class Meta:
unique_together = (('base_item', 'addon_category'),)
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.'))
class Question(LoggedModel):
"""
A question is an input field that can be used to extend a ticket by custom information,
@@ -623,6 +690,7 @@ class Quota(LoggedModel):
func = 'GREATEST'
return Voucher.objects.filter(
Q(event=self.event) &
Q(block_quota=True) &
Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) &
Q(Q(self._position_lookup) | Q(quota=self))
@@ -642,6 +710,7 @@ class Quota(LoggedModel):
now_dt = now_dt or now()
return CartPosition.objects.filter(
Q(event=self.event) &
Q(expires__gte=now_dt) &
~Q(
Q(voucher__isnull=False) & Q(voucher__block_quota=True)
@@ -655,14 +724,14 @@ class Quota(LoggedModel):
# This query has beeen benchmarked against a Count('id', distinct=True) aggregate and won by a small margin.
return OrderPosition.objects.filter(
self._position_lookup, order__status=Order.STATUS_PENDING,
self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event
).values('id').distinct().count()
def count_paid_orders(self):
from pretix.base.models import Order, OrderPosition
return OrderPosition.objects.filter(
self._position_lookup, order__status=Order.STATUS_PAID
self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event
).values('id').distinct().count()
@cached_property

View File

@@ -394,6 +394,8 @@ class AbstractPosition(models.Model):
:type price: decimal.Decimal
:param attendee_name: The attendee's name, if entered.
:type attendee_name: str
:param attendee_email: The attendee's email, if entered.
:type attendee_email: str
:param voucher: A voucher that has been applied to this sale
:type voucher: Voucher
"""
@@ -418,9 +420,17 @@ class AbstractPosition(models.Model):
blank=True, null=True,
help_text=_("Empty, if this product is not an admission ticket")
)
attendee_email = models.EmailField(
verbose_name=_("Attendee email"),
blank=True, null=True,
help_text=_("Empty, if this product is not an admission ticket")
)
voucher = models.ForeignKey(
'Voucher', null=True, blank=True
)
addon_to = models.ForeignKey(
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
)
class Meta:
abstract = True
@@ -486,13 +496,19 @@ class OrderPosition(AbstractPosition):
from . import Voucher
ops = []
for i, cartpos in enumerate(cp):
cp_mapping = {}
# The sorting key ensures that all addons come directly after the position they refer to
for i, cartpos in enumerate(sorted(cp, key=lambda c: (c.addon_to_id or c.pk, c.addon_to_id or 0))):
op = OrderPosition(order=order)
for f in AbstractPosition._meta.fields:
setattr(op, f.name, getattr(cartpos, f.name))
if f.name == 'addon_to':
setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id))
else:
setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax()
op.positionid = i + 1
op.save()
cp_mapping[cartpos.pk] = op
for answ in cartpos.answers.all():
answ.orderposition = op
answ.cartposition = None

View File

@@ -3,17 +3,16 @@ import string
from django.core.validators import RegexValidator
from django.db import models
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from pretix.base.models.base import LoggedModel
from pretix.base.settings import SettingsProxy
from pretix.base.validators import OrganizerSlugBlacklistValidator
from ..settings import settings_hierarkey
from .auth import User
from .settings import OrganizerSetting
@settings_hierarkey.add(cache_namespace='organizer')
class Organizer(LoggedModel):
"""
This model represents an entity organizing events, e.g. a company, institution,
@@ -43,8 +42,6 @@ class Organizer(LoggedModel):
],
verbose_name=_("Short form"),
)
permitted = models.ManyToManyField(User, through='OrganizerPermission',
related_name="organizers")
class Meta:
verbose_name = _("Organizer")
@@ -59,14 +56,6 @@ class Organizer(LoggedModel):
self.get_cache().clear()
return obj
@cached_property
def settings(self) -> SettingsProxy:
"""
Returns an object representing this organizer's settings
"""
from pretix.base.settings import GlobalSettingsObject
return SettingsProxy(self, type=OrganizerSetting, parent=GlobalSettingsObject())
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
@@ -83,39 +72,131 @@ def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
class OrganizerPermission(models.Model):
class Team(LoggedModel):
"""
The relation between an Organizer and a User who has permissions to
access an organizer profile.
A team is a collection of people given certain access rights to one or more events of an organizer.
:param organizer: The organizer this relation refers to
:param name: The name of this team
:type name: str
:param organizer: The organizer this team belongs to
:type organizer: Organizer
:param user: The user this set of permissions is valid for
:type user: User
:param can_create_events: Whether or not this user can create new events with this
organizer account.
:param members: A set of users who belong to this team
:param all_events: Whether this team has access to all events of this organizer
:type all_events: bool
:param limit_events: A set of events this team has access to. Irrelevant if ``all_events`` is ``True``.
:param can_create_events: Whether or not the members can create new events with this organizer account.
:type can_create_events: bool
:param can_change_teams: If ``True``, the members can change the teams of this organizer account.
:type can_change_teams: bool
:param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account.
:type can_change_organizer_settings: bool
:param can_change_event_settings: If ``True``, the members can change the settings of the associated events.
:type can_change_event_settings: bool
:param can_change_items: If ``True``, the members can change and add items and related objects for the associated events.
:type can_change_items: bool
:param can_view_orders: If ``True``, the members can inspect details of all orders of the associated events.
:type can_view_orders: bool
:param can_change_orders: If ``True``, the members can change details of orders of the associated events.
:type can_change_orders: bool
:param can_view_vouchers: If ``True``, the members can inspect details of all vouchers of the associated events.
:type can_view_vouchers: bool
:param can_change_vouchers: If ``True``, the members can change and create vouchers for the associated events.
:type can_change_vouchers: bool
"""
organizer = models.ForeignKey(Organizer, related_name="teams", on_delete=models.CASCADE)
name = models.CharField(max_length=190, verbose_name=_("Team name"))
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
organizer = models.ForeignKey(Organizer, related_name="user_perms", on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name="organizer_perms", on_delete=models.CASCADE, null=True, blank=True)
invite_email = models.EmailField(null=True, blank=True)
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
can_create_events = models.BooleanField(
default=True,
default=False,
verbose_name=_("Can create events"),
)
can_change_permissions = models.BooleanField(
default=True,
verbose_name=_("Can change permissions"),
can_change_teams = models.BooleanField(
default=False,
verbose_name=_("Can change teams and permissions"),
)
can_change_organizer_settings = models.BooleanField(
default=False,
verbose_name=_("Can change organizer settings")
)
class Meta:
verbose_name = _("Organizer permission")
verbose_name_plural = _("Organizer permissions")
can_change_event_settings = models.BooleanField(
default=False,
verbose_name=_("Can change event settings")
)
can_change_items = models.BooleanField(
default=False,
verbose_name=_("Can change product settings")
)
can_view_orders = models.BooleanField(
default=False,
verbose_name=_("Can view orders")
)
can_change_orders = models.BooleanField(
default=False,
verbose_name=_("Can change orders")
)
can_view_vouchers = models.BooleanField(
default=False,
verbose_name=_("Can view vouchers")
)
can_change_vouchers = models.BooleanField(
default=False,
verbose_name=_("Can change vouchers")
)
def __str__(self) -> str:
return _("%(name)s on %(object)s") % {
'name': str(self.user),
'name': str(self.name),
'object': str(self.organizer),
}
def permission_set(self) -> set:
attribs = dir(self)
return {
a for a in attribs if a.startswith('can_') and self.has_permission(a)
}
@property
def can_change_settings(self): # Legacy compatiblilty
return self.can_change_event_settings
def has_permission(self, perm_name):
try:
return getattr(self, perm_name)
except AttributeError:
raise ValueError('Invalid required permission: %s' % perm_name)
def permission_for_event(self, event):
if self.all_events:
return event.organizer_id == self.organizer_id
else:
return self.limit_events.filter(pk=event.pk).exists()
class Meta:
verbose_name = _("Team")
verbose_name_plural = _("Teams")
class TeamInvite(models.Model):
"""
A TeamInvite represents someone who has been invited to a team but hasn't accept the invitation
yet.
:param team: The team the person is invited to
:type team: Team
:param email: The email the invite has been sent to
:type email: str
:param token: The secret required to redeem the invite
:type token: str
"""
team = models.ForeignKey(Team, related_name="invites", on_delete=models.CASCADE)
email = models.EmailField(null=True, blank=True)
token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
def __str__(self) -> str:
return _("Invite to team '{team}' for '{email}'").format(
team=str(self.team), email=self.email
)

View File

@@ -1,34 +0,0 @@
from django.db import models
class GlobalSetting(models.Model):
"""
A global setting is a key-value setting which can be set for a
pretix instance. It will be inherited by all events and organizers.
It is filled via the register_global_settings signal.
"""
key = models.CharField(max_length=255, primary_key=True)
value = models.TextField()
def __init__(self, *args, object=None, **kwargs):
super().__init__(*args, **kwargs)
class OrganizerSetting(models.Model):
"""
An organizer setting is a key-value setting which can be set for an
organizer. It will be inherited by the events of this organizer
"""
object = models.ForeignKey('Organizer', related_name='setting_objects', on_delete=models.CASCADE)
key = models.CharField(max_length=255)
value = models.TextField()
class EventSetting(models.Model):
"""
An event setting is a key-value setting which can be set for a
specific event
"""
object = models.ForeignKey('Event', related_name='setting_objects', on_delete=models.CASCADE)
key = models.CharField(max_length=255)
value = models.TextField()

View File

@@ -1,7 +1,7 @@
from collections import OrderedDict
from datetime import date
from decimal import Decimal
from typing import Any, Dict
from typing import Any, Dict, Union
import pytz
from django import forms
@@ -150,9 +150,9 @@ class BasePaymentProvider:
forms.BooleanField(
label=_('Calculate the fee from the total value including the fee.'),
help_text=_('We recommend you to enable this if you want your users to pay the payment fees of your '
'payment provider. <a href="/control/help/payment/fee_reverse" target="_blank">Click here '
'payment provider. <a href="{docs_url}" target="_blank">Click here '
'for detailled information on what this does.</a> Don\'t forget to set the correct fees '
'above!'),
'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'),
required=False
)),
('_invoice_text',
@@ -266,7 +266,7 @@ class BasePaymentProvider:
"""
raise NotImplementedError() # NOQA
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> "bool|str":
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
"""
Will be called after the user selects this provider as his payment method.
If you provided a form to the user to enter payment data, this method should
@@ -394,14 +394,14 @@ class BasePaymentProvider:
"""
return False
def retry_prepare(self, request: HttpRequest, order: Order) -> "bool|str":
def retry_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Deprecated, use order_prepare instead
"""
raise DeprecationWarning('retry_prepare is deprecated, use order_prepare instead')
return self.order_prepare(request, order)
def order_prepare(self, request: HttpRequest, order: Order) -> "bool|str":
def order_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Will be called if the user retries to pay an unpaid order (after the user filled in
e.g. the form returned by :py:meth:`payment_form`) or if the user changes the payment
@@ -409,6 +409,10 @@ class BasePaymentProvider:
It should return and report errors the same way as :py:meth:`checkout_prepare`, but
receives an ``Order`` object instead of a cart object.
Note: The ``Order`` object given to this method might be different from the version
stored in the database as it's total will already contain the payment fee for the
new payment method.
"""
form = self.payment_form(request)
if form.is_valid():
@@ -458,7 +462,7 @@ class BasePaymentProvider:
return '<div class="alert alert-warning">%s</div>' % _('The money can not be automatically refunded, '
'please transfer the money back manually.')
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> "bool|str":
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Will be called if the event administrator confirms the refund.
@@ -523,7 +527,7 @@ class FreeOrderProvider(BasePaymentProvider):
def order_control_refund_render(self, order: Order) -> str:
return ''
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> "bool|str":
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Will be called if the event administrator confirms the refund.

View File

@@ -1,4 +1,4 @@
from collections import Counter, namedtuple
from collections import Counter, defaultdict, namedtuple
from datetime import timedelta
from decimal import Decimal
from typing import List, Optional
@@ -27,6 +27,7 @@ error_messages = {
'busy': _('We were not able to process your request completely as the '
'server was too busy. Please try again.'),
'empty': _('You did not select any products.'),
'unknown_position': _('Unknown cart position.'),
'not_for_sale': _('You selected a product which is not available for sale.'),
'unavailable': _('Some of the products you selected are no longer available. '
'Please see below for details.'),
@@ -34,6 +35,9 @@ error_messages = {
'the quantity you selected. Please see below for details.'),
'max_items': _("You cannot select more than %s items per order."),
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s."),
'min_items_per_product': _("You need to select at least %(min)s items of the product %(product)s."),
'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than "
"%(min)s items of it."),
'not_started': _('The presale period for this event has not yet started.'),
'ended': _('The presale period has ended.'),
'price_too_high': _('The entered price is to high.'),
@@ -45,11 +49,18 @@ error_messages = {
'voucher_expired': _('This voucher is expired.'),
'voucher_invalid_item': _('This voucher is not valid for this product.'),
'voucher_required': _('You need a valid voucher code to order this product.'),
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
'product %(base)s.'),
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
}
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas'))
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas'))
@@ -74,7 +85,7 @@ class CartManager:
def positions(self):
return CartPosition.objects.filter(
Q(cart_id=self.cart_id) & Q(event=self.event)
)
).select_related('item')
def _calculate_expiry(self):
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
@@ -97,9 +108,15 @@ class CartManager:
def _update_items_cache(self, item_ids: List[int], variation_ids: List[int]):
self._items_cache.update(
{i.pk: i for i in self.event.items.prefetch_related('quotas').filter(
id__in=[i for i in item_ids if i and i not in self._items_cache]
)}
{
i.pk: i
for i
in self.event.items.select_related('category').prefetch_related(
'addons', 'addons__addon_category', 'quotas'
).filter(
id__in=[i for i in item_ids if i and i not in self._items_cache]
)
}
)
self._variations_cache.update(
{v.pk: v for v in
@@ -111,9 +128,10 @@ class CartManager:
)
def _check_max_cart_size(self):
cartsize = self.positions.count()
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation)])
cartsize = self.positions.filter(addon_to__isnull=True).count()
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
not op.position.addon_to_id])
if cartsize > int(self.event.settings.max_items_per_order):
# TODO: i18n plurals
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
@@ -133,7 +151,10 @@ class CartManager:
raise CartError(error_messages['voucher_invalid_item'])
if isinstance(op, self.AddOperation):
if op.item.max_per_order:
if op.item.category and op.item.category.is_addon and not op.addon_to:
raise CartError(error_messages['addon_only'])
if op.item.max_per_order or op.item.min_per_order:
new_total = (
len([1 for p in self.positions if p.item_id == op.item.pk]) +
sum([_op.count for _op in self._operations
@@ -143,13 +164,21 @@ class CartManager:
if isinstance(_op, self.RemoveOperation) and _op.position.item_id == op.item.pk])
)
if new_total > op.item.max_per_order:
raise CartError(
_(error_messages['max_items_per_product']) % {
'max': op.item.max_per_order,
'product': op.item.name
}
)
if op.item.max_per_order and new_total > op.item.max_per_order:
raise CartError(
_(error_messages['max_items_per_product']) % {
'max': op.item.max_per_order,
'product': op.item.name
}
)
if op.item.min_per_order and new_total < op.item.min_per_order:
raise CartError(
_(error_messages['min_items_per_product']) % {
'min': op.item.min_per_order,
'product': op.item.name
}
)
def _get_price(self, item: Item, variation: Optional[ItemVariation],
voucher: Optional[Voucher], custom_price: Optional[Decimal]):
@@ -236,7 +265,8 @@ class CartManager:
price = self._get_price(item, variation, voucher, i.get('price'))
op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False
)
self._check_item_constraints(op)
operations.append(op)
@@ -245,29 +275,144 @@ class CartManager:
self._voucher_use_diff += voucher_use_diff
self._operations += operations
def remove_items(self, items: List[dict]):
def remove_item(self, pos_id: int):
# TODO: We could calculate quotadiffs and voucherdiffs here, which would lead to more
# flexible usages (e.g. a RemoveOperation and an AddOperation in the same transaction
# could cancel each other out quota-wise). However, we are not taking this performance
# penalty for now as there is currently no outside interface that would allow building
# such a transaction.
for i in items:
cw = Q(cart_id=self.cart_id) & Q(item_id=i['item']) & Q(event=self.event)
if i['variation']:
cw &= Q(variation_id=i['variation'])
else:
cw &= Q(variation__isnull=True)
# Prefer to delete positions that have the same price as the one the user clicked on, after thet
# prefer the most expensive ones.
cnt = i['count']
if i['price']:
correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(i['price'].replace(",", ".")))[:cnt]
for cp in correctprice:
self._operations.append(self.RemoveOperation(position=cp))
cnt -= len(correctprice)
if cnt > 0:
for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]:
self._operations.append(self.RemoveOperation(position=cp))
try:
cp = self.positions.get(pk=pos_id)
except CartPosition.DoesNotExist:
raise CartError(error_messages['unknown_position'])
self._operations.append(self.RemoveOperation(position=cp))
def clear(self):
# TODO: We could calculate quotadiffs and voucherdiffs here, which would lead to more
# flexible usages (e.g. a RemoveOperation and an AddOperation in the same transaction
# could cancel each other out quota-wise). However, we are not taking this performance
# penalty for now as there is currently no outside interface that would allow building
# such a transaction.
for cp in self.positions.all():
self._operations.append(self.RemoveOperation(position=cp))
def set_addons(self, addons):
self._update_items_cache(
[a['item'] for a in addons],
[a['variation'] for a in addons],
)
# Prepare various containers to hold data later
current_addons = defaultdict(dict) # CartPos -> currently attached add-ons
input_addons = defaultdict(set) # CartPos -> add-ons according to input
selected_addons = defaultdict(set) # CartPos -> final desired set of add-ons
cpcache = {} # CartPos.pk -> CartPos
quota_diff = Counter() # Quota -> Number of usages
operations = []
available_categories = defaultdict(set) # CartPos -> Category IDs to choose from
toplevel_cp = self.positions.filter(
addon_to__isnull=True
).prefetch_related(
'addons', 'item__addons', 'item__addons__addon_category'
).select_related('item', 'variation')
# Prefill some of the cache containers
for cp in toplevel_cp:
available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()}
cpcache[cp.pk] = cp
current_addons[cp] = {
(a.item_id, a.variation_id): a
for a in cp.addons.all()
}
# Create operations, perform various checks
for a in addons:
# Check whether the specified items are part of what we just fetched from the database
# If they are not, the user supplied item IDs which either do not exist or belong to
# a different event
if a['item'] not in self._items_cache or (a['variation'] and a['variation'] not in self._variations_cache):
raise CartError(error_messages['not_for_sale'])
# Only attach addons to things that are actually in this user's cart
if a['addon_to'] not in cpcache:
raise CartError(error_messages['addon_invalid_base'])
cp = cpcache[a['addon_to']]
item = self._items_cache[a['item']]
variation = self._variations_cache[a['variation']] if a['variation'] is not None else None
if item.category_id not in available_categories[cp.pk]:
raise CartError(error_messages['addon_invalid_base'])
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
if not quotas:
raise CartError(error_messages['unavailable'])
# Every item can be attached to very CartPosition at most once
if a['item'] in ([_a[0] for _a in input_addons[cp.id]]):
raise CartError(error_messages['addon_duplicate_item'])
input_addons[cp.id].add((a['item'], a['variation']))
selected_addons[cp.id, item.category_id].add((a['item'], a['variation']))
if (a['item'], a['variation']) not in current_addons[cp]:
# This add-on is new, add it to the cart
for quota in quotas:
quota_diff[quota] += 1
price = self._get_price(item, variation, None, None)
op = self.AddOperation(
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp
)
self._check_item_constraints(op)
operations.append(op)
# Check constraints on the add-on combinations
for cp in toplevel_cp:
item = cp.item
for iao in item.addons.all():
selected = selected_addons[cp.id, iao.addon_category_id]
if len(selected) > iao.max_count:
# TODO: Proper i18n
# TODO: Proper pluralization
raise CartError(
error_messages['addon_max_count'],
{
'base': str(item.name),
'max': iao.max_count,
'cat': str(iao.addon_category.name),
}
)
elif len(selected) < iao.min_count:
# TODO: Proper i18n
# TODO: Proper pluralization
raise CartError(
error_messages['addon_min_count'],
{
'base': str(item.name),
'min': iao.min_count,
'cat': str(iao.addon_category.name),
}
)
# Detect removed add-ons and create RemoveOperations
for cp, al in current_addons.items():
for k, v in al.items():
if k not in input_addons[cp.id]:
if v.expires > self.now_dt:
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
for quota in quotas:
quota_diff[quota] -= 1
op = self.RemoveOperation(position=v)
operations.append(op)
self._quota_diff += quota_diff
self._operations += operations
def _get_quota_availability(self):
quotas_ok = {}
@@ -298,12 +443,47 @@ class CartManager:
return vouchers_ok
def _check_min_per_product(self):
per_product = Counter()
min_per_product = {}
for p in self.positions:
per_product[p.item_id] += 1
min_per_product[p.item.pk] = p.item.min_per_order
for op in self._operations:
if isinstance(op, self.AddOperation):
per_product[op.item.pk] += op.count
min_per_product[op.item.pk] = op.item.min_per_order
elif isinstance(op, self.RemoveOperation):
per_product[op.position.item_id] -= 1
min_per_product[op.position.item.pk] = op.position.item.min_per_order
err = None
for itemid, num in per_product.items():
min_p = min_per_product[itemid]
if min_p and num < min_p:
self._operations = [o for o in self._operations if not (
isinstance(o, self.AddOperation) and o.item.pk == itemid
)]
removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)]
for p in self.positions:
if p.item_id == itemid and p.pk not in removals:
self._operations.append(self.RemoveOperation(position=p))
err = _(error_messages['min_items_per_product_removed']) % {
'min': min_p,
'product': p.item.name
}
return err
def _perform_operations(self):
vouchers_ok = self._get_voucher_availability()
quotas_ok = self._get_quota_availability()
err = None
new_cart_positions = []
err = err or self._check_min_per_product()
self._operations.sort(key=lambda a: self.order[type(a)])
for op in self._operations:
@@ -342,7 +522,8 @@ class CartManager:
new_cart_positions.append(CartPosition(
event=self.event, item=op.item, variation=op.variation,
price=op.price, expires=self._expiry,
cart_id=self.cart_id, voucher=op.voucher
cart_id=self.cart_id, voucher=op.voucher,
addon_to=op.addon_to if op.addon_to else None
))
elif isinstance(op, self.ExtendOperation):
if available_count == 1:
@@ -377,7 +558,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
:param items: A list of tuple of the form (item id, variation id or None, number, custom_price, voucher)
:param items: A list of dicts with the keys item, variation, number, custom_price, voucher
:param session: Session ID of a guest
:param coupon: A coupon that should also be reeemed
:raises CartError: On any error that occured
@@ -396,11 +577,11 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en') -> None:
def remove_cart_position(self, event: int, position: int, cart_id: str=None, locale='en') -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
:param items: A list of tuple of the form (item id, variation id or None, number)
:param position: A cart position ID
:param session: Session ID of a guest
"""
with language(locale):
@@ -408,7 +589,48 @@ def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=Non
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
cm.remove_items(items)
cm.remove_item(position)
cm.commit()
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
:param session: Session ID of a guest
"""
with language(locale):
event = Event.objects.get(id=event)
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
cm.clear()
cm.commit()
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en') -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
:param addons: A list of dicts with the keys addon_to, item, variation
:param session: Session ID of a guest
"""
with language(locale):
event = Event.objects.get(id=event)
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
cm.set_addons(addons)
cm.commit()
except LockTimeoutException:
self.retry()

View File

@@ -21,3 +21,4 @@ def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) ->
file.filename, file.type, data = ex.render(form_data)
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
file.save()
return file.pk

View File

@@ -72,6 +72,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
desc = str(p.item.name)
if p.variation:
desc += " - " + str(p.variation.value)
if p.addon_to_id:
desc = " + " + desc
InvoiceLine.objects.create(
invoice=invoice, description=desc,
gross_value=p.price, tax_value=p.tax_value,
@@ -200,6 +202,14 @@ def _invoice_generate_german(invoice, f):
p_size = p.wrap(85 * mm, 50 * mm)
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
textobject.setFont('OpenSansBd', 8)
textobject.textLine(_('Order code').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.textLine(invoice.order.full_code)
canvas.drawText(textobject)
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
textobject.setFont('OpenSansBd', 8)
if invoice.is_cancellation:
@@ -243,20 +253,6 @@ def _invoice_generate_german(invoice, f):
canvas.drawText(textobject)
textobject = canvas.beginText(165 * mm, (297 - 50) * mm)
textobject.setFont('OpenSansBd', 8)
textobject.textLine(_('Order code').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.textLine(invoice.order.full_code)
textobject.moveCursor(0, 5)
textobject.setFont('OpenSansBd', 8)
textobject.textLine(_('Order date').upper())
textobject.moveCursor(0, 5)
textobject.setFont('OpenSans', 10)
textobject.textLine(date_format(invoice.order.datetime, "DATE_FORMAT"))
canvas.drawText(textobject)
if invoice.event.settings.invoice_logo_image:
logo_file = invoice.event.settings.get('invoice_logo_image', binary_file=True)
canvas.drawImage(ImageReader(logo_file),

View File

@@ -1,5 +1,5 @@
import logging
from typing import Any, Dict, Union
from typing import Any, Dict, List, Union
import bleach
import cssutils
@@ -114,6 +114,16 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
subject = "[%s] %s" % (prefix, subject)
body_plain += "\r\n\r\n-- \r\n"
signature = str(event.settings.get('mail_text_signature'))
if signature:
signature = signature.format(event=event.name)
signature_md = signature.replace('\n', '<br>\n')
signature_md = bleach.linkify(bleach.clean(markdown.markdown(signature_md), tags=bleach.ALLOWED_TAGS + ['p', 'br']))
htmlctx['signature'] = signature_md
body_plain += signature
body_plain += "\r\n\r\n-- \r\n"
body_plain += _(
"You are receiving this email because you placed an order for {event}."
).format(event=event.name)
@@ -138,7 +148,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
@app.task
def mail_send_task(to: str, subject: str, body: str, html: str, sender: str,
def mail_send_task(to: List[str], subject: str, body: str, html: str, sender: str,
event: int=None, headers: dict=None) -> bool:
email = EmailMultiAlternatives(subject, body, sender, to=to, headers=headers)
email.attach_alternative(inline_css(html), "text/html")

View File

@@ -562,6 +562,7 @@ class OrderChangeManager:
'new_item': op.item.pk,
'new_variation': op.variation.pk if op.variation else None,
'old_price': op.position.price,
'addon_to': op.position.addon_to_id,
'new_price': op.price
})
op.position.item = op.item
@@ -574,18 +575,29 @@ class OrderChangeManager:
'position': op.position.pk,
'positionid': op.position.positionid,
'old_price': op.position.price,
'addon_to': op.position.addon_to_id,
'new_price': op.price
})
op.position.price = op.price
op.position._calculate_tax()
op.position.save()
elif isinstance(op, self.CancelOperation):
for opa in op.position.addons.all():
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
'position': opa.pk,
'positionid': opa.positionid,
'old_item': opa.item.pk,
'old_variation': opa.variation.pk if opa.variation else None,
'addon_to': opa.addon_to_id,
'old_price': opa.price,
})
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_item': op.position.item.pk,
'old_variation': op.position.variation.pk if op.position.variation else None,
'old_price': op.position.price,
'addon_to': None,
})
op.position.delete()

View File

@@ -54,7 +54,7 @@ def assign_automatically(event_id: int, user_id: int=None):
@receiver(signal=periodic_task)
def process_waitinglist(sender, **kwargs):
qs = Event.objects.prefetch_related('setting_objects', 'organizer__setting_objects').select_related('organizer')
qs = Event.objects.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:
assign_automatically.apply_async(args=(e.pk,))

View File

@@ -1,19 +1,14 @@
import decimal
import json
from datetime import date, datetime, time
from datetime import datetime
from django.core.cache import cache
from typing import Any, Dict, Optional
import dateutil.parser
from django.conf import settings
from django.core.files import File
from django.core.files.storage import default_storage
from django.db.models import Model
from django.utils.translation import ugettext_noop
from hierarkey.models import GlobalSettingsBase, Hierarkey
from i18nfield.strings import LazyI18nString
from pretix.base.models.settings import GlobalSetting
from typing import Any
DEFAULTS = {
'max_items_per_order': {
@@ -32,6 +27,14 @@ DEFAULTS = {
'default': 'False',
'type': bool
},
'attendee_emails_asked': {
'default': 'False',
'type': bool
},
'attendee_emails_required': {
'default': 'False',
'type': bool
},
'invoice_address_asked': {
'default': 'True',
'type': bool,
@@ -156,6 +159,14 @@ DEFAULTS = {
'default': None,
'type': datetime
},
'ticket_download_addons': {
'default': 'False',
'type': bool
},
'ticket_download_nonadm': {
'default': 'True',
'type': bool
},
'last_order_modification_date': {
'default': None,
'type': datetime
@@ -180,6 +191,10 @@ DEFAULTS = {
'default': settings.MAIL_FROM,
'type': str
},
'mail_text_signature': {
'type': LazyI18nString,
'default': ""
},
'mail_text_resend_link': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
@@ -382,194 +397,27 @@ Your {event} team"""))
}
}
settings_hierarkey = Hierarkey(attribute_name='settings')
class SettingsProxy:
"""
This object allows convenient access to settings stored in the
EventSettings/OrganizerSettings database model. It exposes all settings as
properties and it will do all the nasty inheritance and defaults stuff for
you.
"""
for k, v in DEFAULTS.items():
settings_hierarkey.add_default(k, v['default'], v['type'])
def __init__(self, obj: Model, parent: Optional[Model]=None, type=None):
self._obj = obj
self._parent = parent
self._cached_obj = None
self._write_cached_obj = None
self._type = type
def _cache(self) -> Dict[str, Any]:
if self._cached_obj is None:
self._cached_obj = cache.get_or_set(
'settings_{}_{}'.format(self._obj.settings_namespace, self._obj.pk),
lambda: {s.key: s.value for s in self._obj.setting_objects.all()},
timeout=1800
)
return self._cached_obj
def i18n_uns(v):
try:
return LazyI18nString(json.loads(v))
except ValueError:
return LazyI18nString(str(v))
def _write_cache(self) -> Dict[str, Any]:
if self._write_cached_obj is None:
self._write_cached_obj = {
s.key: s for s in self._obj.setting_objects.all()
}
return self._write_cached_obj
def _flush(self) -> None:
self._cached_obj = None
self._write_cached_obj = None
self._flush_external_cache()
settings_hierarkey.add_type(LazyI18nString,
serialize=lambda s: json.dumps(s.data),
unserialize=i18n_uns)
def _flush_external_cache(self):
cache.delete('settings_{}_{}'.format(self._obj.settings_namespace, self._obj.pk))
def freeze(self) -> dict:
"""
Returns a dictionary of all settings set for this object, including
any default values of its parents or hardcoded in pretix.
"""
settings = {}
for key, v in DEFAULTS.items():
settings[key] = self._unserialize(v['default'], v['type'])
if self._parent:
settings.update(self._parent.settings.freeze())
for key in self._cache():
settings[key] = self.get(key)
return settings
def _unserialize(self, value: str, as_type: type, binary_file=False) -> Any:
if as_type is None and value is not None and value.startswith('file://'):
as_type = File
if as_type is not None and isinstance(value, as_type):
return value
elif value is None:
return None
elif as_type == int or as_type == float or as_type == decimal.Decimal:
return as_type(value)
elif as_type == dict or as_type == list:
return json.loads(value)
elif as_type == bool or value in ('True', 'False'):
return value == 'True'
elif as_type == File:
try:
fi = default_storage.open(value[7:], 'rb' if binary_file else 'r')
fi.url = default_storage.url(value[7:])
return fi
except OSError:
return False
elif as_type == datetime:
return dateutil.parser.parse(value)
elif as_type == date:
return dateutil.parser.parse(value).date()
elif as_type == time:
return dateutil.parser.parse(value).time()
elif as_type == LazyI18nString and not isinstance(value, LazyI18nString):
try:
return LazyI18nString(json.loads(value))
except ValueError:
return LazyI18nString(str(value))
elif as_type is not None and issubclass(as_type, Model):
return as_type.objects.get(pk=value)
return value
def _serialize(self, value: Any) -> str:
if isinstance(value, str):
return value
elif isinstance(value, int) or isinstance(value, float) \
or isinstance(value, bool) or isinstance(value, decimal.Decimal):
return str(value)
elif isinstance(value, list) or isinstance(value, dict):
return json.dumps(value)
elif isinstance(value, datetime) or isinstance(value, date) or isinstance(value, time):
return value.isoformat()
elif isinstance(value, Model):
return value.pk
elif isinstance(value, LazyI18nString):
return json.dumps(value.data)
elif isinstance(value, File):
return 'file://' + value.name
raise TypeError('Unable to serialize %s into a setting.' % str(type(value)))
def get(self, key: str, default=None, as_type: type=None, binary_file=False):
"""
Get a setting specified by key ``key``. Normally, settings are strings, but
if you put non-strings into the settings object, you can request unserialization
by specifying ``as_type``. If the key does not have a harcdoded type in the pretix source,
omitting ``as_type`` always will get you a string.
If the setting with the specified name does not exist on this object, any parent object
will be queried (e.g. the organizer of an event). If still no value is found, a default
value hardcoded will be returned if one exists. If not, the value of the ``default`` argument
will be returned instead.
"""
if as_type is None and key in DEFAULTS:
as_type = DEFAULTS[key]['type']
if key in self._cache():
value = self._cache()[key]
else:
value = None
if self._parent:
value = self._parent.settings.get(key, as_type=str)
if value is None and key in DEFAULTS:
value = DEFAULTS[key]['default']
if value is None and default is not None:
value = default
return self._unserialize(value, as_type, binary_file=binary_file)
def __getitem__(self, key: str) -> Any:
return self.get(key)
def __getattr__(self, key: str) -> Any:
if key.startswith('_'):
return super().__getattr__(key)
return self.get(key)
def __setattr__(self, key: str, value: Any) -> None:
if key.startswith('_'):
return super().__setattr__(key, value)
self.set(key, value)
def __setitem__(self, key: str, value: Any) -> None:
self.set(key, value)
def set(self, key: str, value: Any) -> None:
"""
Stores a setting to the database of its object.
"""
wc = self._write_cache()
if key in wc:
s = wc[key]
else:
s = self._type(object=self._obj, key=key)
s.value = self._serialize(value)
s.save()
self._cache()[key] = s.value
wc[key] = s
self._flush_external_cache()
def __delattr__(self, key: str) -> None:
if key.startswith('_'):
return super().__delattr__(key)
self.delete(key)
def __delitem__(self, key: str) -> None:
self.delete(key)
def delete(self, key: str) -> None:
"""
Deletes a setting from this object's storage.
"""
if key in self._write_cache():
self._write_cache()[key].delete()
del self._write_cache()[key]
if key in self._cache():
del self._cache()[key]
self._flush_external_cache()
@settings_hierarkey.set_global(cache_namespace='global')
class GlobalSettingsObject(GlobalSettingsBase):
slug = '_global'
class SettingsSandbox:
@@ -614,13 +462,3 @@ class SettingsSandbox:
def set(self, key: str, value: Any):
self._event.settings.set(self._convert_key(key), value)
class GlobalSettingsObject():
settings_namespace = 'global'
def __init__(self):
self.settings = SettingsProxy(self, type=GlobalSetting)
self.setting_objects = GlobalSetting.objects
self.slug = '_global'
self.pk = '_global'

View File

@@ -1,3 +1,4 @@
import warnings
from typing import Any, Callable, List, Tuple
import django.dispatch
@@ -49,7 +50,14 @@ class EventPluginSignal(django.dispatch.Signal):
if not hasattr(app, 'compatibility_errors') or not app.compatibility_errors:
response = receiver(signal=self, sender=sender, **named)
responses.append((receiver, response))
return responses
return sorted(responses, key=lambda r: (receiver.__module__, receiver.__name__))
class DeprecatedSignal(django.dispatch.Signal):
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
warnings.warn('This signal is deprecated and will soon be removed', stacklevel=3)
super().connect(receiver, sender=None, weak=True, dispatch_uid=None)
event_live_issues = EventPluginSignal(
@@ -153,6 +161,21 @@ to the user. The receivers are expected to return HTML code.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
event_copy_data = EventPluginSignal(
providing_args=["other"]
)
"""
This signal is sent out when a new event is created as a clone of an existing event, i.e.
the settings from the older event are copied to the newer one. You can listen to this
signal to copy data or configuration stored within your plugin's models as well.
You don't need to copy data inside the general settings storage which is cloned automatically,
but you might need to modify that data.
The ``sender`` keyword argument will contain the event of the **new** event. The ``other``
keyword argument will contain the event to **copy from**.
"""
periodic_task = django.dispatch.Signal()
"""
This is a regular django signal (no pretix event signal) that we send out every

View File

@@ -148,6 +148,18 @@
</td>
</tr>
{% endif %}
{% if signature %}
<tr>
<td class="gap"></td>
</tr>
<tr>
<td class="order containertd">
<div class="content">
{{ signature | safe }}
</div>
</td>
</tr>
{% endif %}
<tr>
<td class="footer">
<div>

View File

@@ -11,6 +11,7 @@ ALLOWED_TAGS = [
'acronym',
'b',
'blockquote',
'br',
'code',
'em',
'i',
@@ -46,6 +47,7 @@ def rich_text(text: str, **kwargs):
"""
Processes markdown and cleans HTML in a text input.
"""
text = str(text)
body_md = bleach.linkify(bleach.clean(
markdown.markdown(text),
tags=ALLOWED_TAGS,

View File

@@ -51,10 +51,18 @@ class BaseTicketOutput:
This method should generate a download file and return a tuple consisting of a
filename, a file type and file content. The extension will be taken from the filename
which is otherwise ignored.
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
"""
with tempfile.TemporaryDirectory() as d:
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for pos in order.positions.all():
if pos.addon_to_id and not self.event.settings.ticket_download_addons:
continue
if not pos.item.admission and not self.event.settings.ticket_download_nonadm:
continue
fname, __, content = self.generate(pos)
zipf.writestr('{}-{}{}'.format(
order.code, pos.positionid, os.path.splitext(fname)[1]

View File

@@ -72,6 +72,7 @@ class AsyncAction:
# but handle the mssage itself
data.update({
'redirect': self.get_success_url(res.info),
'success': True,
'message': str(self.get_success_message(res.info))
})
else:
@@ -80,6 +81,7 @@ class AsyncAction:
# but handle the mssage itself
data.update({
'redirect': self.get_error_url(),
'success': False,
'message': str(self.get_error_message(res.info))
})
return data
@@ -103,6 +105,7 @@ class AsyncAction:
if "ajax" in self.request.POST or "ajax" in self.request.GET:
return JsonResponse({
'ready': True,
'success': True,
'redirect': self.get_success_url(value),
'message': str(self.get_success_message(value))
})
@@ -113,6 +116,7 @@ class AsyncAction:
if "ajax" in self.request.POST or "ajax" in self.request.GET:
return JsonResponse({
'ready': True,
'success': False,
'redirect': self.get_error_url(),
'message': str(self.get_error_message(exception))
})

View File

@@ -5,7 +5,7 @@ from django.core.urlresolvers import Resolver404, get_script_prefix, resolve
from pretix.base.settings import GlobalSettingsObject
from .signals import html_head, nav_event, nav_topbar
from .signals import html_head, nav_event, nav_global, nav_topbar
from .utils.i18n import get_javascript_format, get_moment_locale
@@ -38,13 +38,19 @@ def contextprocessor(request):
_nav_event += response
if request.event.settings.get('payment_term_weekdays'):
_js_payment_weekdays_disabled = '[0,6]'
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
ctx['nav_event'] = _nav_event
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
_nav_global = []
if not hasattr(request, 'event'):
for receiver, response in nav_global.send(request, request=request):
_nav_global += response
ctx['nav_global'] = sorted(_nav_global, key=lambda n: n['label'])
_nav_topbar = []
for receiver, response in nav_topbar.send(request, request=request):
_nav_topbar += response
ctx['nav_topbar'] = _nav_topbar
ctx['nav_topbar'] = sorted(_nav_topbar, key=lambda n: n['label'])
ctx['js_datetime_format'] = get_javascript_format('DATETIME_INPUT_FORMATS')
ctx['js_date_format'] = get_javascript_format('DATE_INPUT_FORMATS')

View File

@@ -2,12 +2,13 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db.models import Q
from django.utils.timezone import get_current_timezone_name
from django.utils.translation import ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea
from pytz import common_timezones, timezone
from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.models import Event, Organizer
from pretix.control.forms import ExtFileField
@@ -26,7 +27,7 @@ class EventWizardFoundationForm(forms.Form):
self.fields['organizer'] = forms.ModelChoiceField(
label=_("Organizer"),
queryset=Organizer.objects.filter(
id__in=self.user.organizer_perms.filter(can_create_events=True).values_list('organizer', flat=True)
id__in=self.user.teams.filter(can_create_events=True).values_list('organizer', flat=True)
),
widget=forms.RadioSelect,
empty_label=None,
@@ -61,9 +62,11 @@ class EventWizardBasicsForm(I18nModelForm):
]
widgets = {
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker',
'data-date-after': '#id_basics-date_from'}),
'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker',
'data-date-after': '#id_basics-presale_start'}),
}
def __init__(self, *args, **kwargs):
@@ -109,6 +112,16 @@ class EventWizardBasicsForm(I18nModelForm):
class EventWizardCopyForm(forms.Form):
@staticmethod
def copy_from_queryset(user):
return Event.objects.filter(
Q(organizer_id__in=user.teams.filter(
all_events=True, can_change_event_settings=True, can_change_items=True
).values_list('organizer', flat=True)) | Q(id__in=user.teams.filter(
can_change_event_settings=True, can_change_items=True
).values_list('limit_events__id', flat=True))
)
def __init__(self, *args, **kwargs):
kwargs.pop('organizer')
kwargs.pop('locales')
@@ -116,11 +129,7 @@ class EventWizardCopyForm(forms.Form):
super().__init__(*args, **kwargs)
self.fields['copy_from_event'] = forms.ModelChoiceField(
label=_("Copy configuration from"),
queryset=Event.objects.filter(
id__in=self.user.event_perms.filter(
can_change_items=True, can_change_settings=True
).values_list('event', flat=True)
),
queryset=EventWizardCopyForm.copy_from_queryset(self.user),
widget=forms.RadioSelect,
empty_label=_('Do not copy'),
required=False
@@ -134,6 +143,7 @@ class EventUpdateForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
self.fields['location'].widget.attrs['rows'] = '3'
class Meta:
model = Event
@@ -144,6 +154,7 @@ class EventUpdateForm(I18nModelForm):
'currency',
'date_from',
'date_to',
'date_admission',
'is_public',
'presale_start',
'presale_end',
@@ -151,9 +162,11 @@ class EventUpdateForm(I18nModelForm):
]
widgets = {
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker', 'data-date-after': '#id_date_from'}),
'date_admission': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker',
'data-date-after': '#id_presale_start'}),
}
@@ -182,6 +195,7 @@ class EventSettingsForm(SettingsForm):
presale_start_show_date = forms.BooleanField(
label=_("Show start date"),
help_text=_("Show the presale start date before presale has started."),
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_presale_start'}),
required=False
)
last_order_modification_date = forms.DateTimeField(
@@ -221,28 +235,48 @@ class EventSettingsForm(SettingsForm):
min_value=6,
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
"number of hours until it expires and can be re-assigned to the next person on the list."),
required=False
required=False,
widget=forms.NumberInput(attrs={'data-display-dependency': '#id_settings-waiting_list_enabled'}),
)
waiting_list_auto = forms.BooleanField(
label=_("Automatic waiting list assignments"),
help_text=_("If ticket capacity becomes free, automatically create a voucher and send it to the first person "
"on the waiting list for that product. If this is not active, mails will not be send automatically "
"but you can send them manually via the control panel."),
required=False
required=False,
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-waiting_list_enabled'}),
)
attendee_names_asked = forms.BooleanField(
label=_("Ask for attendee names"),
help_text=_("Ask for a name for all tickets which include admission to the event."),
required=False
required=False,
)
attendee_names_required = forms.BooleanField(
label=_("Require attendee names"),
help_text=_("Require customers to fill in the names of all attendees."),
required=False,
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}),
)
attendee_emails_asked = forms.BooleanField(
label=_("Ask for email addresses per ticket"),
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be send "
"to that email address. If you enable this option, the system will additionally ask for "
"individual email addresses for every admission ticket. This might be useful if you want to "
"obtain individual addresses for every attendee even in case of group orders."),
required=False
)
attendee_emails_required = forms.BooleanField(
label=_("Require email addresses per ticket"),
help_text=_("Require customers to fill in individual e-mail addresses for all admission tickets. See the "
"above option for more details. One email address for the order confirmation will always be "
"required regardless of this setting."),
required=False,
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
)
max_items_per_order = forms.IntegerField(
min_value=1,
label=_("Maximum number of items per order")
label=_("Maximum number of items per order"),
help_text=_("Add-on products will not be counted.")
)
reservation_time = forms.IntegerField(
min_value=0,
@@ -274,6 +308,10 @@ class EventSettingsForm(SettingsForm):
raise ValidationError({
'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.')
})
if data['attendee_emails_required'] and not data['attendee_emails_asked']:
raise ValidationError({
'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.')
})
return data
@@ -346,7 +384,7 @@ class ProviderForm(SettingsForm):
if isinstance(v, I18nFormField):
v._required = v.one_required
v.one_required = False
v.widget.enabled_langcodes = self.obj.settings.get('locales')
v.widget.enabled_locales = self.locales
def clean(self):
cleaned_data = super().clean()
@@ -366,11 +404,13 @@ class InvoiceSettingsForm(SettingsForm):
)
invoice_address_required = forms.BooleanField(
label=_("Require invoice address"),
required=False
required=False,
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
)
invoice_address_vatid = forms.BooleanField(
label=_("Ask for VAT ID"),
help_text=_("Does only work if an invoice address is asked for. VAT ID is not required."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
required=False
)
invoice_numbers_consecutive = forms.BooleanField(
@@ -437,42 +477,58 @@ class MailSettingsForm(SettingsForm):
label=_("Sender address"),
help_text=_("Sender address for outgoing emails")
)
mail_text_signature = I18nFormField(
label=_("Signature"),
required=False,
widget=I18nTextarea,
help_text=_("This will be attached to every email. Available placeholders: {event}"),
validators=[PlaceholderValidator(['{event}'])]
)
mail_text_order_placed = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {total}, {currency}, {date}, {paymentinfo}, {url}, "
"{invoice_name}, {invoice_company}")
"{invoice_name}, {invoice_company}"),
validators=[PlaceholderValidator(['{event}', '{total}', '{currency}', '{date}', '{paymentinfo}',
'{url}', '{invoice_name}', '{invoice_company}'])]
)
mail_text_order_paid = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}, {payment_info}")
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}, {payment_info}"),
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}', '{payment_info}'])]
)
mail_text_order_free = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}")
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
)
mail_text_order_changed = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}")
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
)
mail_text_resend_link = I18nFormField(
label=_("Text (sent by admin)"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}")
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
)
mail_text_resend_all_links = I18nFormField(
label=_("Text (requested by user)"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {orders}")
help_text=_("Available placeholders: {event}, {orders}"),
validators=[PlaceholderValidator(['{event}', '{orders}'])]
)
mail_days_order_expire_warning = forms.IntegerField(
label=_("Number of days"),
@@ -485,13 +541,15 @@ class MailSettingsForm(SettingsForm):
label=_("Text"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}")
help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}"),
validators=[PlaceholderValidator(['{event}', '{url}', '{expire_date}', '{invoice_name}', '{invoice_company}'])]
)
mail_text_waiting_list = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {product}, {hours}, {code}")
help_text=_("Available placeholders: {event}, {url}, {product}, {hours}, {code}"),
validators=[PlaceholderValidator(['{event}', '{url}', '{product}', '{hours}', '{code}'])]
)
smtp_use_custom = forms.BooleanField(
label=_("Use custom SMTP server"),
@@ -547,7 +605,8 @@ class DisplaySettingsForm(SettingsForm):
validators=[
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
message=_('Please enter the hexadecimal code of a color, e.g. #990000.'))
]
],
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
)
logo_image = ExtFileField(
label=_('Logo image'),
@@ -577,7 +636,18 @@ class TicketSettingsForm(SettingsForm):
label=_("Download date"),
help_text=_("Ticket download will be offered after this date."),
required=True,
widget=forms.DateTimeInput(attrs={'class': 'datetimepicker'})
widget=forms.DateTimeInput(attrs={'class': 'datetimepicker',
'data-display-dependency': '#id_ticket_download'}),
)
ticket_download_addons = forms.BooleanField(
label=_("Offer to download tickets separately for add-on products"),
required=False,
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_ticket_download'}),
)
ticket_download_nonadm = forms.BooleanField(
label=_("Generate tickets for non-admission products"),
required=False,
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_ticket_download'}),
)
def prepare_fields(self):
@@ -586,6 +656,10 @@ class TicketSettingsForm(SettingsForm):
v._required = v.required
v.required = False
v.widget.is_required = False
if isinstance(v, I18nFormField):
v._required = v.one_required
v.one_required = False
v.widget.enabled_locales = self.locales
def clean(self):
# required=True files should only be required if the feature is enabled

View File

@@ -29,7 +29,7 @@ class GlobalSettingsForm(SettingsForm):
))
])
responses = register_global_settings.send(self)
for r, response in responses:
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

View File

@@ -2,6 +2,7 @@ import copy
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Max
from django.forms import BooleanField, ModelMultipleChoiceField
from django.forms.formsets import DELETION_FIELD_NAME
from django.utils.translation import ugettext as __, ugettext_lazy as _
@@ -11,6 +12,7 @@ from pretix.base.forms import I18nFormSet, I18nModelForm
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn
class CategoryForm(I18nModelForm):
@@ -19,7 +21,8 @@ class CategoryForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'name',
'description'
'description',
'is_addon'
]
@@ -108,6 +111,7 @@ class ItemCreateForm(I18nModelForm):
def __init__(self, *args, **kwargs):
self.event = kwargs['event']
super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all()
self.fields['copy_from'] = forms.ModelChoiceField(
label=_("Copy product information"),
queryset=self.event.items.all(),
@@ -117,7 +121,20 @@ class ItemCreateForm(I18nModelForm):
)
def save(self, *args, **kwargs):
if self.cleaned_data.get('copy_from'):
self.instance.description = self.cleaned_data['copy_from'].description
self.instance.active = self.cleaned_data['copy_from'].active
self.instance.available_from = self.cleaned_data['copy_from'].available_from
self.instance.available_until = self.cleaned_data['copy_from'].available_until
self.instance.require_voucher = self.cleaned_data['copy_from'].require_voucher
self.instance.hide_without_voucher = self.cleaned_data['copy_from'].hide_without_voucher
self.instance.allow_cancel = self.cleaned_data['copy_from'].allow_cancel
self.instance.min_per_order = self.cleaned_data['copy_from'].min_per_order
self.instance.max_per_order = self.cleaned_data['copy_from'].max_per_order
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
instance = super().save(*args, **kwargs)
if self.cleaned_data.get('has_variations'):
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
for variation in self.cleaned_data['copy_from'].variations.all():
@@ -128,8 +145,9 @@ class ItemCreateForm(I18nModelForm):
item=instance, value=__('Standard')
)
for question in Question.objects.filter(items=self.cleaned_data.get('copy_from')):
question.items.add(instance)
if self.cleaned_data.get('copy_from'):
for question in self.cleaned_data['copy_from'].questions.all():
question.items.add(instance)
return instance
@@ -138,6 +156,7 @@ class ItemCreateForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'name',
'category',
'admission',
'default_price',
'tax_rate',
@@ -168,7 +187,8 @@ class ItemUpdateForm(I18nModelForm):
'require_voucher',
'hide_without_voucher',
'allow_cancel',
'max_per_order'
'max_per_order',
'min_per_order',
]
widgets = {
'available_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
@@ -206,4 +226,64 @@ class ItemVariationForm(I18nModelForm):
'value',
'active',
'default_price',
'description',
]
class ItemAddOnsFormSet(I18nFormSet):
def __init__(self, *args, **kwargs):
self.event = kwargs.get('event')
super().__init__(*args, **kwargs)
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
return super()._construct_form(i, **kwargs)
def clean(self):
super().clean()
categories = set()
for i in range(0, self.total_form_count()):
form = self.forms[i]
if self.can_delete:
if self._should_delete_form(form):
# This form is going to be deleted so any of its errors
# should not cause the entire formset to be invalid.
continue
if form.cleaned_data['addon_category'] in categories:
raise ValidationError(_('You added the same add-on category twice'))
categories.add(form.cleaned_data['addon_category'])
@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 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()
class Meta:
model = ItemAddOn
localized_fields = '__all__'
fields = [
'addon_category',
'min_count',
'max_count',
]
help_texts = {
'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all '
'available add-ons are sold out.')
}

View File

@@ -1,4 +1,5 @@
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
@@ -118,3 +119,16 @@ class OrderContactForm(forms.ModelForm):
class Meta:
model = Order
fields = ['email']
class OrderLocaleForm(forms.ModelForm):
locale = forms.ChoiceField()
class Meta:
model = Order
fields = ['locale']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
locale_names = dict(settings.LANGUAGES)
self.fields['locale'].choices = [(a, locale_names[a]) for a in self.instance.event.settings.locales]

View File

@@ -1,8 +1,13 @@
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.forms import I18nModelForm
from pretix.base.models import Organizer
from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.models import Organizer, Team
from pretix.control.forms import ExtFileField
from pretix.multidomain.models import KnownDomain
class OrganizerForm(I18nModelForm):
@@ -25,9 +30,99 @@ class OrganizerForm(I18nModelForm):
class OrganizerUpdateForm(OrganizerForm):
def __init__(self, *args, **kwargs):
self.domain = kwargs.pop('domain', False)
kwargs.setdefault('initial', {})
self.instance = kwargs['instance']
if self.domain and self.instance:
initial_domain = self.instance.domains.first()
if initial_domain:
kwargs['initial'].setdefault('domain', initial_domain.domainname)
super().__init__(*args, **kwargs)
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
if self.domain:
self.fields['domain'] = forms.CharField(
max_length=255,
label=_('Custom domain'),
required=False,
help_text=_('You need to configure the custom domain in the webserver beforehand.')
)
def clean_slug(self):
return self.instance.slug
def save(self, commit=True):
instance = super().save(commit)
if self.domain:
current_domain = instance.domains.first()
if self.cleaned_data['domain']:
if current_domain and current_domain.domainname != self.cleaned_data['domain']:
current_domain.delete()
KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain'])
elif not current_domain:
KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain'])
elif current_domain:
current_domain.delete()
instance.get_cache().clear()
return instance
class TeamForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
self.fields['limit_events'].queryset = organizer.events.all()
class Meta:
model = Team
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings',
'can_change_event_settings', 'can_change_items',
'can_view_orders', 'can_change_orders',
'can_view_vouchers', 'can_change_vouchers']
widgets = {
'limit_events': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '#id_all_events'
}),
}
def clean(self):
data = super().clean()
if self.instance.pk and not data['can_change_teams']:
if not self.instance.organizer.teams.exclude(pk=self.instance.pk).filter(
can_change_teams=True, members__isnull=False
).exists():
raise ValidationError(_('The changes could not be saved because there would be no remaining team with '
'the permission to change teams and permissions.'))
return data
class OrganizerSettingsForm(SettingsForm):
locales = forms.MultipleChoiceField(
choices=settings.LANGUAGES,
label=_("Use languages"),
widget=forms.CheckboxSelectMultiple,
help_text=_('Choose all languages that your organizer homepage should be available in.')
)
organizer_homepage_text = I18nFormField(
label=_('Homepage text'),
required=False,
widget=I18nTextarea,
help_text=_('This will be displayed on the organizer homepage.')
)
organizer_logo_image = ExtFileField(
label=_('Logo image'),
ext_whitelist=(".png", ".jpg", ".svg", ".gif", ".jpeg"),
required=False,
help_text=_('If you provide a logo image, we will by default not show your organization name '
'in the page header. We will show your logo with a maximal height of 120 pixels.')
)

View File

@@ -76,6 +76,9 @@ class VoucherForm(I18nModelForm):
else:
self.instance.variation = None
self.instance.quota = None
if self.instance.item.category and self.instance.item.category.is_addon:
raise ValidationError(_('It is currently not possible to create vouchers for add-on products.'))
else:
self.instance.quota = Quota.objects.get(pk=quotaid, event=self.instance.event)
self.instance.item = None

View File

@@ -95,6 +95,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.item.variation.added': _('The variation "{value}" has been created.'),
'pretix.event.item.variation.deleted': _('The variation "{value}" has been deleted.'),
'pretix.event.item.variation.changed': _('The variation "{value}" has been modified.'),
'pretix.event.item.addons.added': _('An add-on has been added to this product.'),
'pretix.event.item.addons.removed': _('An add-on has been removed from this product.'),
'pretix.event.item.addons.changed': _('An add-on has been changed on this product.'),
'pretix.event.quota.added': _('The quota has been added.'),
'pretix.event.quota.deleted': _('The quota has been deleted.'),
'pretix.event.quota.changed': _('The quota has been modified.'),
@@ -118,7 +121,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.')
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.'),
'pretix.team.created': _('The team has been created.'),
'pretix.team.changed': _('The team settings have been modified.'),
'pretix.team.deleted': _('The team has been deleted.'),
}
data = json.loads(logentry.data)
@@ -146,6 +152,23 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
if logentry.action_type.startswith('pretix.event.tickets.provider.'):
return _('The settings of a ticket output provider have been changed.')
if logentry.action_type == 'pretix.team.member.added':
return _('{user} has been added to the team.').format(user=data.get('email'))
if logentry.action_type == 'pretix.team.member.removed':
return _('{user} has been removed from the team.').format(user=data.get('email'))
if logentry.action_type == 'pretix.team.member.joined':
return _('{user} has joined the team using the invite sent to {email}.').format(
user=data.get('email'), email=data.get('invite_email')
)
if logentry.action_type == 'pretix.team.invite.created':
return _('{user} has been invited to the team.').format(user=data.get('email'))
if logentry.action_type == 'pretix.team.invite.deleted':
return _('The invite for {user} has been revoked.').format(user=data.get('email'))
if logentry.action_type == 'pretix.user.settings.changed':
text = str(_('Your account settings have been changed.'))
if 'email' in data:

View File

@@ -9,9 +9,7 @@ from django.utils.deprecation import MiddlewareMixin
from django.utils.encoding import force_str
from django.utils.translation import ugettext as _
from pretix.base.models import (
Event, EventPermission, Organizer, OrganizerPermission,
)
from pretix.base.models import Event, Organizer
class PermissionMiddleware(MiddlewareMixin):
@@ -61,53 +59,23 @@ class PermissionMiddleware(MiddlewareMixin):
return redirect_to_login(
path, resolved_login_url, REDIRECT_FIELD_NAME)
events = Event.objects.all() if request.user.is_superuser else request.user.events
request.user.events_cache = events.order_by(
"organizer", "date_from").prefetch_related("organizer")
events = request.user.get_events_with_any_permission()
request.user.events_cache = events.order_by("organizer", "date_from").prefetch_related("organizer")
if 'event' in url.kwargs and 'organizer' in url.kwargs:
try:
if request.user.is_superuser:
request.event = Event.objects.filter(
slug=url.kwargs['event'],
organizer__slug=url.kwargs['organizer'],
).select_related('organizer')[0]
request.eventperm = EventPermission(
event=request.event,
user=request.user
)
else:
request.event = Event.objects.filter(
slug=url.kwargs['event'],
permitted__id__exact=request.user.id,
organizer__slug=url.kwargs['organizer'],
).select_related('organizer')[0]
request.eventperm = EventPermission.objects.get(
event=request.event,
user=request.user
)
request.organizer = request.event.organizer
except IndexError:
request.event = Event.objects.filter(
slug=url.kwargs['event'],
organizer__slug=url.kwargs['organizer'],
).select_related('organizer').first()
if not request.event or not request.user.has_event_permission(request.event.organizer, request.event):
raise Http404(_("The selected event was not found or you "
"have no permission to administrate it."))
request.organizer = request.event.organizer
request.eventpermset = request.user.get_event_permission_set(request.organizer, request.event)
elif 'organizer' in url.kwargs:
try:
if request.user.is_superuser:
request.organizer = Organizer.objects.filter(
slug=url.kwargs['organizer'],
)[0]
request.orgaperm = OrganizerPermission(
organizer=request.organizer,
user=request.user
)
else:
request.organizer = Organizer.objects.filter(
slug=url.kwargs['organizer'],
permitted__id__exact=request.user.id,
)[0]
request.orgaperm = OrganizerPermission.objects.get(
organizer=request.organizer,
user=request.user
)
except IndexError:
request.organizer = Organizer.objects.filter(
slug=url.kwargs['organizer'],
).first()
if not request.organizer or not request.user.has_organizer_permission(request.organizer):
raise Http404(_("The selected organizer was not found or you "
"have no permission to administrate it."))
request.orgapermset = request.user.get_organizer_permission_set(request.organizer)

View File

@@ -1,37 +1,29 @@
from django.core.exceptions import PermissionDenied
from django.utils.translation import ugettext as _
from pretix.base.models import EventPermission, OrganizerPermission
def event_permission_required(permission):
"""
This view decorator rejects all requests with a 403 response which are not from
users having the given permission for the event the request is associated with.
"""
if permission == 'can_change_settings':
# Legacy support
permission = 'can_change_event_settings'
def decorator(function):
def wrapper(request, *args, **kw):
if not request.user.is_authenticated: # NOQA
# just a double check, should not ever happen
raise PermissionDenied()
if request.user.is_superuser:
allowed = (
request.user.is_superuser
or request.user.has_event_permission(request.organizer, request.event, permission)
)
if allowed:
return function(request, *args, **kw)
try:
perm = EventPermission.objects.get(
event=request.event,
user=request.user
)
except EventPermission.DoesNotExist:
pass
else:
allowed = not permission
try:
if permission:
allowed = getattr(perm, permission)
except AttributeError:
pass
if allowed or request.user.is_superuser:
return function(request, *args, **kw)
raise PermissionDenied(_('You do not have permission to view this content.'))
return wrapper
return decorator
@@ -55,29 +47,23 @@ def organizer_permission_required(permission):
This view decorator rejects all requests with a 403 response which are not from
users having the given permission for the event the request is associated with.
"""
if permission == 'can_change_settings':
# Legacy support
permission = 'can_change_organizer_settings'
def decorator(function):
def wrapper(request, *args, **kw):
if not request.user.is_authenticated: # NOQA
# just a double check, should not ever happen
raise PermissionDenied()
if request.user.is_superuser:
allowed = (
request.user.is_superuser
or request.user.has_organizer_permission(request.organizer, permission)
)
if allowed:
return function(request, *args, **kw)
try:
perm = OrganizerPermission.objects.get(
organizer=request.organizer,
user=request.user
)
except OrganizerPermission.DoesNotExist:
pass
else:
allowed = not permission
try:
if permission:
allowed = getattr(perm, permission)
except AttributeError:
pass
if allowed or request.user.is_superuser:
return function(request, *args, **kw)
raise PermissionDenied(_('You do not have permission to view this content.'))
return wrapper
return decorator

View File

@@ -1,6 +1,6 @@
from django.dispatch import Signal
from pretix.base.signals import EventPluginSignal
from pretix.base.signals import DeprecatedSignal, EventPluginSignal
restriction_formset = EventPluginSignal(
providing_args=["item"]
@@ -47,11 +47,31 @@ nav_topbar = Signal(
)
"""
This signal allows you to add additional views to the top navigation bar.
You will get the request as a keyword argument ``return``.
You will get the request as a keyword argument ``request``.
Receivers are expected to return a list of dictionaries. The dictionaries
should contain at least the keys ``label`` and ``url``. You can also return
a fontawesome icon name with the key ``icon``, it will be respected depending
on the type of navigation. If set, on desktops only the ``icon`` will be shown.
The ``title`` property can be used to set the alternative text.
If you use this, you should read the documentation on :ref:`how to deal with URLs <urlconf>`
in pretix.
This is no ``EventPluginSignal``, so you do not get the event in the ``sender`` argument
and you may get the signal regardless of whether your plugin is active.
"""
nav_global = Signal(
providing_args=["request"]
)
"""
This signal allows you to add additional views to the navigation bar when no event is
selected. You will get the request as a keyword argument ``request``.
Receivers are expected to return a list of dictionaries. The dictionaries
should contain at least the keys ``label`` and ``url``. You can also return
a fontawesome icon name with the key ``icon``, it will be respected depending
on the type of navigation. You should also return an ``active`` key with a boolean
set to ``True``, when this item should be marked as active.
If you use this, you should read the documentation on :ref:`how to deal with URLs <urlconf>`
in pretix.
@@ -123,14 +143,29 @@ quota as argument in the ``quota`` keyword argument.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
organizer_edit_tabs = Signal(
organizer_edit_tabs = DeprecatedSignal(
providing_args=['organizer', 'request']
)
"""
This signal is sent out to include tabs on the detail page of an organizer. Receivers
should return a tuple with the first item being the tab title and the second item
being the content as HTML. The receivers get the ``organizer`` and the ``request`` as
keyword arguments.
This is a regular django signal (no pretix event signal).
Deprecated signal, no longer works. We just keep the definition so old plugins don't
break the installation.
"""
nav_organizer = Signal(
providing_args=['organizer', 'request']
)
"""
This signal is sent out to include tab links on the detail page of an organizer.
Receivers are expected to return a list of dictionaries. The dictionaries
should contain at least the keys ``label`` and ``url``. You should also return
an ``active`` key with a boolean set to ``True``, when this item should be marked
as active.
If your linked view should stay in the tab-like context of this page, we recommend
that you use ``pretix.control.views.organizer.OrganizerDetailViewMixin`` for your view
and your tempalte inherits from ``pretixcontrol/organizers/base.html``.
This is a regular django signal (no pretix event signal). Receivers will be passed
the keyword arguments ``organizer`` and ``request``.
"""

View File

@@ -32,8 +32,12 @@
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/mail.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asyncdownload.js" %}"></script>
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
<script type="text/javascript" src="{% static "fileupload/jquery.ui.widget.js" %}"></script>
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
{% endcompress %}
{{ html_head|safe }}
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -64,7 +68,7 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown"><i class="fa fa-calendar"></i>
{{ request.event }} <span class="caret"></span></a>
<ul class="dropdown-menu" role="menu">
<li><a href="{% url "control:events" %}">{% trans "Event overview" %}</a></li>
<li><a href="{% url "control:events" %}">{% trans "Events overview" %}</a></li>
{% regroup request.user.events_cache by organizer as event_list %}
{% for g in event_list %}
<li class="dropdown-header">{{ g.grouper }}</li>
@@ -78,7 +82,7 @@
<ul class="nav navbar-nav navbar-top-links navbar-right">
{% for nav in nav_topbar %}
<li {% if nav.children %}class="dropdown"{% endif %}>
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
<a href="{{ nav.url }}" title="{{ nav.title }}" {% if nav.active %}class="active"{% endif %}
{% if nav.children %}class="dropdown-toggle" data-toggle="dropdown"{% endif %}>
{% if nav.icon %}
<span class="fa fa-{{ nav.icon }}"></span>
@@ -113,7 +117,7 @@
</li>
{% endif %}
<li>
<a href="{% url 'control:user.settings' %}">
<a href="{% url 'control:user.settings' %}" title="{% trans "Account Settings" %}" >
<i class="fa fa-user"></i> {{ request.user.get_full_name }}
</a>
</li>
@@ -167,6 +171,32 @@
{% trans "Organizers" %}
</a>
</li>
{% for nav in nav_global %}
<li>
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
{% if nav.children %}class="has-children"{% endif %}>
{% if nav.icon %}
<i class="fa fa-{{ nav.icon }} fa-fw"></i>
{% endif %}
{{ nav.label }}
</a>
{% if nav.children %}
<a href="#" class="arrow">
<span class="fa arrow"></span>
</a>
<ul class="nav nav-second-level">
{% for item in nav.children %}
<li>
<a href="{{ item.url }}"
{% if item.active %}class="active"{% endif %}>
{{ item.label }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
{% endblock %}
</ul>
</div>

View File

@@ -0,0 +1,95 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% block title %}{% trans "Check-ins" %}{% endblock %}
{% block content %}
<h1>{% trans "Check-ins" %}</h1>
<p>
<form class="form-inline helper-display-inline" action="" method="get">
<select name="status" class="form-control">
<option value="">{% trans "All status" %}</option>
<option value="1" {% if request.GET.status == "1" %}selected="selected"{% endif %}>{% trans "Checked in" %}</option>
<option value="0" {% if request.GET.status == "0" %}selected="selected"{% endif %}>{% trans "Not checked in" %}</option>
</select>
<select name="item" class="form-control">
<option value="">{% trans "All products" %}</option>
{% for item in items %}
<option value="{{ item.id }}"
{% if request.GET.item|add:0 == item.id %}selected="selected"{% endif %}>
{{ item.name }}
</option>
{% endfor %}
</select>
<input type="text" name="user" class="form-control" placeholder="{% trans "Search user" %}" value="{{ request.GET.user }}">
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
</form>
</p>
{% if entries|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
No check-in record was found.
{% endblocktrans %}
</p>
</div>
{% else %}
{% include "pretixcontrol/pagination.html" %}
<form method="post" action="">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Order code" %} <a href="?{% url_replace request 'ordering' '-code'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Item" %} <a href="?{% url_replace request 'ordering' '-item'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'item'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Email" %} <a href="?{% url_replace request 'ordering' '-email'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'email'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Name" %} <a href="?{% url_replace request 'ordering' '-name'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'name'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Status" %} <a href="?{% url_replace request 'ordering' '-status'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'status'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Timestamp" %} <a href="?{% url_replace request 'ordering' '-timestamp'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'timestamp'%}"><i class="fa fa-caret-up"></i></a></th>
</tr>
</thead>
<tbody>
{% for e in entries %}
{% with e.checkins.first as checkin %}
<tr>
<td>
<strong><a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=e.order.code %}"
>{{ e.order.code }}</a></strong>
</td>
<td>{{ e.item.name }}</td>
<td>{{ e.order.email }}</td>
<td>
{% if e.addon_to %}
{{ e.addon_to.attendee_name }}
{% elif e.attendee_name %}
{{ e.attendee_name }}
{% endif %}
</td>
<td>
{% if not checkin %}
<span class="label label-danger">{% trans "Not checked in" %}</span>
{% else %}
<span class="label label-success">{% trans "Checked in" %}</span>
{% endif %}
</td>
<td>
{% if checkin %}
{{ checkin.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% endif %}
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
</form>
{% endif %}
{% endblock %}

View File

@@ -1,9 +1,10 @@
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
you have been invited to the team of an event that uses pretix for their
you have been invited to a team on pretix, a platform to perform event
ticket sales.
Event: {{ event }}
Organizer: {{ organizer }}
Team: {{ team }}
If you want to join that team, just click on the following link:
{{ url }}

View File

@@ -1,16 +0,0 @@
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
you have been invited to the team of an event organizer that uses pretix
for their ticket sales.
Organizer: {{ organizer }}
If you want to join that team, just click on the following link:
{{ url }}
If you do not want to join, you can safely ignore or delete this email.
Best regards,
Your pretix team
{% endblocktrans %}

View File

@@ -10,7 +10,7 @@
{% trans "Dashboard" %}
</a>
</li>
{% if request.eventperm.can_change_settings or request.eventperm.can_change_permissions %}
{% if 'can_change_event_settings' in request.eventpermset or 'can_change_permissions' in request.eventpermset %}
<li>
<a href="{% url 'control:event.settings' organizer=request.event.organizer.slug event=request.event.slug %}">
<i class="fa fa-wrench fa-fw"></i>
@@ -18,7 +18,7 @@
</a>
</li>
{% endif %}
{% if request.eventperm.can_change_items %}
{% if 'can_change_items' in request.eventpermset %}
<li>
<a href="{% url 'control:event.items' organizer=request.event.organizer.slug event=request.event.slug %}"
class="has-children">
@@ -55,7 +55,7 @@
</ul>
</li>
{% endif %}
{% if request.eventperm.can_view_orders %}
{% if 'can_view_orders' in request.eventpermset %}
<li>
<a href="{% url 'control:event.orders' organizer=request.event.organizer.slug event=request.event.slug %}"
class="has-children">
@@ -90,10 +90,16 @@
{% trans "Waiting list" %}
</a>
</li>
<li>
<a href="{% url 'control:event.orders.checkins' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.orders.checkins" %}class="active"{% endif %}>
{% trans "Check-ins" %}
</a>
</li>
</ul>
</li>
{% endif %}
{% if request.eventperm.can_view_vouchers %}
{% if 'can_view_vouchers' in request.eventpermset %}
<li>
<a href="{% url 'control:event.vouchers' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if url_name == "event.vouchers" %}class="active"{% endif %}>

View File

@@ -85,12 +85,15 @@
</div>
<div class="col-lg-2 col-sm-6 col-xs-12">
{% if log.user %}
<span class="fa fa-user"></span> {{ log.user.get_full_name }}
{% if log.user.is_superuser %}
<img src="{% static "pretixbase/img/pretix-icon-colored.svg" %}"
data-toggle="tooltip" class="user-admin-icon"
title="{% trans "This change was performed by a pretix administrator." %}">
<span class="fa fa-id-card fa-danger fa-fw"
data-toggle="tooltip"
title="{% trans "This change was performed by a pretix administrator." %}">
</span>
{% else %}
<span class="fa fa-user fa-fw"></span>
{% endif %}
{{ log.user.get_full_name }}
{% endif %}
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">

View File

@@ -15,9 +15,11 @@
{% trans "Customer actions" %}
</option>
{% for up in userlist %}
<option value="{{ up.user_id }}" {% if request.GET.user == up.user_id %}selected="selected"{% endif %}>
{{ up.user }}
</option>
{% if up.user__id %}
<option value="{{ up.user__id }}" {% if request.GET.user == up.user__id %}selected="selected"{% endif %}>
{{ up.user__email }}
</option>
{% endif %}
{% endfor %}
</select>
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
@@ -32,12 +34,15 @@
</div>
<div class="col-lg-2 col-sm-6 col-xs-12">
{% if log.user %}
<span class="fa fa-user"></span> {{ log.user.get_full_name }}
{% if log.user.is_superuser %}
<img src="{% static "pretixbase/img/pretix-icon-colored.svg" %}"
data-toggle="tooltip" class="user-admin-icon"
<span class="fa fa-id-card fa-danger fa-fw"
data-toggle="tooltip"
title="{% trans "This change was performed by a pretix administrator." %}">
</span>
{% else %}
<span class="fa fa-user fa-fw"></span>
{% endif %}
{{ log.user.get_full_name }}
{% endif %}
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">

View File

@@ -2,117 +2,39 @@
{% load i18n %}
{% load bootstrap3 %}
{% block inside %}
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data"
mail-preview-url="{% url "control:event.settings.mail.preview" event=request.event.slug organizer=request.event.organizer.slug %}">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "E-mail settings" %}</legend>
{% bootstrap_field form.mail_prefix layout="horizontal" %}
{% bootstrap_field form.mail_from layout="horizontal" %}
{% bootstrap_field form.mail_text_signature layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "E-mail content" %}</legend>
<div class="panel-group" id="questions_group">
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#order_placed">
<strong>{% trans "Placed order" %}</strong>
</a>
</h4>
</div>
<div id="order_placed" class="panel-collapse collapse">
<div class="panel-body">
{% bootstrap_field form.mail_text_order_placed layout="horizontal" %}
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#order_paid">
<strong>{% trans "Paid order" %}</strong>
</a>
</h4>
</div>
<div id="order_paid" class="panel-collapse collapse">
<div class="panel-body">
{% bootstrap_field form.mail_text_order_paid layout="horizontal" %}
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#order_free">
<strong>{% trans "Free order" %}</strong>
</a>
</h4>
</div>
<div id="order_free" class="panel-collapse collapse">
<div class="panel-body">
{% bootstrap_field form.mail_text_order_free layout="horizontal" %}
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#resend_link">
<strong>{% trans "Resend link" %}</strong>
</a>
</h4>
</div>
<div id="resend_link" class="panel-collapse collapse">
<div class="panel-body">
{% bootstrap_field form.mail_text_resend_link layout="horizontal" %}
{% bootstrap_field form.mail_text_resend_all_links layout="horizontal" %}
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#order_changed">
<strong>{% trans "Order changed" %}</strong>
</a>
</h4>
</div>
<div id="order_changed" class="panel-collapse collapse">
<div class="panel-body">
{% bootstrap_field form.mail_text_order_changed layout="horizontal" %}
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#order_expirew">
<strong>{% trans "Payment reminder" %}</strong>
</a>
</h4>
</div>
<div id="order_expirew" class="panel-collapse collapse">
<div class="panel-body">
{% bootstrap_field form.mail_days_order_expire_warning layout="horizontal" %}
{% bootstrap_field form.mail_text_order_expire_warning layout="horizontal" %}
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#waiting_list">
<strong>{% trans "Waiting list notification" %}</strong>
</a>
</h4>
</div>
<div id="waiting_list" class="panel-collapse collapse">
<div class="panel-body">
{% bootstrap_field form.mail_text_waiting_list layout="horizontal" %}
</div>
</div>
</div>
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed" %}
{% blocktrans asvar title_paid_order %}Paid order{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_paid" title=title_paid_order items="mail_text_order_paid" %}
{% blocktrans asvar title_free_order %}Free order{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_free" title=title_free_order items="mail_text_order_free" %}
{% blocktrans asvar title_resend_link %}Resend link{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="resend_link" title=title_resend_link items="mail_text_resend_link,mail_text_resend_all_links" %}
{% blocktrans asvar title_order_changed %}Order changed{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_changed" title=title_order_changed items="mail_text_order_changed" %}
{% blocktrans asvar title_payment_reminder %}Payment reminder{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_text_order_expire_warning" exclude="mail_days_order_expire_warning" %}
{% blocktrans asvar title_waiting_list_notification %}Waiting list notification{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="waiting_list" title=title_waiting_list_notification items="mail_text_waiting_list" %}
</div>
</fieldset>
<fieldset>

View File

@@ -0,0 +1,53 @@
{% load i18n %}
{% load bootstrap3 %}
{% load mail_settings_preview %}
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#{{ pid }}">
<strong>{% trans title %}</strong>
</a>
</h4>
</div>
<div id="{{ pid }}" class="panel-collapse collapse">
<div class="panel-body">
{% with exclude|split as exclusion %}
{% with items|split as item_list %}
{% for item in item_list %}
{% if item in exclusion %}
{% with form|getattr:item as field %}
{% bootstrap_field field layout="horizontal" %}
{% endwith %}
{% else %}
<div id="{{ item }}_panel" class="preview-panel form-group" for="{{ item }}">
{% with form|getattr:item as field %}
<label class="col-md-3 control-label">{{ field.label }}</label>
<div class="col-md-9">
<div class="tab-content">
<div id="{{ item }}_edit" class="tab-pane fade in active">
{% bootstrap_field field show_label=False form_group_class="" %}
</div>
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
{% for l in request.event.settings.locales %}
<pre lang="{{ l }}" for="{{ item }}" class="mail-preview"></pre>
{% endfor %}
</div>
</div>
<ul class="nav nav-pills pull-right">
<li role="presentation" class="active">
<a data-toggle="pill" type="edit" href="#{{ item }}_edit"><i class="fa fa-pencil-square-o fa-fw"></i> {% trans "Edit" %}</a>
</li>
<li role="presentation">
<a data-toggle="pill" type="preview" href="#{{ item }}_preview"><i class="fa fa-tv fa-fw"></i> {% trans "Preview" %}</a>
</li>
</ul>
</div>
{% endwith %}
</div>
{% endif %}
{% endfor %}
{% endwith %}
{% endwith %}
</div>
</div>
</div>

View File

@@ -1,88 +1,19 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load staticfiles %}
{% load bootstrap3 %}
{% block inside %}
<form action="" method="post" class="form-horizontal form-permissions">
{% csrf_token %}
<fieldset>
<legend>{% trans "Permissions" %}</legend>
{% bootstrap_formset_errors formset %}
{{ formset.management_form }}
<div class="table-responsive">
<table class="table table-striped table-condensed">
<thead>
<tr>
<th>{% trans "User" %}</th>
<th>{% trans "Change settings" %}</th>
<th>{% trans "Change products" %}</th>
<th>{% trans "View orders" %}</th>
<th>{% trans "Change orders" %}</th>
<th>{% trans "Change permissions" %}</th>
<th>{% trans "View vouchers" %}</th>
<th>{% trans "Change vouchers" %}</th>
<th>{% trans "Delete" %}</th>
</tr>
</thead>
<tbody>
{% for form in formset %}
<tr>
<td>
{{ form.id }}
{% if form.instance.user %}
{{ form.instance.user }}
{% else %}
{{ form.instance.invite_email }}
<span class="fa fa-envelope-o" data-toggle="tooltip"
title="{% trans "invited, pending response" %}"></span>
{% endif %}
</td>
<td>{{ form.can_change_settings }}</td>
<td>{{ form.can_change_items }}</td>
<td>{{ form.can_view_orders }}</td>
<td>{{ form.can_change_orders }}</td>
<td>{{ form.can_change_permissions }}</td>
<td>{{ form.can_view_vouchers }}</td>
<td>{{ form.can_change_vouchers }}</td>
<td>{{ form.DELETE }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="9">
<strong>{% trans "Adding a new user" %}</strong><br>
{% blocktrans trimmed %}
To add a new user, you can enter their email address here. If they already have a
pretix account, they will immediately be added to the event. Otherwise, they will
be sent an email with an invitation.
{% endblocktrans %}
</td>
</tr>
<tr>
<td>
<div class="row-fluid">
<div class="col-sm-12">
{% bootstrap_field add_form.user layout='inline' %}
</div>
</div>
</td>
<td>{{ add_form.can_change_settings }}</td>
<td>{{ add_form.can_change_items }}</td>
<td>{{ add_form.can_view_orders }}</td>
<td>{{ add_form.can_change_orders }}</td>
<td>{{ add_form.can_change_permissions }}</td>
<td>{{ add_form.can_change_vouchers }}</td>
<td>{{ add_form.can_view_vouchers }}</td>
</tr>
</tfoot>
</table>
</div>
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
<div class="section-moved">
<img src="{% static "pretixcontrol/img/moved.svg" %}" class="img-moved">
<p>
{% blocktrans trimmed %}
Permission settings have moved and are now configured as part of an organizer account instead
of every event on its own.
{% endblocktrans %}
</p>
<a href="{% url "control:organizer.teams" organizer=request.event.organizer.slug %}"
class="btn btn-link btn-lg">{% trans "Go to the organizer team settings" %}</a>
</div>
</form>
{% endblock %}

View File

@@ -12,6 +12,7 @@
{% bootstrap_field form.date_from layout="horizontal" %}
{% bootstrap_field form.date_to layout="horizontal" %}
{% bootstrap_field form.location layout="horizontal" %}
{% bootstrap_field form.date_admission layout="horizontal" %}
{% bootstrap_field form.currency layout="horizontal" %}
{% bootstrap_field form.is_public layout="horizontal" %}
</fieldset>
@@ -41,6 +42,8 @@
{% bootstrap_field sform.max_items_per_order layout="horizontal" %}
{% bootstrap_field sform.attendee_names_asked layout="horizontal" %}
{% bootstrap_field sform.attendee_names_required layout="horizontal" %}
{% bootstrap_field sform.attendee_emails_asked layout="horizontal" %}
{% bootstrap_field sform.attendee_emails_required layout="horizontal" %}
{% bootstrap_field sform.cancel_allow_user layout="horizontal" %}
</fieldset>
<fieldset>

View File

@@ -5,7 +5,7 @@
{% block content %}
<h1>{% trans "Settings" %}</h1>
<ul class="nav nav-pills">
{% if request.eventperm.can_change_settings %}
{% if 'can_change_event_settings' in request.eventpermset %}
<li {% if "event.settings" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.settings' organizer=request.event.organizer.slug event=request.event.slug %}">
{% trans "General" %}
@@ -41,8 +41,6 @@
{% trans "Invoicing" %}
</a>
</li>
{% endif %}
{% if request.eventperm.can_change_permissions %}
<li {% if "event.settings.permissions" == url_name %}class="active"{% endif %}>
<a href="{% url 'control:event.settings.permissions' organizer=request.event.organizer.slug event=request.event.slug %}">
{% trans "Permissions" %}

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