Compare commits

...
204 Commits
Author SHA1 Message Date
Raphael Michel 8113d9471d Add tests for locking in API 2023-09-26 01:13:04 +02:00
Felix HartnagelandRaphael Michel 20edbe47d6 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5436 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2023-09-26 01:09:26 +02:00
Mahdia AliyyandRaphael Michel 9b6e7f369c Translations: Update Indonesian
Currently translated at 100.0% (5436 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-22 13:26:42 +02:00
Raphael Michel 3ba36512d2 Add event_pattern like event_url 2023-09-22 12:24:01 +02:00
Richard Schreiber d7d419c826 Remove empty optgroups from language selector widget 2023-09-21 15:41:31 +02:00
Mahdia AliyyandRaphael Michel acb35866ba Translations: Update Indonesian
Currently translated at 94.8% (201 of 212 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/id/

powered by weblate
2023-09-20 16:08:12 +02:00
Mahdia AliyyandRaphael Michel 11a04acae8 Translations: Update Indonesian
Currently translated at 100.0% (5436 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-20 16:08:12 +02:00
Mahdia AliyyandRaphael Michel b882d84678 Translations: Update Indonesian
Currently translated at 94.8% (201 of 212 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/id/

powered by weblate
2023-09-20 16:08:12 +02:00
Mahdia AliyyandRaphael Michel 8e0ad694b7 Translations: Update Indonesian
Currently translated at 100.0% (5436 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-20 16:08:12 +02:00
39a0eaff3e Add language translation score to selector widget (#3603)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-09-20 16:02:18 +02:00
Mahdia AliyyandRaphael Michel 7b3eeaf411 Translations: Update Indonesian
Currently translated at 94.8% (201 of 212 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/id/

powered by weblate
2023-09-19 09:21:13 +02:00
Mahdia AliyyandRaphael Michel 3f536c2c7a Translations: Update Indonesian
Currently translated at 100.0% (5436 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-19 09:21:13 +02:00
Martin GrossandGitHub 36203d653b Display amounts with currency-derived decimals (#3604) 2023-09-18 17:48:34 +02:00
Mahdia AliyyandRaphael Michel fae45d066a Translations: Update Indonesian
Currently translated at 93.0% (5059 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 17:47:44 +02:00
Raphael MichelandRaphael Michel 5de5642b59 Translations: Update Indonesian
Currently translated at 92.9% (5054 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 17:47:44 +02:00
Mahdia AliyyandRaphael Michel 5991a71316 Translations: Update Indonesian
Currently translated at 92.9% (5054 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 17:47:44 +02:00
Mahdia AliyyandRaphael Michel da09baa712 Translations: Update Indonesian
Currently translated at 25.9% (55 of 212 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/id/

powered by weblate
2023-09-18 17:47:44 +02:00
Mahdia AliyyandRaphael Michel 271d58d2d9 Translations: Update Indonesian
Currently translated at 92.9% (5052 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 17:47:44 +02:00
Raphael Michel 400967c9e2 Add Indonesian language 2023-09-18 17:43:00 +02:00
Raphael MichelandRaphael Michel b37947a94a Translations: Update Indonesian
Currently translated at 9.0% (490 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Mahdia AliyyandRaphael Michel f86e869702 Translations: Update Indonesian
Currently translated at 9.0% (490 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Raphael MichelandRaphael Michel 43a27dc0b0 Translations: Update Indonesian
Currently translated at 8.9% (488 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Mahdia AliyyandRaphael Michel 2e475432f6 Translations: Update Indonesian
Currently translated at 8.9% (488 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Mahdia AliyyandRaphael Michel 03dfeab03f Translations: Update Indonesian
Currently translated at 7.9% (433 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Aufa FadhlurohmanandRaphael Michel e86a53a763 Translations: Update Indonesian
Currently translated at 10.3% (22 of 212 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Mahdia AliyyandRaphael Michel 99deb7a78c Translations: Update Indonesian
Currently translated at 2.7% (152 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Aufa FadhlurohmanandRaphael Michel 6c69bc6a78 Translations: Update Indonesian
Currently translated at 2.7% (152 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Mahdia AliyyandRaphael Michel 9f6b9f03e6 Translations: Update Indonesian
Currently translated at 1.2% (70 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Aufa FadhlurohmanandRaphael Michel d9b8d61ac6 Translations: Update Indonesian
Currently translated at 1.2% (70 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Rubén VargasandRaphael Michel 6eff5d0cf8 Translations: Update Spanish
Currently translated at 58.4% (3179 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2023-09-18 13:20:04 +02:00
Aufa FadhlurohmanandRaphael Michel d2a6c6600d Translations: Update Indonesian
Currently translated at 1.1% (61 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Aufa FadhlurohmanandRaphael Michel 1f01ab232e Translations: Update Indonesian
Currently translated at 9.9% (21 of 212 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Mahdia AliyyandRaphael Michel c74bd17f59 Translations: Update Indonesian
Currently translated at 1.1% (61 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Aufa FadhlurohmanandRaphael Michel 980d9a1671 Translations: Update Indonesian
Currently translated at 1.1% (61 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-18 13:20:04 +02:00
Ed BennettandRaphael Michel 0ac5290e90 Translations: Update Welsh
Currently translated at 4.3% (234 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cy/

powered by weblate
2023-09-18 13:20:04 +02:00
Ed BennettandRaphael Michel c7a9ff52e3 Translations: Update Welsh
Currently translated at 4.2% (230 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cy/

powered by weblate
2023-09-18 13:20:04 +02:00
MichaelandRaphael Michel 111598db7a Translations: Update Czech
Currently translated at 78.8% (4287 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cs/

powered by weblate
2023-09-18 13:20:04 +02:00
Ed BennettandRaphael Michel be53751fc7 Translations: Update Welsh
Currently translated at 3.8% (207 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cy/

powered by weblate
2023-09-18 13:20:04 +02:00
MichaelandRaphael Michel 8d07e2dc92 Translations: Update Czech
Currently translated at 96.6% (205 of 212 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/cs/

powered by weblate
2023-09-18 13:20:04 +02:00
MichaelandRaphael Michel c2a42db9cd Translations: Update Czech
Currently translated at 77.3% (4205 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cs/

powered by weblate
2023-09-18 13:20:04 +02:00
Raphael Michel bedd841063 Metrics: Set batch size for redis hscan 2023-09-18 12:00:37 +02:00
Raphael Michel 543b089acf Fix #3599 -- Add instructions for PostgreSQL migration with docker 2023-09-18 11:03:52 +02:00
Raphael Michel 575858aec6 Check-in: Change explanation weight of gate (Z#23130674) 2023-09-15 17:20:59 +02:00
Ed BennettandRaphael Michel 6ceb5bd7ce Translations: Update Welsh
Currently translated at 3.5% (191 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cy/

powered by weblate
2023-09-14 17:38:09 +02:00
Ed BennettandRaphael Michel 162fd9e656 Translations: Update Welsh
Currently translated at 3.4% (188 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cy/

powered by weblate
2023-09-14 17:38:09 +02:00
MichaelandRaphael Michel ae73370a3e Translations: Update Czech
Currently translated at 76.3% (4151 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cs/

powered by weblate
2023-09-14 17:38:09 +02:00
MichaelandRaphael Michel 5f6ec5e771 Translations: Update Czech
Currently translated at 75.9% (4129 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cs/

powered by weblate
2023-09-14 17:38:09 +02:00
Ed BennettandRaphael Michel bf548904f3 Translations: Update Welsh
Currently translated at 3.0% (166 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cy/

powered by weblate
2023-09-14 17:38:09 +02:00
MichaelandRaphael Michel f280eedc21 Translations: Update Czech
Currently translated at 75.7% (4119 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cs/

powered by weblate
2023-09-14 17:38:09 +02:00
Ed BennettandRaphael Michel 57b5036f36 Translations: Update Welsh
Currently translated at 2.9% (162 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cy/

powered by weblate
2023-09-14 17:38:09 +02:00
Ed BennettandRaphael Michel 545d911219 Translations: Update Welsh
Currently translated at 2.9% (158 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cy/

powered by weblate
2023-09-14 17:38:09 +02:00
Ed BennettandRaphael Michel c58cfb59ad Translations: Update Welsh
Currently translated at 2.7% (152 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cy/

powered by weblate
2023-09-14 17:38:09 +02:00
Raphael MichelandGitHub 56803e3d65 Fix voucher and order views for users with read-only permissions (#3594) 2023-09-14 17:37:48 +02:00
Richard Schreiber 7d8a788361 Docs: fix code block in widget data-attributes docs 2023-09-14 12:02:06 +02:00
ecb4c34e08 Payment providers: Allow to set an availability start date per method (Z#23126769) (#3569)
Co-authored-by: Mira <weller@rami.io>
2023-09-14 11:08:37 +02:00
Richard SchreiberandGitHub 75abab403a Widget: make data-attributes reactive (#3586) 2023-09-14 10:51:35 +02:00
Raphael Michel dea7de4e6c Fix Order.can_modify_answers if only invoice name is required 2023-09-14 10:35:04 +02:00
Richard SchreiberandGitHub af3100a354 E-mail content: insert placeholder into text on click 2023-09-14 10:32:22 +02:00
Richard SchreiberandGitHub 66ae5d1af2 Widget: fix running different versions of Vue on the same page 2023-09-14 10:31:18 +02:00
Ronan LE MEILLATandRaphael Michel 9efb1653ee Translations: Update French
Currently translated at 100.0% (212 of 212 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/fr/

powered by weblate
2023-09-12 17:18:55 +02:00
Ronan LE MEILLATandRaphael Michel c6f1441976 Translations: Update French
Currently translated at 100.0% (5436 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2023-09-12 17:18:55 +02:00
Ronan LE MEILLATandRaphael Michel 7d08337c49 Translations: Update French
Currently translated at 100.0% (5436 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2023-09-12 17:18:55 +02:00
Ronan LE MEILLATandRaphael Michel c92a1eb641 Translations: Update French
Currently translated at 100.0% (5436 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2023-09-12 17:18:55 +02:00
Raphael Michel babe0934a8 Fix incorrect usage of email subject setting 2023-09-12 16:58:51 +02:00
Raphael Michel 160e0e0531 Email settings: Fix wrong placeholder configuration 2023-09-12 16:58:41 +02:00
Raphael Michel 183387a683 Waiting list: Add email placeholder {name} 2023-09-12 16:53:12 +02:00
Raphael Michel cea0ac59bf API: Fix parsing of time frames for exporters 2023-09-12 16:45:01 +02:00
Felix FreibergerandGitHub 3b6f116f5d Fix typo in gift card acceptance page (#3592) 2023-09-12 12:37:45 +02:00
Raphael Michel e884c9820f Move new settings to _base_settings 2023-09-12 11:56:49 +02:00
Raphael Michel 7545e92373 [SECURITY] Do not allow Pillow to parse EPS files 2023-09-12 11:50:01 +02:00
Raphael MichelandGitHub b16680e0e5 Docker: Upgrade to Debian bookworm (#3591) 2023-09-12 09:45:15 +02:00
Raphael MichelandGitHub eb04fdf4d2 Check-in rules: New variables (#3521) 2023-09-12 09:43:57 +02:00
c842ea597c New locking mechanism (#2408)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-09-11 11:44:50 +02:00
Raphael Michel b2b3fa36be Fix incorrect handling of boolean configuration flags 2023-09-11 10:00:59 +02:00
Raphael Michel af50bc2fa4 QR code generator: Lift permission requirement 2023-09-08 17:35:29 +02:00
Raphael Michel 734a116b6f Pass subevent to item_descriptions signal 2023-09-08 17:35:29 +02:00
Raphael MichelandRaphael Michel 8f36e36619 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5436 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2023-09-08 15:58:29 +02:00
Raphael MichelandRaphael Michel 2f35e1fcc6 Translations: Update German
Currently translated at 100.0% (5436 of 5436 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2023-09-08 15:58:29 +02:00
Raphael Michel cdfc2190d5 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-09-08 15:36:58 +02:00
8dbc7ac5d7 Bank transfer: Hide email form if email was already sent (Z#23128941) (#3570)
Co-authored-by: Mira <weller@rami.io>
2023-09-08 14:11:45 +02:00
Richard Schreiber b04ec720dc Fix details sneak-peek keyup 2023-09-08 13:15:19 +02:00
Raphael Michel 1478f191a8 Fix crash in send_expiry_warnings 2023-09-08 12:35:02 +02:00
Raphael MichelandRaphael Michel 171b1de0c4 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5435 of 5435 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2023-09-08 10:24:29 +02:00
Raphael MichelandRaphael Michel 799f1a5388 Translations: Update German
Currently translated at 100.0% (5435 of 5435 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2023-09-08 10:24:29 +02:00
Raphael MichelandRaphael Michel 3cdb61fcf9 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5435 of 5435 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2023-09-08 10:24:29 +02:00
Raphael MichelandRaphael Michel e5ce2deaeb Translations: Update German
Currently translated at 100.0% (5435 of 5435 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2023-09-08 10:24:29 +02:00
Raphael Michel f986f20f6a Translations: Update wordlist 2023-09-08 10:10:36 +02:00
Raphael Michel 8f22d52e24 Fix misformatted translation strings 2023-09-08 10:05:08 +02:00
Raphael Michel 1c944baafa Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-09-08 09:50:53 +02:00
Raphael Michel e55e3eeb0c Docs: Adjust install guide for Debian 12 2023-09-08 09:50:00 +02:00
Aufa FadhlurohmanandRaphael Michel 063967528f Translations: Update Indonesian
Currently translated at 0.1% (4 of 5426 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-08 09:49:07 +02:00
Aufa FadhlurohmanandRaphael Michel edbe42c524 Translations: Update Indonesian
Currently translated at 5.6% (12 of 212 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/id/

powered by weblate
2023-09-08 09:49:07 +02:00
Aufa FadhlurohmanandRaphael Michel 50a855a964 Translations: Update Indonesian
Currently translated at 0.1% (2 of 5426 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/id/

powered by weblate
2023-09-08 09:49:07 +02:00
Tuomas SavelaandRaphael Michel 1ad81d21a4 Translations: Update Finnish
Currently translated at 16.4% (893 of 5426 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fi/

powered by weblate
2023-09-08 09:49:07 +02:00
MiraandGitHub 0f8fa3fe47 Fix: keep is_business heuristic result for a longer time (Z#23126061) (#3582) 2023-09-08 09:44:19 +02:00
Martin GrossandGitHub 45b9a705e2 Allow to refund currencies with zero decimals (#3583) 2023-09-07 17:55:43 +02:00
Phin WolkwitzandGitHub 3f07050d42 Payment: Add setting to prevent reminder mails (Z#23123914) (#3573)
Adds a checkbox in each payment provider's settings controlling whether sending out expiry reminders should be prevented
2023-09-07 14:27:09 +02:00
Raphael Michel f97e6d0a71 Fix missing white space 2023-09-07 11:16:05 +02:00
Raphael MichelandGitHub c0031e4579 Add bulk operations for orders (#3548)
* Add bulk operations for orders

* UI tweaks

* Fix test failures

* Fix filter form

* Add tests

* Run isort
2023-09-06 17:02:21 +02:00
Raphael MichelandGitHub ce73d4831e Allow to sort the list of check-in lists (Z#23128978) (#3568) 2023-09-06 13:23:54 +02:00
Raphael MichelandRaphael Michel fbfae0838a Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5426 of 5426 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2023-09-06 12:33:27 +02:00
Raphael MichelandRaphael Michel 5445a79572 Translations: Update German
Currently translated at 100.0% (5426 of 5426 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2023-09-06 12:33:27 +02:00
Raphael MichelandRaphael Michel 97a03f0aae Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5426 of 5426 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2023-09-06 12:33:27 +02:00
Raphael MichelandRaphael Michel 780a5cdeee Translations: Update German
Currently translated at 100.0% (5426 of 5426 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2023-09-06 12:33:27 +02:00
Phin WolkwitzandGitHub 991f245dce Scheduled emails: Extend filter by subevents (Z#23122902) (#3551)
To create automated mail rules for specific subevents only, this adds a selection widget to choose which, only appearing if there are any subevents to select.
2023-09-06 12:05:37 +02:00
Raphael Michel 8913d35838 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-09-06 10:12:50 +02:00
Ash SoandRaphael Michel 7f2616cf80 Translations: Update Chinese (Traditional)
Currently translated at 99.7% (5385 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/zh_Hant/

powered by weblate
2023-09-06 10:12:16 +02:00
7d9b54709a Update translations (#3561)
Co-authored-by: Ash So <ashs@vankaifong.com>
Co-authored-by: subpublic <anders@subpublic.com>
2023-09-06 10:11:31 +02:00
231e05f967 API: Add parent event slug to subevent list search (#3567)
Co-authored-by: Raphael Michel <michel@rami.io>
2023-09-06 10:10:04 +02:00
Raphael Michel e50db7844b Log pretix.customer.created when creating account through OIDC 2023-09-06 09:55:56 +02:00
Raphael Michel b43523ea65 API: Fix order and invoice viewset with staff permissions 2023-09-06 09:38:42 +02:00
Raphael Michel 447370d7b3 API: Fix subevent_before/after on organizer-level order view 2023-09-06 09:38:42 +02:00
Raphael MichelandGitHub 6a36efd18c Invoice settings: Reorder and explain recommended options (#3572) 2023-09-05 16:42:16 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c1ebbfe82a Bump @rollup/plugin-node-resolve from 15.1.0 to 15.2.1 in /src/pretix/static/npm_dir (#3564)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-04 21:35:34 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7f36a80dd8 Bump @babel/preset-env in /src/pretix/static/npm_dir (#3571)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.22.9 to 7.22.15.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.22.15/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-04 18:42:24 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c52b998ea8 Bump @babel/core from 7.22.9 to 7.22.11 in /src/pretix/static/npm_dir (#3563)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-04 17:29:32 +02:00
Raphael MichelandGitHub eecd002ffd API: Add webhooks for customer events (#3558) 2023-09-04 08:47:30 +02:00
MiraandGitHub 53027b621f Update payment.py (#3559) 2023-08-31 15:18:23 +02:00
robbi5andGitHub 826c54bce7 Questions: Enable phonenumber type during checkin (#3557) 2023-08-31 13:46:19 +02:00
Ash SoandRaphael Michel 0cf2f06fcf Translations: Update Chinese (Traditional)
Currently translated at 99.7% (5385 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/zh_Hant/

powered by weblate
2023-08-31 11:02:05 +02:00
Raphael MichelandGitHub 12095c0c9c Sendmail: Rename "automated emails" to "scheduled emails" (#3556) 2023-08-31 11:01:50 +02:00
bca4cd1e23 Update translations (#3553)
Co-authored-by: Ash So <ashs@vankaifong.com>
Co-authored-by: Martin Gross <gross@rami.io>
2023-08-31 09:38:28 +02:00
Raphael Michel 53e84dfb08 API: Fix validation of duplicate customer email addresses 2023-08-30 16:57:15 +02:00
Raphael MichelandGitHub 2e8447486c Improve edge cases in handling of check-in nonces (#3516) 2023-08-30 10:43:24 +02:00
5b184bb1a0 Fix leaflet osm tile suggestion (#3549)
Co-authored-by: Raphael Michel <michel@rami.io>
2023-08-30 09:53:41 +02:00
Ash SoandRaphael Michel 8c6f0a5dc1 Translations: Update Chinese (Traditional)
Currently translated at 99.7% (5384 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/zh_Hant/

powered by weblate
2023-08-30 09:46:11 +02:00
AlainandRaphael Michel 6a53091b91 Translations: Update Dutch
Currently translated at 84.4% (4559 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl/

powered by weblate
2023-08-30 09:46:11 +02:00
MiraandGitHub be4bc9a6f3 TemplateBasedMailRenderer: make markdown compiler call overridable (#3550) 2023-08-30 09:41:34 +02:00
Raphael Michel efb1141d59 PayPal: Add missing payment.fail() statements 2023-08-29 15:10:05 +02:00
Raphael Michel 322a730eb2 PayPal: Fix incorrect Decimal comparison 2023-08-29 15:06:03 +02:00
Raphael MichelandGitHub 8d2224e725 API: Allow organizer-level access of orders and invoices (#3547) 2023-08-28 16:54:42 +02:00
Raphael Michel 5b819b76f0 Check-in: Fix N+1 query issue identified by sentry 2023-08-28 16:54:09 +02:00
Raphael MichelandGitHub 5d90a42acf Discounts: Allow "buy X to get Y" with different product sets for X and Y (#3543) 2023-08-28 16:21:52 +02:00
Raphael Michel 5398671fde Fix crash in invoice address detection (PRETIXEU-8XE) 2023-08-28 11:45:30 +02:00
Raphael Michel f7d4460deb Fix N+1 query issues detected by Sentry 2023-08-26 16:24:03 +02:00
AlainandRaphael Michel f76576a587 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 73.7% (3982 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl_Informal/

powered by weblate
2023-08-24 18:07:17 +02:00
AlainandRaphael Michel cf5f0dc7f9 Translations: Update Dutch
Currently translated at 80.6% (171 of 212 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nl/

powered by weblate
2023-08-24 18:07:17 +02:00
AlainandRaphael Michel 567984bd5e Translations: Update Dutch
Currently translated at 84.2% (4549 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/nl/

powered by weblate
2023-08-24 18:07:17 +02:00
Raphael MichelandGitHub 1c6bd46d21 Order data export: Add product/variation ID (#3542) 2023-08-24 18:02:24 +02:00
MiraandGitHub 9ba3227837 Checkout: Prefill is_business heuristically (Z#23126061) (#3533) 2023-08-24 17:06:47 +02:00
Richard SchreiberandGitHub 21864885cb Checkout: improve heuristic to open invoice-panel (#3545) 2023-08-24 16:15:47 +02:00
MiraandGitHub 38173e3a54 Tax rules: add custom rules for country subdivision (e.g. state) (Z#23111850) (#3520) 2023-08-24 14:11:10 +02:00
4baf317934 Automated emails: Extend filter by check-in state (#3489)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Raphael Michel <michel@rami.io>
2023-08-23 16:19:27 +02:00
Raphael MichelandGitHub c2b25bad06 Event dashboard: Fix incorrect optimization introduced in 8e9f0f07a (#3540) 2023-08-23 14:54:38 +02:00
Raphael Michel 9e3ad6c05c Order payment step: Pass info_data to checkout_confirm_render 2023-08-23 12:18:49 +02:00
Raphael Michel f017de1a21 Voucher bulk creation: Fix validation issue 2023-08-23 12:18:30 +02:00
Raphael MichelandGitHub b56bd8541e Devices: Fix bulk edit query (#3541) 2023-08-23 11:20:26 +02:00
Raphael Michel 1c9219609a Fix progress callback for slow_delete helper 2023-08-23 10:50:39 +02:00
0c96f758a8 Fix quota cache mixup (#3539)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-08-23 10:09:50 +02:00
Richard SchreiberandGitHub 9bd3444aad PDF: fix deduplicated list of addons (exclude canceled) (#3538) 2023-08-22 14:05:30 +02:00
Raphael Michel 10a83935d9 CartManager: Fix TransactionManagementError
Bug occured when extending a product and deleting it at the same time
2023-08-22 13:42:56 +02:00
Raphael Michel e8ea6e0f5c Item creation: Fix failing test 2023-08-22 12:59:57 +02:00
Raphael Michel e94e5be878 Item creation: Fix bug in copying meta data 2023-08-22 11:32:43 +02:00
Richard SchreiberandGitHub 1073ea626e Banktransfer: make row-headers sticky (Z#23127000) (#3537) 2023-08-22 10:53:26 +02:00
Raphael MichelandRaphael Michel 23ab8df443 Translations: Add Welsh 2023-08-22 10:53:15 +02:00
Kian CrossandGitHub d6caf01a38 Add warning about configuration of Celery in development mode to docs (#3525) 2023-08-22 10:44:11 +02:00
Raphael Michel 1424ae78e9 Revert accidental change 2023-08-22 10:20:19 +02:00
Raphael Michel 827382edc3 Bump redis to 4.6.* 2023-08-22 09:43:21 +02:00
Maurice KaagandRaphael Michel 85482bc939 Translations: Update French
Currently translated at 100.0% (5400 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2023-08-22 09:20:21 +02:00
Felix HartnagelandRaphael Michel 42ce545f2f Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5400 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2023-08-22 09:20:21 +02:00
Raphael Michel e49bc5d78d Item creation: Fix crash (PRETIXEU-8VE) 2023-08-22 09:14:23 +02:00
Richard Schreiber 6e7a32ef2a Vouchers: improve batch-select UI 2023-08-22 09:11:14 +02:00
Raphael MichelandGitHub 37df7a6313 Allow PDF variables to provide a bulk evaluation method (second try at #3517) (#3535) 2023-08-21 17:59:55 +02:00
Raphael MichelandGitHub d5951415a4 Item creation: Fix saving meta data (#3534) 2023-08-21 16:21:17 +02:00
Raphael Michel 691159ed83 Check-in list: Fix ordering by seat 2023-08-21 15:41:50 +02:00
Raphael Michel 18f517af44 Waiting list: Extend compatibility note 2023-08-21 14:52:39 +02:00
89ba2da7e7 QR code generator for voucher URLs and general URLs (#3518)
* QR code generator: Allow other URLs to be used (e.g. for plugins)

* Add QR code to voucher URL view

* Fix allowed_hosts

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-08-17 10:10:27 +02:00
c1c47e50c3 Voucher redemption: Display event title in some cases (#3519)
* Voucher redemption: Display event title in some cases (Z#23127871)

* Remove unnecessary "with" statement

* fix indentation

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-08-17 09:17:47 +02:00
Richard SchreiberandGitHub f262cd632c Chekout: make disabling sneak-peek more robust (#3527) 2023-08-16 15:02:02 +02:00
Richard Schreiber 8d58294af1 Fix typeahead item variations order_by 2023-08-16 10:01:27 +02:00
Richard Schreiber ddc94a8a16 Revert "Allow PDF variables to provide a bulk evaluation method (#3517)"
This reverts commit 6ada83df9a.
2023-08-14 15:11:13 +02:00
Raphael Michel 83811c0343 Fix minor CSS issue in button groups 2023-08-10 14:12:19 +02:00
Raphael Michel b2c05a72e5 Voucher list: Fix ordering by product 2023-08-10 11:29:10 +02:00
Martin Gross 8c56a23562 Add logentry plain for pretix.giftcards.acceptance.acceptor.removed 2023-08-10 11:21:54 +02:00
Raphael Michel 53e1d9c6c4 Tests: Fix improper cleanup of SITE_URL 2023-08-10 11:20:26 +02:00
6250ab2165 Bank transfer: Allow customer to send latest invoice via email (Z#207218) (#3511)
Co-authored-by: Raphael Michel <michel@rami.io>
2023-08-09 18:23:45 +02:00
Raphael MichelandGitHub 6ada83df9a Allow PDF variables to provide a bulk evaluation method (#3517) 2023-08-09 18:22:56 +02:00
Raphael Michel cfd6376936 Fix transaction view after Django upgrade 2023-08-09 17:11:20 +02:00
Raphael Michel edb0cd0941 Update STORAGES in docker settings 2023-08-09 15:01:21 +02:00
Raphael MichelandGitHub 88ac407cf3 Cart: Disable sneak peek on very small carts (#3512) 2023-08-09 14:53:50 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5ba56fb5ac Bump @babel/core from 7.22.5 to 7.22.9 in /src/pretix/static/npm_dir (#3501)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-09 14:53:34 +02:00
Raphael MichelandGitHub b51c9f7552 Upgrade to Django 4.2 (#3497) 2023-08-09 14:47:41 +02:00
Ronan LE MEILLATandRaphael Michel 0853296663 Translations: Update French
Currently translated at 99.9% (5398 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2023-08-09 14:47:26 +02:00
Raphael Michel 721e7549bc Remove forgotten debug statement 2023-08-09 10:34:24 +02:00
Martin GrossandGitHub aee86de330 Import: Allow to import "False"-value (Z#23127414) (#3505) 2023-08-08 15:36:51 +02:00
Raphael Michel 756a4355d1 Use newer postgres version for test 2023-08-08 15:32:02 +02:00
MiraandGitHub 5119bbd0b1 Docs: Update i18n.rst (fix dead link) (#3513) 2023-08-08 15:04:51 +02:00
Raphael Michel 728bd74e28 Organizer settings: Move save button to the left 2023-08-07 17:44:52 +02:00
MiraandGitHub 015ffeecbf Main menu: Add load indicator to event selector (#3508) 2023-08-07 14:25:50 +02:00
Raphael MichelandGitHub 0365f6d9fc Order change manager: Set new expiry date if splitted order is pending (#3509) 2023-08-07 14:13:44 +02:00
Raphael MichelandGitHub e208a79c32 Docs: Update implementation docs for URL routing (#3510) 2023-08-07 14:13:19 +02:00
ticketflockandRaphael Michel 0037d37960 Translations: Add English (Old) 2023-08-07 14:04:34 +02:00
ticketflockandRaphael Michel 50d9b1e4a3 Translations: Add English (Middle) 2023-08-07 14:04:34 +02:00
Patrizia CotzaandRaphael Michel 7919d012e6 Translations: Update Spanish
Currently translated at 58.5% (3159 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2023-08-07 14:04:34 +02:00
Ronan LE MEILLATandRaphael Michel 327f95a9cc Translations: Update French
Currently translated at 100.0% (212 of 212 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/fr/

powered by weblate
2023-08-07 14:04:34 +02:00
Ronan LE MEILLATandRaphael Michel 98946ded4b Translations: Update French
Currently translated at 99.9% (5398 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2023-08-07 14:04:34 +02:00
Ronan LE MEILLATandRaphael Michel cf47b69bd3 Translations: Update French
Currently translated at 99.3% (5366 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2023-08-07 14:04:34 +02:00
Raphael MichelandRaphael Michel fa5c69ce0a Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5400 of 5400 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2023-08-07 14:04:34 +02:00
Raphael Michel 39d85fc112 Event creation: Fix rare crash (PRETIXEU-8RD) 2023-08-07 09:47:14 +02:00
MiraandGitHub 23e222bf13 Sidebar dropdown: remove menu load delay 2023-08-03 14:28:59 +02:00
Raphael Michel cb068b029f Wallet detection: Fix race condition 2023-07-28 17:31:47 +02:00
Raphael Michel 9e95f3be1b Wallet detection: Extend CSP header for google pay 2023-07-28 16:49:11 +02:00
Raphael Michel 401c02865b Voucher form: Sort quotas by date 2023-07-28 16:29:03 +02:00
Raphael Michel 062450002d Bump to 2023.8.0.dev0 2023-07-28 09:30:04 +02:00
266 changed files with 181942 additions and 75566 deletions
+5 -1
View File
@@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: harmon758/postgresql-action@v1 - uses: harmon758/postgresql-action@v1
with: with:
postgresql version: '11' postgresql version: '15'
postgresql db: 'pretix' postgresql db: 'pretix'
postgresql user: 'postgres' postgresql user: 'postgres'
postgresql password: 'postgres' postgresql password: 'postgres'
@@ -66,6 +66,10 @@ jobs:
- name: Run tests - name: Run tests
working-directory: ./src working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/travis_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml --reruns 3 tests --maxfail=100 run: PRETIX_CONFIG_FILE=tests/travis_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml --reruns 3 tests --maxfail=100
- name: Run concurrency tests
working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/travis_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reruns 0 --reuse-db
if: matrix.database == 'postgres'
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v1 uses: codecov/codecov-action@v1
with: with:
+12 -12
View File
@@ -1,4 +1,4 @@
FROM python:3.11-bullseye FROM python:3.11-bookworm
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
@@ -20,20 +20,20 @@ RUN apt-get update && \
supervisor \ supervisor \
libmaxminddb0 \ libmaxminddb0 \
libmaxminddb-dev \ libmaxminddb-dev \
zlib1g-dev && \ zlib1g-dev \
nodejs \
npm && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
dpkg-reconfigure locales && \ dpkg-reconfigure locales && \
locale-gen C.UTF-8 && \ locale-gen C.UTF-8 && \
/usr/sbin/update-locale LANG=C.UTF-8 && \ /usr/sbin/update-locale LANG=C.UTF-8 && \
mkdir /etc/pretix && \ mkdir /etc/pretix && \
mkdir /data && \ mkdir /data && \
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \ useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \ echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
mkdir /static && \ mkdir /static && \
mkdir /etc/supervisord && \ mkdir /etc/supervisord
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash - && \
apt-get install -y nodejs
ENV LC_ALL=C.UTF-8 \ ENV LC_ALL=C.UTF-8 \
@@ -63,10 +63,10 @@ RUN pip3 install -U \
RUN chmod +x /usr/local/bin/pretix && \ RUN chmod +x /usr/local/bin/pretix && \
rm /etc/nginx/sites-enabled/default && \ rm /etc/nginx/sites-enabled/default && \
cd /pretix/src && \ cd /pretix/src && \
rm -f pretix.cfg && \ rm -f pretix.cfg && \
mkdir -p data && \ mkdir -p data && \
chown -R pretixuser:pretixuser /pretix /data data && \ chown -R pretixuser:pretixuser /pretix /data data && \
sudo -u pretixuser make production sudo -u pretixuser make production
USER pretixuser USER pretixuser
VOLUME ["/etc/pretix", "/data"] VOLUME ["/etc/pretix", "/data"]
+1 -1
View File
@@ -1,4 +1,4 @@
from pretix.settings import * from pretix.settings import *
LOGGING['handlers']['mail_admins']['include_html'] = True LOGGING['handlers']['mail_admins']['include_html'] = True
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' STORAGES["staticfiles"]["BACKEND"] = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
+7
View File
@@ -152,6 +152,7 @@ Example::
password=abcd password=abcd
host=localhost host=localhost
port=3306 port=3306
advisory_lock_index=1
sslmode=require sslmode=require
sslrootcert=/etc/pretix/postgresql-ca.crt sslrootcert=/etc/pretix/postgresql-ca.crt
sslcert=/etc/pretix/postgresql-client-crt.crt sslcert=/etc/pretix/postgresql-client-crt.crt
@@ -167,11 +168,17 @@ Example::
``user``, ``password``, ``host``, ``port`` ``user``, ``password``, ``host``, ``port``
Connection details for the database connection. Empty by default. Connection details for the database connection. Empty by default.
``advisory_lock_index``
On PostgreSQL, pretix uses the "advisory lock" feature. However, advisory locks use a server-wide name space and
and are not scoped to a specific database. If you run multiple pretix applications with the same PostgreSQL server,
you should set separate values for this setting (integers up to 256).
``sslmode``, ``sslrootcert`` ``sslmode``, ``sslrootcert``
Connection TLS details for the PostgreSQL database connection. Possible values of ``sslmode`` are ``disable``, ``allow``, ``prefer``, ``require``, ``verify-ca``, and ``verify-full``. ``sslrootcert`` should be the accessible path of the ca certificate. Both values are empty by default. Connection TLS details for the PostgreSQL database connection. Possible values of ``sslmode`` are ``disable``, ``allow``, ``prefer``, ``require``, ``verify-ca``, and ``verify-full``. ``sslrootcert`` should be the accessible path of the ca certificate. Both values are empty by default.
``sslcert``, ``sslkey`` ``sslcert``, ``sslkey``
Connection mTLS details for the PostgreSQL database connection. It's also necessary to specify ``sslmode`` and ``sslrootcert`` parameters, please check the correct values from the TLS part. ``sslcert`` should be the accessible path of the client certificate. ``sslkey`` should be the accessible path of the client key. All values are empty by default. Connection mTLS details for the PostgreSQL database connection. It's also necessary to specify ``sslmode`` and ``sslrootcert`` parameters, please check the correct values from the TLS part. ``sslcert`` should be the accessible path of the client certificate. ``sslkey`` should be the accessible path of the client key. All values are empty by default.
.. _`config-replica`: .. _`config-replica`:
Database replica settings Database replica settings
+6 -5
View File
@@ -12,7 +12,7 @@ solution with many things readily set-up, look at :ref:`dockersmallscale`.
get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service
offers at `pretix.eu`_. offers at `pretix.eu`_.
We tested this guide on the Linux distribution **Debian 11.6** but it should work very similar on other We tested this guide on the Linux distribution **Debian 12** but it should work very similar on other
modern distributions, especially on all systemd-based ones. modern distributions, especially on all systemd-based ones.
Requirements Requirements
@@ -64,7 +64,7 @@ Package dependencies
To build and run pretix, you will need the following debian packages:: To build and run pretix, you will need the following debian packages::
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \ # apt-get install git build-essential python3-dev python3-venv python3 python3-pip \
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \ python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
gettext libpq-dev libjpeg-dev libopenjp2-7-dev gettext libpq-dev libjpeg-dev libopenjp2-7-dev
@@ -130,9 +130,10 @@ We now install pretix, its direct dependencies and gunicorn::
Note that you need Python 3.9 or newer. You can find out your Python version using ``python -V``. Note that you need Python 3.9 or newer. You can find out your Python version using ``python -V``.
We also need to create a data directory:: We also need to create a data directory and allow your webserver to traverse to the root directory::
(venv)$ mkdir -p /var/pretix/data/media (venv)$ mkdir -p /var/pretix/data/media
(venv)$ chmod +x /var/pretix
Finally, we compile static files and translation data and create the database structure:: Finally, we compile static files and translation data and create the database structure::
@@ -248,14 +249,14 @@ The following snippet is an example on how to configure a nginx proxy for pretix
} }
location /static/ { location /static/ {
alias /var/pretix/venv/lib/python3.10/site-packages/pretix/static.dist/; alias /var/pretix/venv/lib/python3.11/site-packages/pretix/static.dist/;
access_log off; access_log off;
expires 365d; expires 365d;
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
} }
.. note:: Remember to replace the ``python3.10`` in the ``/static/`` path in the config .. note:: Remember to replace the ``python3.11`` in the ``/static/`` path in the config
above with your python version. above with your python version.
We recommend reading about setting `strong encryption settings`_ for your web server. We recommend reading about setting `strong encryption settings`_ for your web server.
+24 -4
View File
@@ -16,12 +16,17 @@ already upgraded to pretix 5.0 or later, downgrade back to the last 4.x release
Update database schema Update database schema
---------------------- ----------------------
Before you start, make sure your database schema is up to date:: Before you start, make sure your database schema is up to date. With a local installation::
# sudo -u pretix -s # sudo -u pretix -s
$ source /var/pretix/venv/bin/activate $ source /var/pretix/venv/bin/activate
(venv)$ python -m pretix migrate (venv)$ python -m pretix migrate
With a docker installation::
docker exec -it pretix.service pretix migrate
Install PostgreSQL Install PostgreSQL
------------------ ------------------
@@ -70,10 +75,14 @@ Of course, instead of all this you can also run a PostgreSQL docker container an
Stop pretix Stop pretix
----------- -----------
To prevent any more changes to your data, stop pretix from running:: To prevent any more changes to your data, stop pretix from running. With a local installation::
# systemctl stop pretix-web pretix-worker # systemctl stop pretix-web pretix-worker
With docker::
# systemctl stop pretix
Change configuration Change configuration
-------------------- --------------------
@@ -90,12 +99,16 @@ Change the database configuration in your ``/etc/pretix/pretix.cfg`` file::
Create database schema Create database schema
----------------------- -----------------------
To create the schema in your new PostgreSQL database, use the following commands:: To create the schema in your new PostgreSQL database, use the following commands. With a local installation::
# sudo -u pretix -s # sudo -u pretix -s
$ source /var/pretix/venv/bin/activate $ source /var/pretix/venv/bin/activate
(venv)$ python -m pretix migrate (venv)$ python -m pretix migrate
With docker::
# docker run --rm -v /var/pretix-data:/data -v /etc/pretix:/etc/pretix -v /var/run/redis:/var/run/redis pretix/standalone:stable migrate
Migrate your data Migrate your data
----------------- -----------------
@@ -144,11 +157,18 @@ Afterwards, delete the file again::
Start pretix Start pretix
------------ ------------
Now, restart pretix. Maybe stop your MySQL server as a verification step that you are no longer using it:: Stop your MySQL server as a verification step that you are no longer using it::
# systemctl stop mariadb # systemctl stop mariadb
Then, restart pretix. With a local installation::
# systemctl start pretix-web pretix-worker # systemctl start pretix-web pretix-worker
With a docker installation::
# systemctl start pretix
And you're done! After you've verified everything has been copied correctly, you can delete the old MySQL database. And you're done! After you've verified everything has been copied correctly, you can delete the old MySQL database.
.. note:: Don't forget to update your backup process to back up your PostgreSQL database instead of your MySQL database now. .. note:: Don't forget to update your backup process to back up your PostgreSQL database instead of your MySQL database now.
+33 -2
View File
@@ -31,9 +31,9 @@ subevent_mode strings Determines h
``"same"`` (discount is only applied for groups within ``"same"`` (discount is only applied for groups within
the same date), or ``"distinct"`` (discount is only applied the same date), or ``"distinct"`` (discount is only applied
for groups with no two same dates). for groups with no two same dates).
condition_all_products boolean If ``true``, the discount applies to all items. condition_all_products boolean If ``true``, the discount condition applies to all items.
condition_limit_products list of integers If ``condition_all_products`` is not set, this is a list condition_limit_products list of integers If ``condition_all_products`` is not set, this is a list
of internal item IDs that the discount applies to. of internal item IDs that the discount condition applies to.
condition_apply_to_addons boolean If ``true``, the discount applies to add-on products as well, condition_apply_to_addons boolean If ``true``, the discount applies to add-on products as well,
otherwise it only applies to top-level items. The discount never otherwise it only applies to top-level items. The discount never
applies to bundled products. applies to bundled products.
@@ -48,6 +48,17 @@ benefit_discount_matching_percent decimal (string) The percenta
benefit_only_apply_to_cheapest_n_matches integer If set higher than 0, the discount will only be applied to benefit_only_apply_to_cheapest_n_matches integer If set higher than 0, the discount will only be applied to
the cheapest matches. Useful for a "3 for 2"-style discount. the cheapest matches. Useful for a "3 for 2"-style discount.
Cannot be combined with ``condition_min_value``. Cannot be combined with ``condition_min_value``.
benefit_same_products boolean If ``true``, the discount benefit applies to the same set of items
as the condition (see above).
benefit_limit_products list of integers If ``benefit_same_products`` is not set, this is a list
of internal item IDs that the discount benefit applies to.
benefit_apply_to_addons boolean (Only used if ``benefit_same_products`` is ``false``.)
If ``true``, the discount applies to add-on products as well,
otherwise it only applies to top-level items. The discount never
applies to bundled products.
benefit_ignore_voucher_discounted boolean (Only used if ``benefit_same_products`` is ``false``.)
If ``true``, the discount does not apply to products which have
been discounted by a voucher.
======================================== ========================== ======================================================= ======================================== ========================== =======================================================
@@ -94,6 +105,10 @@ Endpoints
"condition_ignore_voucher_discounted": false, "condition_ignore_voucher_discounted": false,
"condition_min_count": 3, "condition_min_count": 3,
"condition_min_value": "0.00", "condition_min_value": "0.00",
"benefit_same_products": true,
"benefit_limit_products": [],
"benefit_apply_to_addons": true,
"benefit_ignore_voucher_discounted": false,
"benefit_discount_matching_percent": "100.00", "benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1 "benefit_only_apply_to_cheapest_n_matches": 1
} }
@@ -146,6 +161,10 @@ Endpoints
"condition_ignore_voucher_discounted": false, "condition_ignore_voucher_discounted": false,
"condition_min_count": 3, "condition_min_count": 3,
"condition_min_value": "0.00", "condition_min_value": "0.00",
"benefit_same_products": true,
"benefit_limit_products": [],
"benefit_apply_to_addons": true,
"benefit_ignore_voucher_discounted": false,
"benefit_discount_matching_percent": "100.00", "benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1 "benefit_only_apply_to_cheapest_n_matches": 1
} }
@@ -184,6 +203,10 @@ Endpoints
"condition_ignore_voucher_discounted": false, "condition_ignore_voucher_discounted": false,
"condition_min_count": 3, "condition_min_count": 3,
"condition_min_value": "0.00", "condition_min_value": "0.00",
"benefit_same_products": true,
"benefit_limit_products": [],
"benefit_apply_to_addons": true,
"benefit_ignore_voucher_discounted": false,
"benefit_discount_matching_percent": "100.00", "benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1 "benefit_only_apply_to_cheapest_n_matches": 1
} }
@@ -211,6 +234,10 @@ Endpoints
"condition_ignore_voucher_discounted": false, "condition_ignore_voucher_discounted": false,
"condition_min_count": 3, "condition_min_count": 3,
"condition_min_value": "0.00", "condition_min_value": "0.00",
"benefit_same_products": true,
"benefit_limit_products": [],
"benefit_apply_to_addons": true,
"benefit_ignore_voucher_discounted": false,
"benefit_discount_matching_percent": "100.00", "benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1 "benefit_only_apply_to_cheapest_n_matches": 1
} }
@@ -267,6 +294,10 @@ Endpoints
"condition_ignore_voucher_discounted": false, "condition_ignore_voucher_discounted": false,
"condition_min_count": 3, "condition_min_count": 3,
"condition_min_value": "0.00", "condition_min_value": "0.00",
"benefit_same_products": true,
"benefit_limit_products": [],
"benefit_apply_to_addons": true,
"benefit_ignore_voucher_discounted": false,
"benefit_discount_matching_percent": "100.00", "benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1 "benefit_only_apply_to_cheapest_n_matches": 1
} }
+59 -2
View File
@@ -12,6 +12,7 @@ The invoice resource contains the following public fields:
Field Type Description Field Type Description
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
number string Invoice number (with prefix) number string Invoice number (with prefix)
event string The slug of the parent event
order string Order code of the order this invoice belongs to order string Order code of the order this invoice belongs to
is_cancellation boolean ``true``, if this invoice is the cancellation of a is_cancellation boolean ``true``, if this invoice is the cancellation of a
different invoice. different invoice.
@@ -121,9 +122,13 @@ internal_reference string Customer's refe
The attribute ``lines.subevent`` has been added. The attribute ``lines.subevent`` has been added.
.. versionchanged:: 2023.8
Endpoints The ``event`` attribute has been added. The organizer-level endpoint has been added.
---------
List of all invoices
--------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/
@@ -152,6 +157,7 @@ Endpoints
"results": [ "results": [
{ {
"number": "SAMPLECONF-00001", "number": "SAMPLECONF-00001",
"event": "sampleconf",
"order": "ABC12", "order": "ABC12",
"is_cancellation": false, "is_cancellation": false,
"invoice_from_name": "Big Events LLC", "invoice_from_name": "Big Events LLC",
@@ -221,6 +227,50 @@ Endpoints
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/invoices/
Returns a list of all invoices within all events of a given organizer (with sufficient access permissions).
Supported query parameters and output format of this endpoint are identical to the list endpoint within an event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"number": "SAMPLECONF-00001",
"event": "sampleconf",
"order": "ABC12",
...
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
Fetching individual invoices
----------------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/
Returns information on one invoice, identified by its invoice number. Returns information on one invoice, identified by its invoice number.
@@ -243,6 +293,7 @@ Endpoints
{ {
"number": "SAMPLECONF-00001", "number": "SAMPLECONF-00001",
"event": "sampleconf",
"order": "ABC12", "order": "ABC12",
"is_cancellation": false, "is_cancellation": false,
"invoice_from_name": "Big Events LLC", "invoice_from_name": "Big Events LLC",
@@ -337,6 +388,12 @@ Endpoints
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds. seconds.
Modifying invoices
------------------
Invoices cannot be edited directly, but the following actions can be triggered:
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/
Cancels the invoice and creates a new one. Cancels the invoice and creates a new one.
+49
View File
@@ -20,6 +20,7 @@ The order resource contains the following public fields:
Field Type Description Field Type Description
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
code string Order code code string Order code
event string The slug of the parent event
status string Order status, one of: status string Order status, one of:
* ``n`` pending * ``n`` pending
@@ -130,6 +131,10 @@ last_modified datetime Last modificati
The ``valid_if_pending`` attribute has been added. The ``valid_if_pending`` attribute has been added.
.. versionchanged:: 2023.8
The ``event`` attribute has been added. The organizer-level endpoint has been added.
.. _order-position-resource: .. _order-position-resource:
@@ -289,6 +294,7 @@ List of all orders
"results": [ "results": [
{ {
"code": "ABC12", "code": "ABC12",
"event": "sampleconf",
"status": "p", "status": "p",
"testmode": false, "testmode": false,
"secret": "k24fiuwvu8kxz3y1", "secret": "k24fiuwvu8kxz3y1",
@@ -441,6 +447,48 @@ List of all orders
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/orders/
Returns a list of all orders within all events of a given organizer (with sufficient access permissions).
Supported query parameters and output format of this endpoint are identical to the list endpoint within an event,
with the exception that the ``pdf_data`` parameter is not supported here.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/orders/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
X-Page-Generated: 2017-12-01T10:00:00Z
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"code": "ABC12",
"event": "sampleconf",
...
}
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
Fetching individual orders Fetching individual orders
-------------------------- --------------------------
@@ -466,6 +514,7 @@ Fetching individual orders
{ {
"code": "ABC12", "code": "ABC12",
"event": "sampleconf",
"status": "p", "status": "p",
"testmode": false, "testmode": false,
"secret": "k24fiuwvu8kxz3y1", "secret": "k24fiuwvu8kxz3y1",
+13 -4
View File
@@ -1,10 +1,10 @@
Automated email rules Scheduled email rules
===================== =====================
Resource description Resource description
-------------------- --------------------
Automated email rules that specify emails that the system will send automatically at a specific point in time, e.g. Scheduled email rules that specify emails that the system will send automatically at a specific point in time, e.g.
the day of the event. the day of the event.
.. rst-class:: rest-resource-table .. rst-class:: rest-resource-table
@@ -23,10 +23,14 @@ limit_products list of integers List of product
restrict_to_status list List of order states to restrict recipients to. Valid restrict_to_status list List of order states to restrict recipients to. Valid
entries are ``p`` for paid, ``e`` for expired, ``c`` for canceled, entries are ``p`` for paid, ``e`` for expired, ``c`` for canceled,
``n__pending_approval`` for pending approval, ``n__pending_approval`` for pending approval,
``n__not_pending_approval_and_not_valid_if_pending`` for payment pending, ``n__not_pending_approval_and_not_valid_if_pending`` for payment
``n__valid_if_pending`` for payment pending but already confirmed, pending, ``n__valid_if_pending`` for payment pending but already confirmed,
and ``n__pending_overdue`` for pending with payment overdue. and ``n__pending_overdue`` for pending with payment overdue.
The default is ``["p", "n__valid_if_pending"]``. The default is ``["p", "n__valid_if_pending"]``.
checked_in_status string Check-in status to restrict recipients to. Valid strings are:
``null`` for no filtering (default), ``checked_in`` for
limiting to attendees that are or have been checked in, and
``no_checkin`` for limiting to attendees who have not checked in.
date_is_absolute boolean If ``true``, the email is set at a specific point in time. date_is_absolute boolean If ``true``, the email is set at a specific point in time.
send_date datetime If ``date_is_absolute`` is set: Date and time to send the email. send_date datetime If ``date_is_absolute`` is set: Date and time to send the email.
send_offset_days integer If ``date_is_absolute`` is not set, this is the number of days send_offset_days integer If ``date_is_absolute`` is not set, this is the number of days
@@ -89,6 +93,7 @@ Endpoints
"n__not_pending_approval_and_not_valid_if_pending", "n__not_pending_approval_and_not_valid_if_pending",
"n__valid_if_pending" "n__valid_if_pending"
], ],
"checked_in_status": null,
"send_date": null, "send_date": null,
"send_offset_days": 1, "send_offset_days": 1,
"send_offset_time": "18:00", "send_offset_time": "18:00",
@@ -139,6 +144,7 @@ Endpoints
"n__not_pending_approval_and_not_valid_if_pending", "n__not_pending_approval_and_not_valid_if_pending",
"n__valid_if_pending" "n__valid_if_pending"
], ],
"checked_in_status": null,
"send_date": null, "send_date": null,
"send_offset_days": 1, "send_offset_days": 1,
"send_offset_time": "18:00", "send_offset_time": "18:00",
@@ -180,6 +186,7 @@ Endpoints
"n__not_pending_approval_and_not_valid_if_pending", "n__not_pending_approval_and_not_valid_if_pending",
"n__valid_if_pending" "n__valid_if_pending"
], ],
"checked_in_status": "checked_in",
"send_date": null, "send_date": null,
"send_offset_days": 1, "send_offset_days": 1,
"send_offset_time": "18:00", "send_offset_time": "18:00",
@@ -209,6 +216,7 @@ Endpoints
"n__not_pending_approval_and_not_valid_if_pending", "n__not_pending_approval_and_not_valid_if_pending",
"n__valid_if_pending" "n__valid_if_pending"
], ],
"checked_in_status": "checked_in",
"send_date": null, "send_date": null,
"send_offset_days": 1, "send_offset_days": 1,
"send_offset_time": "18:00", "send_offset_time": "18:00",
@@ -266,6 +274,7 @@ Endpoints
"n__not_pending_approval_and_not_valid_if_pending", "n__not_pending_approval_and_not_valid_if_pending",
"n__valid_if_pending" "n__valid_if_pending"
], ],
"checked_in_status": "checked_in",
"send_date": null, "send_date": null,
"send_offset_days": 1, "send_offset_days": 1,
"send_offset_time": "18:00", "send_offset_time": "18:00",
+5
View File
@@ -68,6 +68,10 @@ last_modified datetime Last modificati
The ``date_from_before``, ``date_from_after``, ``date_to_before``, and ``date_to_after`` query parameters have been The ``date_from_before``, ``date_from_after``, ``date_to_before``, and ``date_to_after`` query parameters have been
added. added.
.. versionchanged:: 2023.8.0
For the organizer-wide endpoint, the ``search`` query parameter has been modified to filter sub-events by their parent events slug too.
Endpoints Endpoints
--------- ---------
@@ -472,6 +476,7 @@ Endpoints
:query date_to_after: If set to a date and time, only events that have an end date and end at or after the given time are returned. :query date_to_after: If set to a date and time, only events that have an end date and end at or after the given time are returned.
:query date_to_before: If set to a date and time, only events that have an end date and end at or before the given time are returned. :query date_to_before: If set to a date and time, only events that have an end date and end at or before the given time are returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. :query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
:query search: Only return events matching a given search query.
:query sales_channel: If set to a sales channel identifier, the response will only contain subevents from events available on this sales channel. :query sales_channel: If set to a sales channel identifier, the response will only contain subevents from events available on this sales channel.
:param organizer: The ``slug`` field of a valid organizer :param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
+3
View File
@@ -67,6 +67,9 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.live.deactivated`` * ``pretix.event.live.deactivated``
* ``pretix.event.testmode.activated`` * ``pretix.event.testmode.activated``
* ``pretix.event.testmode.deactivated`` * ``pretix.event.testmode.deactivated``
* ``pretix.customer.created``
* ``pretix.customer.changed``
* ``pretix.customer.anonymized``
Installed plugins might register more valid values. Installed plugins might register more valid values.
+1 -1
View File
@@ -37,7 +37,7 @@ you to execute a piece of code with a different locale:
This is very useful e.g. when sending an email to a user that has a different language than the user performing the This is very useful e.g. when sending an email to a user that has a different language than the user performing the
action that causes the mail to be sent. action that causes the mail to be sent.
.. _translation features: https://docs.djangoproject.com/en/1.9/topics/i18n/translation/ .. _translation features: https://docs.djangoproject.com/en/4.2/topics/i18n/translation/
.. _GNU gettext: https://www.gnu.org/software/gettext/ .. _GNU gettext: https://www.gnu.org/software/gettext/
.. _strings: https://django-i18nfield.readthedocs.io/en/latest/strings.html .. _strings: https://django-i18nfield.readthedocs.io/en/latest/strings.html
.. _database fields: https://django-i18nfield.readthedocs.io/en/latest/quickstart.html .. _database fields: https://django-i18nfield.readthedocs.io/en/latest/quickstart.html
+1
View File
@@ -18,3 +18,4 @@ Contents:
email email
permissions permissions
logging logging
locking
@@ -0,0 +1,69 @@
.. highlight:: python
Resource locking
================
.. versionchanged:: 2023.8
Our locking mechanism changed heavily in version 2023.8. Read `this PR`_ for background information.
One of pretix's core objectives as a ticketing system could be described as the management of scarce resources.
Specifically, the following types of scarce-ness exist in pretix:
- Quotas can limit the number of tickets available
- Seats can only be booked once
- Vouchers can only be used a limited number of times
- Some memberships can only be used a limited number of times
For all of these, it is critical that we prevent race conditions.
While for some events it wouldn't be a big deal to sell a ticket more or less, for some it would be problematic and selling the same seat twice would always be catastrophic.
We therefore implement a standardized locking approach across the system to limit concurrency in cases where it could
be problematic.
To acquire a lock on a set of quotas to create a new order that uses that quota, you should follow the following pattern::
with transaction.atomic(durable=True):
quotas = Quota.objects.filter(...)
lock_objects(quotas, shared_lock_objects=[event])
check_quota(quotas)
create_ticket()
The lock will automatically be released at the end of your database transaction.
Generally, follow the following guidelines during your development:
- **Always** acquire a lock on every **quota**, **voucher** or **seat** that you "use" during your transaction. "Use"
here means any action after which the quota, voucher or seat will be **less available**, such as creating a cart
position, creating an order, creating a blocking voucher, etc.
- There is **no need** to acquire a lock if you **free up** capacity, e.g. by canceling an order, deleting a voucher, etc.
- **Always** acquire a shared lock on the ``event`` you are working in whenever you acquire a lock on a quota, voucher,
or seat.
- Only call ``lock_objects`` **once** per transaction. If you violate this rule, `deadlocks`_ become possible.
- For best performance, call ``lock_objects`` as **late** in your transaction as possible, but always before you check
if the desired resource is still available in sufficient quantity.
Behind the scenes, the locking is implemented through `PostgreSQL advisory locks`_. You should also be aware of the following
properties of our system:
- In some situations, an exclusive lock on the ``event`` is used, such as when the system can't determine for sure which
seats will become unavailable after the transaction.
- An exclusive lock on the event is also used if you pass more than 20 objects to ``lock_objects``. This is a performance
trade-off because it would take long to acquire all of the individual locks.
- If ``lock_objects`` is unable to acquire a lock within 3 seconds, a ``LockTimeoutException`` will be thrown.
.. note::
We currently do not use ``lock_objects`` for memberships. Instead, we use ``select_for_update()`` on the membership
model. This might change in the future, but you should usually not be concerned about it since
``validate_memberships_in_order(lock=True)`` will handle it for you.
.. _this PR: https://github.com/pretix/pretix/pull/2408
.. _deadlocks: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS
.. _PostgreSQL advisory locks: https://www.postgresql.org/docs/11/explicit-locking.html#ADVISORY-LOCKS
+23 -10
View File
@@ -15,25 +15,33 @@ and the admin panel is available at ``https://pretix.eu/control/event/bigorg/awe
If the organizer now configures a custom domain like ``tickets.bigorg.com``, his event will If the organizer now configures a custom domain like ``tickets.bigorg.com``, his event will
from now on be available on ``https://tickets.bigorg.com/awesomecon/``. The former URL at from now on be available on ``https://tickets.bigorg.com/awesomecon/``. The former URL at
``pretix.eu`` will redirect there. However, the admin panel will still only be available ``pretix.eu`` will redirect there. It's also possible to do this for just an event, in which
on ``pretix.eu`` for convenience and security reasons. case the event will be available on ``https://tickets.awesomecon.org/``.
However, the admin panel will still only be available on ``pretix.eu`` for convenience and security reasons.
URL routing URL routing
----------- -----------
The hard part about implementing this URL routing in Django is that The hard part about implementing this URL routing in Django is that
``https://pretix.eu/bigorg/awesomecon/`` contains two parameters of nearly arbitrary content ``https://pretix.eu/bigorg/awesomecon/`` contains two parameters of nearly arbitrary content
and ``https://tickets.bigorg.com/awesomecon/`` contains only one. The only robust way to do and ``https://tickets.bigorg.com/awesomecon/`` contains only one and ``https://tickets.awesomecon.org/`` does not contain any.
this is by having *separate* URL configuration for those two cases. In pretix, we call the The only robust way to do this is by having *separate* URL configuration for those three cases.
former our ``maindomain`` config and the latter our ``subdomain`` config. For pretix's core
modules we do some magic to avoid duplicate configuration, but for a fairly simple plugin with
only a handful of routes, we recommend just configuring the two URL sets separately.
In pretix, we therefore do not have a global URL configuration, but three, living in the following modules:
- ``pretix.multidomain.maindomain_urlconf``
- ``pretix.multidomain.organizer_domain_urlconf``
- ``pretix.multidomain.event_domain_urlconf``
We provide some helper utilities to work with these to avoid duplicate configuration of the individual URLs.
The file ``urls.py`` inside your plugin package will be loaded and scanned for URL configuration The file ``urls.py`` inside your plugin package will be loaded and scanned for URL configuration
automatically and should be provided by any plugin that provides any view. automatically and should be provided by any plugin that provides any view.
However, unlike plain Django, we look not only for a ``urlpatterns`` attribute on the module but support other
attributes like ``event_patterns`` and ``organizer_patterns`` as well.
A very basic example that provides one view in the admin panel and one view in the frontend For example, for a simple plugin that adds one URL to the backend and one event-level URL to the frontend, you can
could look like this:: create the following configuration in your ``urls.py``::
from django.urls import re_path from django.urls import re_path
@@ -52,7 +60,7 @@ could look like this::
As you can see, the view in the frontend is not included in the standard Django ``urlpatterns`` As you can see, the view in the frontend is not included in the standard Django ``urlpatterns``
setting but in a separate list with the name ``event_patterns``. This will automatically prepend setting but in a separate list with the name ``event_patterns``. This will automatically prepend
the appropriate parameters to the regex (e.g. the event or the event and the organizer, depending the appropriate parameters to the regex (e.g. the event or the event and the organizer, depending
on the called domain). on the called domain). For organizer-level views, ``organizer_patterns`` works the same way.
If you only provide URLs in the admin area, you do not need to provide a ``event_patterns`` attribute. If you only provide URLs in the admin area, you do not need to provide a ``event_patterns`` attribute.
@@ -71,11 +79,16 @@ is a python method that emulates a behavior similar to ``reverse``:
.. autofunction:: pretix.multidomain.urlreverse.eventreverse .. autofunction:: pretix.multidomain.urlreverse.eventreverse
If you need to communicate the URL externally, you can use a different method to ensure that it is always an absolute URL:
.. autofunction:: pretix.multidomain.urlreverse.build_absolute_uri
In addition, there is a template tag that works similar to ``url`` but takes an event or organizer object In addition, there is a template tag that works similar to ``url`` but takes an event or organizer object
as its first argument and can be used like this:: as its first argument and can be used like this::
{% load eventurl %} {% load eventurl %}
<a href="{% eventurl request.event "presale:event.checkout" step="payment" %}">Pay</a> <a href="{% eventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
<a href="{% abseventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
Implementation details Implementation details
+14
View File
@@ -96,6 +96,20 @@ http://localhost:8000/control/ for the admin view.
port (for example because you develop on `pretixdroid`_), you can check port (for example because you develop on `pretixdroid`_), you can check
`Django's documentation`_ for more options. `Django's documentation`_ for more options.
When running the local development webserver, ensure Celery is not configured
in ``pretix.cfg``. i.e., you should remove anything such as::
[celery]
backend=redis://redis:6379/2
broker=redis://redis:6379/2
If you choose to use Celery for development, you must also start a Celery worker
process::
celery -A pretix.celery_app worker -l info
However, beware that code changes will not auto-reload within Celery.
.. _`checksandtests`: .. _`checksandtests`:
Code checks and unit tests Code checks and unit tests
+65 -108
View File
@@ -293,6 +293,16 @@ with that information::
</pretix-widget> </pretix-widget>
This works for the pretix Button as well, if you also specify a product. This works for the pretix Button as well, if you also specify a product.
As data-attributes are reactive, you can change them with JavaScript as well. Please note, that once the user
started the checkout process, we do not update the data-attributes in the existing checkout process to not
interrupt the checkout UX.
When updating data-attributes through JavaScript, make sure you do not have a stale reference to the HTMLNode of the
widget. When the widget is created, the original HTMLNode can happen to be replaced. So make sure to always have a
fresh reference like so
``document.querySelectorAll("pretix-widget, pretix-button, .pretix-widget-wrapper")``
Currently, the following attributes are understood by pretix itself: Currently, the following attributes are understood by pretix itself:
* ``data-email`` will pre-fill the order email field as well as the attendee email field (if enabled). * ``data-email`` will pre-fill the order email field as well as the attendee email field (if enabled).
@@ -329,125 +339,72 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders * If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
made through this widget will be counted towards this campaign. made through this widget will be counted towards this campaign.
* If you use the tracking plugin, you can enable cross-domain tracking. To do so, you need to initialize the * If you use the tracking plugin, you can enable cross-domain tracking. Please note: when you run your pretix-shop on a
pretix-widget manually. Use the html code to embed the widget and add one the following code snippets. Make sure to subdomain of your main tracking domain, then you do not need cross-domain tracking as tracking automatically works
replace all occurrences of <MEASUREMENT_ID> with your Google Analytics MEASUREMENT_ID (UA-XXXXXXX-X or G-XXXXXXXX) across subdomains. See :ref:`custom_domain` for how to set this up.
Please also make sure to add the embedding website to your `Referral exclusions Please make sure to add the embedding website to your `Referral exclusions
<https://support.google.com/analytics/answer/2795830>`_ in your Google Analytics settings. <https://support.google.com/analytics/answer/2795830>`_ in your Google Analytics settings.
If you use Google Analytics 4 (GA4 G-XXXXXXXX):: Add Google Analytics as you normally would with all your `window.dataLayer` and `gtag` configurations. Also add the
widget code normally. Then you have two options:
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script> * Block loading of the widget at most 2 seconds or until Googles client- and session-ID are loaded. This method
<script type="text/javascript"> uses `window.pretixWidgetCallback`. Note that if it takes longer than 2 seconds to load, client- and session-ID
window.dataLayer = window.dataLayer || []; are never passed to the widget. Make sure to replace all occurrences of <MEASUREMENT_ID> with your Google
function gtag(){dataLayer.push(arguments);} Analytics MEASUREMENT_ID (G-XXXXXXXX)::
gtag('js', new Date());
gtag('config', '<MEASUREMENT_ID>');
window.pretixWidgetCallback = function () { <script type="text/javascript">
window.PretixWidget.build_widgets = false; window.pretixWidgetCallback = function () {
window.addEventListener('load', function() { // Wait for GA to be loaded window.PretixWidget.build_widgets = false;
if (!window['google_tag_manager']) { window.addEventListener('load', function() { // Wait for GA to be loaded
window.PretixWidget.buildWidgets(); if (!window['google_tag_manager']) {
return; window.PretixWidget.buildWidgets();
} return;
}
var clientId; var clientId;
var sessionId; var sessionId;
var loadingTimeout; var loadingTimeout;
function build() { function build() {
// use loadingTimeout to make sure build() is only called once // use loadingTimeout to make sure build() is only called once
if (!loadingTimeout) return; if (!loadingTimeout) return;
window.clearTimeout(loadingTimeout); window.clearTimeout(loadingTimeout);
loadingTimeout = null; loadingTimeout = null;
if (clientId) window.PretixWidget.widget_data["tracking-ga-id"] = clientId; if (clientId) window.PretixWidget.widget_data["tracking-ga-id"] = clientId;
if (sessionId) window.PretixWidget.widget_data["tracking-ga-sessid"] = sessionId; if (sessionId) window.PretixWidget.widget_data["tracking-ga-sessid"] = sessionId;
window.PretixWidget.buildWidgets(); window.PretixWidget.buildWidgets();
}; };
// make sure to build pretix-widgets if gtag fails to load either client_id or session_id // make sure to build pretix-widgets if gtag fails to load either client_id or session_id
loadingTimeout = window.setTimeout(build, 2000); loadingTimeout = window.setTimeout(build, 2000);
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
clientId = id;
if (sessionId !== undefined) build();
});
gtag('get', '<MEASUREMENT_ID>', 'session_id', function(id) {
sessionId = id;
if (clientId !== undefined) build();
});
});
};
</script>
* Or asynchronously set data-attributes the widgets are shown immediately, but once the user has started checkout,
data-attributes are not updated. Make sure to replace all occurrences of <MEASUREMENT_ID> with your Google
Analytics MEASUREMENT_ID (G-XXXXXXXX)::
<script type="text/javascript">
window.addEventListener('load', function() {
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) { gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
clientId = id; const widgets = document.querySelectorAll("pretix-widget, pretix-button, .pretix-widget-wrapper");
if (sessionId !== undefined) build(); widgets.forEach(widget => widget.setAttribute("data-tracking-ga-id", id))
}); });
gtag('get', '<MEASUREMENT_ID>', 'session_id', function(id) { gtag('get', '<MEASUREMENT_ID>', 'session_id', function(id) {
sessionId = id; const widgets = document.querySelectorAll("pretix-widget, pretix-button, .pretix-widget-wrapper");
if (clientId !== undefined) build(); widgets.forEach(widget => widget.setAttribute("data-tracking-ga-sessid", id))
}); });
}); });
}; </script>
</script>
If you use Universal Analytics with ``gtag.js`` (UA-XXXXXXX-X)::
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script>
<script type="text/javascript">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '<MEASUREMENT_ID>');
window.pretixWidgetCallback = function () {
window.PretixWidget.build_widgets = false;
window.addEventListener('load', function() { // Wait for GA to be loaded
if (!window['google_tag_manager']) {
window.PretixWidget.buildWidgets();
return;
}
// make sure to build pretix-widgets if gtag fails to load client_id
var loadingTimeout = window.setTimeout(function() {
loadingTimeout = null;
window.PretixWidget.buildWidgets();
}, 1000);
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
if (loadingTimeout) {
window.clearTimeout(loadingTimeout);
window.PretixWidget.widget_data["tracking-ga-id"] = id;
window.PretixWidget.buildWidgets();
}
});
});
};
</script>
If you use ``analytics.js`` (Universal Analytics)::
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', '<MEASUREMENT_ID>', 'auto');
ga('send', 'pageview');
window.pretixWidgetCallback = function () {
window.PretixWidget.build_widgets = false;
window.addEventListener('load', function() { // Wait for GA to be loaded
if (!window['ga'] || !ga.create) {
// Tracking is probably blocked
window.PretixWidget.buildWidgets()
return;
}
var loadingTimeout = window.setTimeout(function() {
loadingTimeout = null;
window.PretixWidget.buildWidgets();
}, 1000);
ga(function(tracker) {
if (loadingTimeout) {
window.clearTimeout(loadingTimeout);
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
window.PretixWidget.buildWidgets();
}
});
});
};
</script>
.. _Let's Encrypt: https://letsencrypt.org/ .. _Let's Encrypt: https://letsencrypt.org/
+5 -2
View File
@@ -36,7 +36,7 @@ dependencies = [
"css-inline==0.8.*", "css-inline==0.8.*",
"defusedcsv>=1.1.0", "defusedcsv>=1.1.0",
"dj-static", "dj-static",
"Django==4.1.*", "Django==4.2.*",
"django-bootstrap3==23.1.*", "django-bootstrap3==23.1.*",
"django-compressor==4.3.*", "django-compressor==4.3.*",
"django-countries==7.5.*", "django-countries==7.5.*",
@@ -90,7 +90,7 @@ dependencies = [
"pytz-deprecation-shim==0.1.*", "pytz-deprecation-shim==0.1.*",
"pyuca", "pyuca",
"qrcode==7.4.*", "qrcode==7.4.*",
"redis==4.5.*,>=4.5.4", "redis==4.6.*",
"reportlab==4.0.*", "reportlab==4.0.*",
"requests==2.31.*", "requests==2.31.*",
"sentry-sdk==1.15.*", "sentry-sdk==1.15.*",
@@ -110,8 +110,10 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
memcached = ["pylibmc"] memcached = ["pylibmc"]
dev = [ dev = [
"aiohttp==3.8.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.18.*",
"flake8==6.0.*", "flake8==6.0.*",
"freezegun", "freezegun",
"isort==5.12.*", "isort==5.12.*",
@@ -119,6 +121,7 @@ dev = [
"potypo", "potypo",
"pycodestyle==2.10.*", "pycodestyle==2.10.*",
"pyflakes==3.0.*", "pyflakes==3.0.*",
"pytest-asyncio",
"pytest-cache", "pytest-cache",
"pytest-cov", "pytest-cov",
"pytest-django==4.*", "pytest-django==4.*",
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2023.7.0" __version__ = "2023.8.0.dev0"
+26 -1
View File
@@ -89,6 +89,7 @@ ALL_LANGUAGES = [
('fi', _('Finnish')), ('fi', _('Finnish')),
('gl', _('Galician')), ('gl', _('Galician')),
('el', _('Greek')), ('el', _('Greek')),
('id', _('Indonesian')),
('it', _('Italian')), ('it', _('Italian')),
('lv', _('Latvian')), ('lv', _('Latvian')),
('pl', _('Polish')), ('pl', _('Polish')),
@@ -196,7 +197,14 @@ STATICFILES_DIRS = [
STATICI18N_ROOT = os.path.join(BASE_DIR, "pretix/static") STATICI18N_ROOT = os.path.join(BASE_DIR, "pretix/static")
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
},
}
# if os.path.exists(os.path.join(DATA_DIR, 'static')): # if os.path.exists(os.path.join(DATA_DIR, 'static')):
# STATICFILES_DIRS.insert(0, os.path.join(DATA_DIR, 'static')) # STATICFILES_DIRS.insert(0, os.path.join(DATA_DIR, 'static'))
@@ -252,3 +260,20 @@ PRETIX_PRIMARY_COLOR = '#8E44B3'
# stressful for some cache setups so it is enabled by default and currently can't be enabled through pretix.cfg # stressful for some cache setups so it is enabled by default and currently can't be enabled through pretix.cfg
CACHE_LARGE_VALUES_ALLOWED = False CACHE_LARGE_VALUES_ALLOWED = False
CACHE_LARGE_VALUES_ALIAS = 'default' CACHE_LARGE_VALUES_ALIAS = 'default'
# Allowed file extensions for various places plus matching Pillow formats.
# Never allow EPS, it is full of dangerous bugs.
FILE_UPLOAD_EXTENSIONS_IMAGE = (".png", ".jpg", ".gif", ".jpeg")
PILLOW_FORMATS_IMAGE = ('PNG', 'GIF', 'JPEG')
FILE_UPLOAD_EXTENSIONS_FAVICON = (".ico", ".png", "jpg", ".gif", ".jpeg")
FILE_UPLOAD_EXTENSIONS_QUESTION_IMAGE = (".png", "jpg", ".gif", ".jpeg", ".bmp", ".tif", ".tiff", ".jfif")
PILLOW_FORMATS_QUESTIONS_IMAGE = ('PNG', 'GIF', 'JPEG', 'BMP', 'TIFF')
FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT = (
".png", ".jpg", ".gif", ".jpeg", ".pdf", ".txt", ".docx", ".gif", ".svg",
".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages",
".bmp", ".tif", ".tiff"
)
FILE_UPLOAD_EXTENSIONS_OTHER = FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT
+3 -1
View File
@@ -32,11 +32,13 @@ class DiscountSerializer(I18nAwareModelSerializer):
'available_until', 'subevent_mode', 'condition_all_products', 'condition_limit_products', 'available_until', 'subevent_mode', 'condition_all_products', 'condition_limit_products',
'condition_apply_to_addons', 'condition_min_count', 'condition_min_value', 'condition_apply_to_addons', 'condition_min_count', 'condition_min_value',
'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches', 'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches',
'condition_ignore_voucher_discounted') 'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons',
'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['condition_limit_products'].queryset = self.context['event'].items.all() self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
self.fields['benefit_limit_products'].queryset = self.context['event'].items.all()
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
+431 -316
View File
@@ -22,11 +22,13 @@
import logging import logging
import os import os
from collections import Counter, defaultdict from collections import Counter, defaultdict
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
import pycountry import pycountry
from django.conf import settings from django.conf import settings
from django.core.files import File from django.core.files import File
from django.db import models
from django.db.models import F, Q from django.db.models import F, Q
from django.utils.encoding import force_str from django.utils.encoding import force_str
from django.utils.timezone import now from django.utils.timezone import now
@@ -58,10 +60,11 @@ from pretix.base.models.orders import (
) )
from pretix.base.pdf import get_images, get_variables from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages from pretix.base.services.cart import error_messages
from pretix.base.services.locking import NoLockManager from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
from pretix.base.services.pricing import ( from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, is_included_for_free, apply_discounts, get_line_price, get_listed_price, is_included_for_free,
) )
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.signals import register_ticket_outputs from pretix.base.signals import register_ticket_outputs
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
@@ -283,11 +286,12 @@ class FailedCheckinSerializer(I18nAwareModelSerializer):
raw_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True) raw_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True) raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
raw_subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True) raw_subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True)
nonce = serializers.CharField(required=False, allow_null=True)
class Meta: class Meta:
model = Checkin model = Checkin
fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation', fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation',
'raw_subevent', 'datetime', 'type', 'position') 'raw_subevent', 'nonce', 'datetime', 'type', 'position')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -372,11 +376,15 @@ class PdfDataSerializer(serializers.Field):
self.context['vars_images'] = get_images(self.context['event']) self.context['vars_images'] = get_images(self.context['event'])
for k, f in self.context['vars'].items(): for k, f in self.context['vars'].items():
try: if 'evaluate_bulk' in f:
res[k] = f['evaluate'](instance, instance.order, ev) # Will be evaluated later by our list serializers
except: res[k] = (f['evaluate_bulk'], instance)
logger.exception('Evaluating PDF variable failed') else:
res[k] = '(error)' try:
res[k] = f['evaluate'](instance, instance.order, ev)
except:
logger.exception('Evaluating PDF variable failed')
res[k] = '(error)'
if not hasattr(ev, '_cached_meta_data'): if not hasattr(ev, '_cached_meta_data'):
ev._cached_meta_data = ev.meta_data ev._cached_meta_data = ev.meta_data
@@ -429,6 +437,38 @@ class PdfDataSerializer(serializers.Field):
return res return res
class OrderPositionListSerializer(serializers.ListSerializer):
def to_representation(self, data):
# We have a custom implementation of this method because PdfDataSerializer() might keep some elements unevaluated
# with a (callable, input) tuple. We'll loop over these entries and evaluate them bulk-wise to save on SQL queries.
if isinstance(self.parent, OrderSerializer) and isinstance(self.parent.parent, OrderListSerializer):
# Do not execute our custom code because it will be executed by OrderListSerializer later for the
# full result set.
return super().to_representation(data)
iterable = data.all() if isinstance(data, models.Manager) else data
data = []
evaluate_queue = defaultdict(list)
for item in iterable:
entry = self.child.to_representation(item)
if "pdf_data" in entry:
for k, v in entry["pdf_data"].items():
if isinstance(v, tuple) and callable(v[0]):
evaluate_queue[v[0]].append((v[1], entry, k))
data.append(entry)
for func, entries in evaluate_queue.items():
results = func([item for (item, entry, k) in entries])
for (item, entry, k), result in zip(entries, results):
entry["pdf_data"][k] = result
return data
class OrderPositionSerializer(I18nAwareModelSerializer): class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = CheckinSerializer(many=True, read_only=True) checkins = CheckinSerializer(many=True, read_only=True)
answers = AnswerSerializer(many=True) answers = AnswerSerializer(many=True)
@@ -440,6 +480,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
attendee_name = serializers.CharField(required=False) attendee_name = serializers.CharField(required=False)
class Meta: class Meta:
list_serializer_class = OrderPositionListSerializer
model = OrderPosition model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount', 'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
@@ -468,6 +509,20 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
def validate(self, data): def validate(self, data):
raise TypeError("this serializer is readonly") raise TypeError("this serializer is readonly")
def to_representation(self, data):
if isinstance(self.parent, (OrderListSerializer, OrderPositionListSerializer)):
# Do not execute our custom code because it will be executed by OrderListSerializer later for the
# full result set.
return super().to_representation(data)
entry = super().to_representation(data)
if "pdf_data" in entry:
for k, v in entry["pdf_data"].items():
if isinstance(v, tuple) and callable(v[0]):
entry["pdf_data"][k] = v[0]([v[1]])[0]
return entry
class RequireAttentionField(serializers.Field): class RequireAttentionField(serializers.Field):
def to_representation(self, instance: OrderPosition): def to_representation(self, instance: OrderPosition):
@@ -562,7 +617,7 @@ class PaymentURLField(serializers.URLField):
def to_representation(self, instance: OrderPayment): def to_representation(self, instance: OrderPayment):
if instance.state != OrderPayment.PAYMENT_STATE_CREATED: if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
return None return None
return build_absolute_uri(self.context['event'], 'presale:event.order.pay', kwargs={ return build_absolute_uri(instance.order.event, 'presale:event.order.pay', kwargs={
'order': instance.order.code, 'order': instance.order.code,
'secret': instance.order.secret, 'secret': instance.order.secret,
'payment': instance.pk, 'payment': instance.pk,
@@ -607,13 +662,42 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
class OrderURLField(serializers.URLField): class OrderURLField(serializers.URLField):
def to_representation(self, instance: Order): def to_representation(self, instance: Order):
return build_absolute_uri(self.context['event'], 'presale:event.order', kwargs={ return build_absolute_uri(instance.event, 'presale:event.order', kwargs={
'order': instance.code, 'order': instance.code,
'secret': instance.secret, 'secret': instance.secret,
}) })
class OrderListSerializer(serializers.ListSerializer):
def to_representation(self, data):
# We have a custom implementation of this method because PdfDataSerializer() might keep some elements
# unevaluated with a (callable, input) tuple. We'll loop over these entries and evaluate them bulk-wise to
# save on SQL queries.
iterable = data.all() if isinstance(data, models.Manager) else data
data = []
evaluate_queue = defaultdict(list)
for item in iterable:
entry = self.child.to_representation(item)
for p in entry.get("positions", []):
if "pdf_data" in p:
for k, v in p["pdf_data"].items():
if isinstance(v, tuple) and callable(v[0]):
evaluate_queue[v[0]].append((v[1], p, k))
data.append(entry)
for func, entries in evaluate_queue.items():
results = func([item for (item, entry, k) in entries])
for (item, entry, k), result in zip(entries, results):
entry["pdf_data"][k] = result
return data
class OrderSerializer(I18nAwareModelSerializer): class OrderSerializer(I18nAwareModelSerializer):
event = SlugRelatedField(slug_field='slug', read_only=True)
invoice_address = InvoiceAddressSerializer(allow_null=True) invoice_address = InvoiceAddressSerializer(allow_null=True)
positions = OrderPositionSerializer(many=True, read_only=True) positions = OrderPositionSerializer(many=True, read_only=True)
fees = OrderFeeSerializer(many=True, read_only=True) fees = OrderFeeSerializer(many=True, read_only=True)
@@ -627,8 +711,9 @@ class OrderSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Order model = Order
list_serializer_class = OrderListSerializer
fields = ( fields = (
'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', 'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads', 'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url', 'customer', 'valid_if_pending' 'url', 'customer', 'valid_if_pending'
@@ -1061,338 +1146,367 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else: else:
ia = None ia = None
lock_required = False quotas_by_item = {}
quota_diff_for_locking = Counter()
voucher_diff_for_locking = Counter()
seat_diff_for_locking = Counter()
quota_usage = Counter()
voucher_usage = Counter()
seat_usage = Counter()
v_budget = {}
now_dt = now()
delete_cps = []
consume_carts = validated_data.pop('consume_carts', [])
for pos_data in positions_data: for pos_data in positions_data:
pos_data['_quotas'] = list( if (pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent')) not in quotas_by_item:
pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent')) quotas_by_item[pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent')] = list(
if pos_data.get('variation') pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')) if pos_data.get('variation')
) else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent'))
if pos_data.get('voucher') or pos_data.get('seat') or any(q.size is not None for q in pos_data['_quotas']): )
lock_required = True for q in quotas_by_item[pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent')]:
quota_diff_for_locking[q] += 1
if pos_data.get('voucher'):
voucher_diff_for_locking[pos_data['voucher']] += 1
if pos_data.get('seat'):
try:
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
except Seat.DoesNotExist:
pos_data['seat'] = Seat.DoesNotExist
else:
pos_data['seat'] = seat
seat_diff_for_locking[pos_data['seat']] += 1
lockfn = self.context['event'].lock if consume_carts:
if simulate or not lock_required: offset = now() + timedelta(seconds=LOCK_TRUST_WINDOW)
lockfn = NoLockManager for cp in CartPosition.objects.filter(
with lockfn() as now_dt: event=self.context['event'], cart_id__in=consume_carts, expires__gt=now_dt
free_seats = set() ):
seats_seen = set() quotas = (cp.variation.quotas.filter(subevent=cp.subevent)
consume_carts = validated_data.pop('consume_carts', []) if cp.variation else cp.item.quotas.filter(subevent=cp.subevent))
delete_cps = [] for quota in quotas:
quota_avail_cache = {} if cp.expires > offset:
v_budget = {} quota_diff_for_locking[quota] -= 1
voucher_usage = Counter() quota_usage[quota] -= 1
if consume_carts: if cp.voucher:
for cp in CartPosition.objects.filter( if cp.expires > offset:
event=self.context['event'], cart_id__in=consume_carts, expires__gt=now() voucher_diff_for_locking[cp.voucher] -= 1
): voucher_usage[cp.voucher] -= 1
quotas = (cp.variation.quotas.filter(subevent=cp.subevent) if cp.seat:
if cp.variation else cp.item.quotas.filter(subevent=cp.subevent)) if cp.expires > offset:
for quota in quotas: seat_diff_for_locking[cp.seat] -= 1
if quota not in quota_avail_cache: seat_usage[cp.seat] -= 1
quota_avail_cache[quota] = list(quota.availability()) delete_cps.append(cp)
if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] += 1
if cp.voucher:
voucher_usage[cp.voucher] -= 1
if cp.expires > now_dt:
if cp.seat:
free_seats.add(cp.seat)
delete_cps.append(cp)
errs = [{} for p in positions_data] if not simulate:
full_lock_required = seat_diff_for_locking and self.context['event'].settings.seating_minimal_distance > 0
if full_lock_required:
# We lock the entire event in this case since we don't want to deal with fine-granular locking
# in the case of seating distance enforcement
lock_objects([self.context['event']])
else:
lock_objects(
[q for q, d in quota_diff_for_locking.items() if d > 0 and q.size is not None and not force] +
[v for v, d in voucher_diff_for_locking.items() if d > 0 and not force] +
[s for s, d in seat_diff_for_locking.items() if d > 0],
shared_lock_objects=[self.context['event']]
)
for i, pos_data in enumerate(positions_data): qa = QuotaAvailability()
qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0])
qa.compute()
if pos_data.get('voucher'): # These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental
v = pos_data['voucher'] # use further down
del quota_diff_for_locking, voucher_diff_for_locking, seat_diff_for_locking
if pos_data.get('addon_to'): errs = [{} for p in positions_data]
errs[i]['voucher'] = ['Vouchers are currently not supported for add-on products.']
continue
if not v.applies_to(pos_data['item'], pos_data.get('variation')): for i, pos_data in enumerate(positions_data):
errs[i]['voucher'] = [error_messages['voucher_invalid_item']] if pos_data.get('voucher'):
continue v = pos_data['voucher']
if v.subevent_id and pos_data.get('subevent').pk != v.subevent_id: if pos_data.get('addon_to'):
errs[i]['voucher'] = [error_messages['voucher_invalid_subevent']] errs[i]['voucher'] = ['Vouchers are currently not supported for add-on products.']
continue continue
if v.valid_until is not None and v.valid_until < now_dt: if not v.applies_to(pos_data['item'], pos_data.get('variation')):
errs[i]['voucher'] = [error_messages['voucher_expired']] errs[i]['voucher'] = [error_messages['voucher_invalid_item']]
continue continue
voucher_usage[v] += 1 if v.subevent_id and pos_data.get('subevent').pk != v.subevent_id:
if voucher_usage[v] > 0: errs[i]['voucher'] = [error_messages['voucher_invalid_subevent']]
redeemed_in_carts = CartPosition.objects.filter( continue
Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
).exclude(pk__in=[cp.pk for cp in delete_cps])
v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail < voucher_usage[v]:
errs[i]['voucher'] = [
'The voucher has already been used the maximum number of times.'
]
if v.budget is not None: if v.valid_until is not None and v.valid_until < now_dt:
price = pos_data.get('price') errs[i]['voucher'] = [error_messages['voucher_expired']]
listed_price = get_listed_price(pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent')) continue
if pos_data.get('voucher'): voucher_usage[v] += 1
price_after_voucher = pos_data.get('voucher').calculate_price(listed_price) if voucher_usage[v] > 0:
else: redeemed_in_carts = CartPosition.objects.filter(
price_after_voucher = listed_price Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
if price is None: ).exclude(pk__in=[cp.pk for cp in delete_cps])
price = price_after_voucher v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail < voucher_usage[v]:
errs[i]['voucher'] = [
'The voucher has already been used the maximum number of times.'
]
if v not in v_budget: if v.budget is not None:
v_budget[v] = v.budget - v.budget_used() price = pos_data.get('price')
disc = max(listed_price - price, 0) listed_price = get_listed_price(pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent'))
if disc > v_budget[v]:
new_disc = v_budget[v]
v_budget[v] -= new_disc
if new_disc == Decimal('0.00') or pos_data.get('price') is not None:
errs[i]['voucher'] = [
'The voucher has a remaining budget of {}, therefore a discount of {} can not be '
'given.'.format(v_budget[v] + new_disc, disc)
]
continue
pos_data['price'] = price + (disc - new_disc)
else:
v_budget[v] -= disc
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
if pos_data.get('addon_to'):
errs[i]['seat'] = ['Seats are currently not supported for add-on products.']
continue
if not seated:
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
try:
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
except Seat.DoesNotExist:
errs[i]['seat'] = ['The specified seat does not exist.']
else:
pos_data['seat'] = seat
if (seat not in free_seats and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat in seats_seen:
errs[i]['seat'] = [gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
seats_seen.add(seat)
elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.']
requested_valid_from = pos_data.pop('requested_valid_from', None)
if 'valid_from' not in pos_data and 'valid_until' not in pos_data:
valid_from, valid_until = pos_data['item'].compute_validity(
requested_start=(
max(requested_valid_from, now())
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
else now()
),
enforce_start_limit=True,
override_tz=self.context['event'].timezone,
)
pos_data['valid_from'] = valid_from
pos_data['valid_until'] = valid_until
if not force:
for i, pos_data in enumerate(positions_data):
if pos_data.get('voucher'): if pos_data.get('voucher'):
if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota: price_after_voucher = pos_data.get('voucher').calculate_price(listed_price)
else:
price_after_voucher = listed_price
if price is None:
price = price_after_voucher
if v not in v_budget:
v_budget[v] = v.budget - v.budget_used()
disc = max(listed_price - price, 0)
if disc > v_budget[v]:
new_disc = v_budget[v]
v_budget[v] -= new_disc
if new_disc == Decimal('0.00') or pos_data.get('price') is not None:
errs[i]['voucher'] = [
'The voucher has a remaining budget of {}, therefore a discount of {} can not be '
'given.'.format(v_budget[v] + new_disc, disc)
]
continue continue
pos_data['price'] = price + (disc - new_disc)
else:
v_budget[v] -= disc
if pos_data.get('subevent'): seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('item').pk in pos_data['subevent'].item_overrides and pos_data['subevent'].item_overrides[pos_data['item'].pk].disabled: if pos_data.get('seat'):
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format( if pos_data.get('addon_to'):
str(pos_data.get('item')) errs[i]['seat'] = ['Seats are currently not supported for add-on products.']
)] continue
if ( if not seated:
pos_data.get('variation') and pos_data['variation'].pk in pos_data['subevent'].var_overrides and errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
pos_data['subevent'].var_overrides[pos_data['variation'].pk].disabled seat = pos_data['seat']
): if seat is Seat.DoesNotExist:
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format( errs[i]['seat'] = ['The specified seat does not exist.']
str(pos_data.get('item')) else:
)] seat_usage[seat] += 1
if (seat_usage[seat] > 0 and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat_usage[seat] > 1:
errs[i]['seat'] = [gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.']
new_quotas = pos_data['_quotas'] requested_valid_from = pos_data.pop('requested_valid_from', None)
if len(new_quotas) == 0: if 'valid_from' not in pos_data and 'valid_until' not in pos_data:
errs[i]['item'] = [gettext_lazy('The product "{}" is not assigned to a quota.').format( valid_from, valid_until = pos_data['item'].compute_validity(
requested_start=(
max(requested_valid_from, now())
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
else now()
),
enforce_start_limit=True,
override_tz=self.context['event'].timezone,
)
pos_data['valid_from'] = valid_from
pos_data['valid_until'] = valid_until
if not force:
for i, pos_data in enumerate(positions_data):
if pos_data.get('voucher'):
if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota:
continue
if pos_data.get('subevent'):
if pos_data.get('item').pk in pos_data['subevent'].item_overrides and pos_data['subevent'].item_overrides[pos_data['item'].pk].disabled:
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
str(pos_data.get('item'))
)]
if (
pos_data.get('variation') and pos_data['variation'].pk in pos_data['subevent'].var_overrides and
pos_data['subevent'].var_overrides[pos_data['variation'].pk].disabled
):
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
str(pos_data.get('item')) str(pos_data.get('item'))
)] )]
else:
for quota in new_quotas:
if quota not in quota_avail_cache:
quota_avail_cache[quota] = list(quota.availability())
if quota_avail_cache[quota][1] is not None: new_quotas = quotas_by_item[pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent')]
quota_avail_cache[quota][1] -= 1 if len(new_quotas) == 0:
if quota_avail_cache[quota][1] < 0: errs[i]['item'] = [gettext_lazy('The product "{}" is not assigned to a quota.').format(
errs[i]['item'] = [ str(pos_data.get('item'))
gettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format( )]
quota.name
)
]
if any(errs):
raise ValidationError({'positions': errs})
if validated_data.get('locale', None) is None:
validated_data['locale'] = self.context['event'].settings.locale
order = Order(event=self.context['event'], **validated_data)
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
order.meta_info = "{}"
order.total = Decimal('0.00')
if validated_data.get('require_approval') is not None:
order.require_approval = validated_data['require_approval']
if simulate:
order = WrappedModel(order)
order.last_modified = now()
order.code = 'PREVIEW'
else:
order.save()
if ia:
if not simulate:
ia.order = order
ia.save()
else: else:
order.invoice_address = ia for quota in new_quotas:
ia.last_modified = now() quota_usage[quota] += 1
if quota_usage[quota] > 0 and qa.results[quota][1] is not None:
if qa.results[quota][1] < quota_usage[quota]:
errs[i]['item'] = [
gettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
quota.name
)
]
# Generate position objects if any(errs):
pos_map = {} raise ValidationError({'positions': errs})
for pos_data in positions_data:
addon_to = pos_data.pop('addon_to', None)
attendee_name = pos_data.pop('attendee_name', '')
if attendee_name and not pos_data.get('attendee_name_parts'):
pos_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
if simulate:
pos.order = order._wrapped
else:
pos.order = order
if addon_to:
if simulate:
pos.addon_to = pos_map[addon_to]
else:
pos.addon_to = pos_map[addon_to]
pos_map[pos.positionid] = pos if validated_data.get('locale', None) is None:
pos_data['__instance'] = pos validated_data['locale'] = self.context['event'].settings.locale
order = Order(event=self.context['event'], **validated_data)
# Calculate prices if not set order.set_expires(subevents=[p.get('subevent') for p in positions_data])
for pos_data in positions_data: order.meta_info = "{}"
pos = pos_data['__instance'] order.total = Decimal('0.00')
if pos.addon_to_id and is_included_for_free(pos.item, pos.addon_to): if validated_data.get('require_approval') is not None:
listed_price = Decimal('0.00') order.require_approval = validated_data['require_approval']
else: if simulate:
listed_price = get_listed_price(pos.item, pos.variation, pos.subevent) order = WrappedModel(order)
order.last_modified = now()
if pos.price is None: order.code = 'PREVIEW'
if pos.voucher: else:
price_after_voucher = pos.voucher.calculate_price(listed_price) order.save()
else:
price_after_voucher = listed_price
line_price = get_line_price(
price_after_voucher=price_after_voucher,
custom_price_input=None,
custom_price_input_is_net=False,
tax_rule=pos.item.tax_rule,
invoice_address=ia,
bundled_sum=Decimal('0.00'),
)
pos.price = line_price.gross
pos._auto_generated_price = True
else:
if pos.voucher:
if not pos.item.tax_rule or pos.item.tax_rule.price_includes_tax:
price_after_voucher = max(pos.price, pos.voucher.calculate_price(listed_price))
else:
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
else:
price_after_voucher = listed_price
pos._auto_generated_price = False
pos._voucher_discount = listed_price - price_after_voucher
if pos.voucher:
pos.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
order_positions = [pos_data['__instance'] for pos_data in positions_data]
discount_results = apply_discounts(
self.context['event'],
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.price, bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
for cp in order_positions
]
)
for cp, (new_price, discount) in zip(order_positions, discount_results):
if new_price != pos.price and pos._auto_generated_price:
pos.price = new_price
pos.discount = discount
# Save instances
for pos_data in positions_data:
answers_data = pos_data.pop('answers', [])
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
pos = pos_data['__instance']
pos._calculate_tax()
if simulate:
pos = WrappedModel(pos)
pos.id = 0
answers = []
for answ_data in answers_data:
options = answ_data.pop('options', [])
answ = WrappedModel(QuestionAnswer(**answ_data))
answ.options = WrappedList(options)
answers.append(answ)
pos.answers = answers
pos.pseudonymization_id = "PREVIEW"
pos.checkins = []
pos_map[pos.positionid] = pos
else:
if pos.voucher:
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
pos.save()
seen_answers = set()
for answ_data in answers_data:
# Workaround for a pretixPOS bug :-(
if answ_data.get('question') in seen_answers:
continue
seen_answers.add(answ_data.get('question'))
options = answ_data.pop('options', [])
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
answ = pos.answers.create(**answ_data, answer='')
answ.file.save(os.path.basename(an.name), an, save=False)
answ.answer = 'file://' + answ.file.name
answ.save()
else:
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
if use_reusable_medium:
use_reusable_medium.linked_orderposition = pos
use_reusable_medium.save(update_fields=['linked_orderposition'])
use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.changed',
data={
'by_order': order.code,
'linked_orderposition': pos.pk,
}
)
if ia:
if not simulate: if not simulate:
for cp in delete_cps: ia.order = order
if cp.addon_to_id: ia.save()
else:
order.invoice_address = ia
ia.last_modified = now()
# Generate position objects
pos_map = {}
for pos_data in positions_data:
addon_to = pos_data.pop('addon_to', None)
attendee_name = pos_data.pop('attendee_name', '')
if attendee_name and not pos_data.get('attendee_name_parts'):
pos_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
if simulate:
pos.order = order._wrapped
else:
pos.order = order
if addon_to:
if simulate:
pos.addon_to = pos_map[addon_to]
else:
pos.addon_to = pos_map[addon_to]
pos_map[pos.positionid] = pos
pos_data['__instance'] = pos
# Calculate prices if not set
for pos_data in positions_data:
pos = pos_data['__instance']
if pos.addon_to_id and is_included_for_free(pos.item, pos.addon_to):
listed_price = Decimal('0.00')
else:
listed_price = get_listed_price(pos.item, pos.variation, pos.subevent)
if pos.price is None:
if pos.voucher:
price_after_voucher = pos.voucher.calculate_price(listed_price)
else:
price_after_voucher = listed_price
line_price = get_line_price(
price_after_voucher=price_after_voucher,
custom_price_input=None,
custom_price_input_is_net=False,
tax_rule=pos.item.tax_rule,
invoice_address=ia,
bundled_sum=Decimal('0.00'),
)
pos.price = line_price.gross
pos._auto_generated_price = True
else:
if pos.voucher:
if not pos.item.tax_rule or pos.item.tax_rule.price_includes_tax:
price_after_voucher = max(pos.price, pos.voucher.calculate_price(listed_price))
else:
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
else:
price_after_voucher = listed_price
pos._auto_generated_price = False
pos._voucher_discount = listed_price - price_after_voucher
if pos.voucher:
pos.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
order_positions = [pos_data['__instance'] for pos_data in positions_data]
discount_results = apply_discounts(
self.context['event'],
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.price, bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
for cp in order_positions
]
)
for cp, (new_price, discount) in zip(order_positions, discount_results):
if new_price != pos.price and pos._auto_generated_price:
pos.price = new_price
pos.discount = discount
# Save instances
for pos_data in positions_data:
answers_data = pos_data.pop('answers', [])
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
pos = pos_data['__instance']
pos._calculate_tax()
if simulate:
pos = WrappedModel(pos)
pos.id = 0
answers = []
for answ_data in answers_data:
options = answ_data.pop('options', [])
answ = WrappedModel(QuestionAnswer(**answ_data))
answ.options = WrappedList(options)
answers.append(answ)
pos.answers = answers
pos.pseudonymization_id = "PREVIEW"
pos.checkins = []
pos_map[pos.positionid] = pos
else:
if pos.voucher:
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
pos.save()
seen_answers = set()
for answ_data in answers_data:
# Workaround for a pretixPOS bug :-(
if answ_data.get('question') in seen_answers:
continue continue
cp.addons.all().delete() seen_answers.add(answ_data.get('question'))
cp.delete()
options = answ_data.pop('options', [])
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
answ = pos.answers.create(**answ_data, answer='')
answ.file.save(os.path.basename(an.name), an, save=False)
answ.answer = 'file://' + answ.file.name
answ.save()
else:
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
if use_reusable_medium:
use_reusable_medium.linked_orderposition = pos
use_reusable_medium.save(update_fields=['linked_orderposition'])
use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.changed',
data={
'by_order': order.code,
'linked_orderposition': pos.pk,
}
)
if not simulate:
for cp in delete_cps:
if cp.addon_to_id:
continue
cp.addons.all().delete()
cp.delete()
order.total = sum([p.price for p in pos_map.values()]) order.total = sum([p.price for p in pos_map.values()])
fees = [] fees = []
@@ -1512,6 +1626,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
class InvoiceSerializer(I18nAwareModelSerializer): class InvoiceSerializer(I18nAwareModelSerializer):
event = SlugRelatedField(slug_field='slug', read_only=True)
order = serializers.SlugRelatedField(slug_field='code', read_only=True) order = serializers.SlugRelatedField(slug_field='code', read_only=True)
refers = serializers.SlugRelatedField(slug_field='full_invoice_no', read_only=True) refers = serializers.SlugRelatedField(slug_field='full_invoice_no', read_only=True)
lines = InlineInvoiceLineSerializer(many=True) lines = InlineInvoiceLineSerializer(many=True)
@@ -1520,7 +1635,7 @@ class InvoiceSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Invoice model = Invoice
fields = ('order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode', fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id', 'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode', 'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode',
'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary', 'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary',
+8
View File
@@ -94,6 +94,14 @@ class CustomerSerializer(I18nAwareModelSerializer):
data['name_parts']['_scheme'] = self.context['request'].organizer.settings.name_scheme data['name_parts']['_scheme'] = self.context['request'].organizer.settings.name_scheme
return data return data
def validate_email(self, value):
qs = Customer.objects.filter(organizer=self.context['organizer'], email__iexact=value)
if self.instance and self.instance.pk:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise ValidationError(_("An account with this email address is already registered."))
return value
class CustomerCreateSerializer(CustomerSerializer): class CustomerCreateSerializer(CustomerSerializer):
send_email = serializers.BooleanField(default=False, required=False, allow_null=True) send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
+7 -2
View File
@@ -94,8 +94,13 @@ class VoucherSerializer(I18nAwareModelSerializer):
) )
if check_quota: if check_quota:
Voucher.clean_quota_check( Voucher.clean_quota_check(
full_data, 1, self.instance, self.context.get('event'), full_data,
full_data.get('quota'), full_data.get('item'), full_data.get('variation') full_data.get('max_usages', 1) - (self.instance.redeemed if self.instance else 0),
self.instance,
self.context.get('event'),
full_data.get('quota'),
full_data.get('item'),
full_data.get('variation')
) )
Voucher.clean_voucher_code(full_data, self.context.get('event'), self.instance.pk if self.instance else None) Voucher.clean_voucher_code(full_data, self.context.get('event'), self.instance.pk if self.instance else None)
+3 -1
View File
@@ -61,6 +61,8 @@ orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet)
orga_router.register(r'reusablemedia', media.ReusableMediaViewSet) orga_router.register(r'reusablemedia', media.ReusableMediaViewSet)
orga_router.register(r'teams', organizer.TeamViewSet) orga_router.register(r'teams', organizer.TeamViewSet)
orga_router.register(r'devices', organizer.DeviceViewSet) orga_router.register(r'devices', organizer.DeviceViewSet)
orga_router.register(r'orders', order.OrganizerOrderViewSet)
orga_router.register(r'invoices', order.InvoiceViewSet)
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
team_router = routers.DefaultRouter() team_router = routers.DefaultRouter()
@@ -77,7 +79,7 @@ event_router.register(r'questions', item.QuestionViewSet)
event_router.register(r'discounts', discount.DiscountViewSet) event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet) event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet) event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.OrderViewSet) event_router.register(r'orders', order.EventOrderViewSet)
event_router.register(r'orderpositions', order.OrderPositionViewSet) event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet) event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets') event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
+16 -6
View File
@@ -25,6 +25,7 @@ from typing import List
from django.db import transaction from django.db import transaction
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework import status, viewsets from rest_framework import status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
@@ -41,7 +42,7 @@ from pretix.base.models import CartPosition
from pretix.base.services.cart import ( from pretix.base.services.cart import (
_get_quota_availability, _get_voucher_availability, error_messages, _get_quota_availability, _get_voucher_availability, error_messages,
) )
from pretix.base.services.locking import NoLockManager from pretix.base.services.locking import lock_objects
class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet): class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
@@ -150,12 +151,21 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
quota_diff[q] += 1 quota_diff[q] += 1
seats_seen = set() seats_seen = set()
now_dt = now()
with transaction.atomic():
full_lock_required = seat_diff and self.request.event.settings.seating_minimal_distance > 0
if full_lock_required:
# We lock the entire event in this case since we don't want to deal with fine-granular locking
# in the case of seating distance enforcement
lock_objects([self.request.event])
else:
lock_objects(
[q for q, d in quota_diff.items() if q.size is not None and d > 0] +
[v for v, d in voucher_use_diff.items() if d > 0] +
[s for s, d in seat_diff.items() if d > 0],
shared_lock_objects=[self.request.event]
)
lockfn = NoLockManager
if self._require_locking(quota_diff, voucher_use_diff, seat_diff):
lockfn = self.request.event.lock
with lockfn() as now_dt, transaction.atomic():
vouchers_ok, vouchers_depend_on_cart = _get_voucher_availability( vouchers_ok, vouchers_depend_on_cart = _get_voucher_availability(
self.request.event, self.request.event,
voucher_use_diff, voucher_use_diff,
+20 -4
View File
@@ -164,8 +164,21 @@ class CheckinListViewSet(viewsets.ModelViewSet):
secret=serializer.validated_data['raw_barcode'] secret=serializer.validated_data['raw_barcode']
).first() ).first()
clist = self.get_object()
if serializer.validated_data.get('nonce'):
if kwargs.get('position'):
prev = kwargs['position'].all_checkins.filter(nonce=serializer.validated_data['nonce']).first()
else:
prev = clist.checkins.filter(
nonce=serializer.validated_data['nonce'],
raw_barcode=serializer.validated_data['raw_barcode'],
).first()
if prev:
# Ignore because nonce is already handled
return Response(serializer.data, status=201)
c = serializer.save( c = serializer.save(
list=self.get_object(), list=clist,
successful=False, successful=False,
forced=True, forced=True,
force_sent=True, force_sent=True,
@@ -265,6 +278,7 @@ with scopes_disabled():
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.checkinlist = kwargs.pop('checkinlist') self.checkinlist = kwargs.pop('checkinlist')
self.gate = kwargs.pop('gate')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def has_checkin_qs(self, queryset, name, value): def has_checkin_qs(self, queryset, name, value):
@@ -274,7 +288,7 @@ with scopes_disabled():
if not self.checkinlist.rules: if not self.checkinlist.rules:
return queryset return queryset
return queryset.filter( return queryset.filter(
SQLLogic(self.checkinlist).apply(self.checkinlist.rules) SQLLogic(self.checkinlist, self.gate).apply(self.checkinlist.rules)
).filter( ).filter(
Q(valid_from__isnull=True) | Q(valid_from__lte=now()), Q(valid_from__isnull=True) | Q(valid_from__lte=now()),
Q(valid_until__isnull=True) | Q(valid_until__gte=now()), Q(valid_until__isnull=True) | Q(valid_until__gte=now()),
@@ -396,7 +410,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce, def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported, untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
source_type='barcode', legacy_url_support=False, simulate=False): source_type='barcode', legacy_url_support=False, simulate=False, gate=None):
if not checkinlists: if not checkinlists:
raise ValidationError('No check-in list passed.') raise ValidationError('No check-in list passed.')
@@ -404,7 +418,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
prefetch_related_objects([cl for cl in checkinlists if not cl.all_products], 'limit_products') prefetch_related_objects([cl for cl in checkinlists if not cl.all_products], 'limit_products')
device = auth if isinstance(auth, Device) else None device = auth if isinstance(auth, Device) else None
gate = auth.gate if isinstance(auth, Device) else None gate = gate or (auth.gate if isinstance(auth, Device) else None)
context = { context = {
'request': request, 'request': request,
@@ -659,6 +673,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
raw_source_type=source_type, raw_source_type=source_type,
from_revoked_secret=from_revoked_secret, from_revoked_secret=from_revoked_secret,
simulate=simulate, simulate=simulate,
gate=gate,
) )
except RequiredQuestionsError as e: except RequiredQuestionsError as e:
return Response({ return Response({
@@ -757,6 +772,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
def get_filterset_kwargs(self): def get_filterset_kwargs(self):
return { return {
'checkinlist': self.checkinlist, 'checkinlist': self.checkinlist,
'gate': self.request.auth.gate if isinstance(self.request.auth, Device) else None,
} }
@cached_property @cached_property
-1
View File
@@ -166,7 +166,6 @@ class InitializeView(APIView):
device.software_brand = serializer.validated_data.get('software_brand') device.software_brand = serializer.validated_data.get('software_brand')
device.software_version = serializer.validated_data.get('software_version') device.software_version = serializer.validated_data.get('software_version')
device.info = serializer.validated_data.get('info') device.info = serializer.validated_data.get('info')
print(serializer.validated_data, request.data)
device.rsa_pubkey = serializer.validated_data.get('rsa_pubkey') device.rsa_pubkey = serializer.validated_data.get('rsa_pubkey')
device.api_token = generate_api_token() device.api_token = generate_api_token()
device.save() device.save()
+15 -1
View File
@@ -381,16 +381,29 @@ with scopes_disabled():
| Q(location__icontains=i18ncomp(value)) | Q(location__icontains=i18ncomp(value))
) )
class OrganizerSubEventFilter(SubEventFilter):
def search_qs(self, queryset, name, value):
return queryset.filter(
Q(name__icontains=i18ncomp(value))
| Q(event__slug__icontains=value)
| Q(location__icontains=i18ncomp(value))
)
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = SubEventSerializer serializer_class = SubEventSerializer
queryset = SubEvent.objects.none() queryset = SubEvent.objects.none()
write_permission = 'can_change_event_settings' write_permission = 'can_change_event_settings'
filter_backends = (DjangoFilterBackend, TotalOrderingFilter) filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
filterset_class = SubEventFilter
ordering = ('date_from',) ordering = ('date_from',)
ordering_fields = ('id', 'date_from', 'last_modified') ordering_fields = ('id', 'date_from', 'last_modified')
@property
def filterset_class(self):
if getattr(self.request, 'event', None):
return SubEventFilter
return OrganizerSubEventFilter
def get_queryset(self): def get_queryset(self):
if getattr(self.request, 'event', None): if getattr(self.request, 'event', None):
qs = self.request.event.subevents qs = self.request.event.subevents
@@ -415,6 +428,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
'subeventitem_set', 'subeventitem_set',
'subeventitemvariation_set', 'subeventitemvariation_set',
'meta_values', 'meta_values',
'meta_values__property',
Prefetch( Prefetch(
'seat_category_mappings', 'seat_category_mappings',
to_attr='_seat_category_mappings', to_attr='_seat_category_mappings',
+85 -30
View File
@@ -26,6 +26,7 @@ from decimal import Decimal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import django_filters import django_filters
from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import ( from django.db.models import (
Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects, Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects,
@@ -44,6 +45,7 @@ from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError, APIException, NotFound, PermissionDenied, ValidationError,
) )
from rest_framework.mixins import CreateModelMixin from rest_framework.mixins import CreateModelMixin
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken from pretix.api.models import OAuthAccessToken
@@ -115,12 +117,16 @@ with scopes_disabled():
@scopes_disabled() @scopes_disabled()
def subevent_after_qs(self, qs, name, value): def subevent_after_qs(self, qs, name, value):
if getattr(self.request, 'event', None):
subevents = self.request.event.subevents
else:
subevents = SubEvent.objects.filter(event__organizer=self.request.organizer)
qs = qs.filter( qs = qs.filter(
pk__in=Subquery( pk__in=Subquery(
OrderPosition.all.filter( OrderPosition.all.filter(
subevent_id__in=SubEvent.objects.filter( subevent_id__in=subevents.filter(
Q(date_to__gt=value) | Q(date_from__gt=value, date_to__isnull=True), Q(date_to__gt=value) | Q(date_from__gt=value, date_to__isnull=True),
event=self.request.event
).values_list('id'), ).values_list('id'),
).values_list('order_id') ).values_list('order_id')
) )
@@ -128,12 +134,16 @@ with scopes_disabled():
return qs return qs
def subevent_before_qs(self, qs, name, value): def subevent_before_qs(self, qs, name, value):
if getattr(self.request, 'event', None):
subevents = self.request.event.subevents
else:
subevents = SubEvent.objects.filter(event__organizer=self.request.organizer)
qs = qs.filter( qs = qs.filter(
pk__in=Subquery( pk__in=Subquery(
OrderPosition.all.filter( OrderPosition.all.filter(
subevent_id__in=SubEvent.objects.filter( subevent_id__in=subevents.filter(
Q(date_from__lt=value), Q(date_from__lt=value),
event=self.request.event
).values_list('id'), ).values_list('id'),
).values_list('order_id') ).values_list('order_id')
) )
@@ -185,7 +195,7 @@ with scopes_disabled():
) )
class OrderViewSet(viewsets.ModelViewSet): class OrderViewSetMixin:
serializer_class = OrderSerializer serializer_class = OrderSerializer
queryset = Order.objects.none() queryset = Order.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter) filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
@@ -193,19 +203,12 @@ class OrderViewSet(viewsets.ModelViewSet):
ordering_fields = ('datetime', 'code', 'status', 'last_modified') ordering_fields = ('datetime', 'code', 'status', 'last_modified')
filterset_class = OrderFilter filterset_class = OrderFilter
lookup_field = 'code' lookup_field = 'code'
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_serializer_context(self): def get_base_queryset(self):
ctx = super().get_serializer_context() raise NotImplementedError()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
ctx['exclude'] = self.request.query_params.getlist('exclude')
ctx['include'] = self.request.query_params.getlist('include')
return ctx
def get_queryset(self): def get_queryset(self):
qs = self.request.event.orders qs = self.get_base_queryset()
if 'fees' not in self.request.GET.getlist('exclude'): if 'fees' not in self.request.GET.getlist('exclude'):
if self.request.query_params.get('include_canceled_fees', 'false') == 'true': if self.request.query_params.get('include_canceled_fees', 'false') == 'true':
fqs = OrderFee.all fqs = OrderFee.all
@@ -227,11 +230,12 @@ class OrderViewSet(viewsets.ModelViewSet):
opq = OrderPosition.all opq = OrderPosition.all
else: else:
opq = OrderPosition.objects opq = OrderPosition.objects
if request.query_params.get('pdf_data', 'false') == 'true': if request.query_params.get('pdf_data', 'false') == 'true' and getattr(request, 'event', None):
prefetch_related_objects([request.organizer], 'meta_properties') prefetch_related_objects([request.organizer], 'meta_properties')
prefetch_related_objects( prefetch_related_objects(
[request.event], [request.event],
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'), to_attr='meta_values_cached'), Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'),
'questions', 'questions',
'item_meta_properties', 'item_meta_properties',
) )
@@ -266,13 +270,12 @@ class OrderViewSet(viewsets.ModelViewSet):
) )
) )
def _get_output_provider(self, identifier): def get_serializer_context(self):
responses = register_ticket_outputs.send(self.request.event) ctx = super().get_serializer_context()
for receiver, response in responses: ctx['exclude'] = self.request.query_params.getlist('exclude')
prov = response(self.request.event) ctx['include'] = self.request.query_params.getlist('include')
if prov.identifier == identifier: ctx['pdf_data'] = False
return prov return ctx
raise NotFound('Unknown output provider.')
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce @scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
def list(self, request, **kwargs): def list(self, request, **kwargs):
@@ -289,6 +292,45 @@ class OrderViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data, headers={'X-Page-Generated': date}) return Response(serializer.data, headers={'X-Page-Generated': date})
class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
def get_base_queryset(self):
perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
if isinstance(self.request.auth, (TeamAPIToken, Device)):
return Order.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.auth.get_events_with_permission(perm, request=self.request)
)
elif self.request.user.is_authenticated:
return Order.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.user.get_events_with_permission(perm, request=self.request)
)
else:
raise PermissionDenied()
class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
return ctx
def get_base_queryset(self):
return self.request.event.orders
def _get_output_provider(self, identifier):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
prov = response(self.request.event)
if prov.identifier == identifier:
return prov
raise NotFound('Unknown output provider.')
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)') @action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs): def download(self, request, output, **kwargs):
provider = self._get_output_provider(output) provider = self._get_output_provider(output)
@@ -1191,7 +1233,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
ftype, ignored = mimetypes.guess_type(image_file.name) ftype, ignored = mimetypes.guess_type(image_file.name)
extension = os.path.basename(image_file.name).split('.')[-1] extension = os.path.basename(image_file.name).split('.')[-1]
else: else:
img = Image.open(image_file) img = Image.open(image_file, formats=settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
ftype = Image.MIME[img.format] ftype = Image.MIME[img.format]
extensions = { extensions = {
'GIF': 'gif', 'TIFF': 'tif', 'BMP': 'bmp', 'JPEG': 'jpg', 'PNG': 'png' 'GIF': 'gif', 'TIFF': 'tif', 'BMP': 'bmp', 'JPEG': 'jpg', 'PNG': 'png'
@@ -1782,11 +1824,24 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
write_permission = 'can_change_orders' write_permission = 'can_change_orders'
def get_queryset(self): def get_queryset(self):
return self.request.event.invoices.prefetch_related('lines').select_related('order', 'refers').annotate( perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
if getattr(self.request, 'event', None):
qs = self.request.event.invoices
elif isinstance(self.request.auth, (TeamAPIToken, Device)):
qs = Invoice.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.auth.get_events_with_permission(perm, request=self.request)
)
elif self.request.user.is_authenticated:
qs = Invoice.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.user.get_events_with_permission(perm, request=self.request)
)
return qs.prefetch_related('lines').select_related('order', 'refers').annotate(
nr=Concat('prefix', 'invoice_no') nr=Concat('prefix', 'invoice_no')
) )
@action(detail=True, ) @action(detail=True)
def download(self, request, **kwargs): def download(self, request, **kwargs):
invoice = self.get_object() invoice = self.get_object()
@@ -1805,7 +1860,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
return resp return resp
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def regenerate(self, request, **kwarts): def regenerate(self, request, **kwargs):
inv = self.get_object() inv = self.get_object()
if inv.canceled: if inv.canceled:
raise ValidationError('The invoice has already been canceled.') raise ValidationError('The invoice has already been canceled.')
@@ -1815,7 +1870,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
raise PermissionDenied('The invoice file is no longer stored on the server.') raise PermissionDenied('The invoice file is no longer stored on the server.')
elif inv.sent_to_organizer: elif inv.sent_to_organizer:
raise PermissionDenied('The invoice file has already been exported.') raise PermissionDenied('The invoice file has already been exported.')
elif now().astimezone(self.request.event.timezone).date() - inv.date > datetime.timedelta(days=1): elif now().astimezone(inv.event.timezone).date() - inv.date > datetime.timedelta(days=1):
raise PermissionDenied('The invoice file is too old to be regenerated.') raise PermissionDenied('The invoice file is too old to be regenerated.')
else: else:
inv = regenerate_invoice(inv) inv = regenerate_invoice(inv)
@@ -1830,7 +1885,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
return Response(status=204) return Response(status=204)
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def reissue(self, request, **kwarts): def reissue(self, request, **kwargs):
inv = self.get_object() inv = self.get_object()
if inv.canceled: if inv.canceled:
raise ValidationError('The invoice has already been canceled.') raise ValidationError('The invoice has already been canceled.')
+16 -47
View File
@@ -19,8 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import contextlib
from django.db import transaction from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
from django.utils.timezone import now from django.utils.timezone import now
@@ -69,30 +67,9 @@ class VoucherViewSet(viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
return self.request.event.vouchers.select_related('seat').all() return self.request.event.vouchers.select_related('seat').all()
def _predict_quota_check(self, data, instance): @transaction.atomic()
# This method predicts if Voucher.clean_quota_needs_checking
# *migh* later require a quota check. It is only approximate
# and returns True a little too often. The point is to avoid
# locks when we know we won't need them.
if 'allow_ignore_quota' in data and data.get('allow_ignore_quota'):
return False
if instance and 'allow_ignore_quota' not in data and instance.allow_ignore_quota:
return False
if 'block_quota' in data and not data.get('block_quota'):
return False
if instance and 'block_quota' not in data and not instance.block_quota:
return False
return True
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
if self._predict_quota_check(request.data, None): return super().create(request, *args, **kwargs)
lockfn = request.event.lock
else:
lockfn = contextlib.suppress # noop context manager
with lockfn():
return super().create(request, *args, **kwargs)
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
@@ -108,13 +85,9 @@ class VoucherViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event ctx['event'] = self.request.event
return ctx return ctx
@transaction.atomic()
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
if self._predict_quota_check(request.data, self.get_object()): return super().update(request, *args, **kwargs)
lockfn = request.event.lock
else:
lockfn = contextlib.suppress # noop context manager
with lockfn():
return super().update(request, *args, **kwargs)
def perform_update(self, serializer): def perform_update(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
@@ -140,22 +113,18 @@ class VoucherViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance) super().perform_destroy(instance)
@action(detail=False, methods=['POST']) @action(detail=False, methods=['POST'])
@transaction.atomic()
def batch_create(self, request, *args, **kwargs): def batch_create(self, request, *args, **kwargs):
if any(self._predict_quota_check(d, None) for d in request.data): serializer = self.get_serializer(data=request.data, many=True)
lockfn = request.event.lock serializer.is_valid(raise_exception=True)
else: with transaction.atomic():
lockfn = contextlib.suppress # noop context manager serializer.save(event=self.request.event)
with lockfn(): for i, v in enumerate(serializer.instance):
serializer = self.get_serializer(data=request.data, many=True) v.log_action(
serializer.is_valid(raise_exception=True) 'pretix.voucher.added',
with transaction.atomic(): user=self.request.user,
serializer.save(event=self.request.event) auth=self.request.auth,
for i, v in enumerate(serializer.instance): data=self.request.data[i]
v.log_action( )
'pretix.voucher.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data[i]
)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+27
View File
@@ -202,6 +202,21 @@ class ParametrizedWaitingListEntryWebhookEvent(ParametrizedWebhookEvent):
} }
class ParametrizedCustomerWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
customer = logentry.content_object
if not customer:
return None
return {
'notification_id': logentry.pk,
'organizer': customer.organizer.slug,
'customer': customer.identifier,
'action': logentry.action_type,
}
@receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events") @receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events")
def register_default_webhook_events(sender, **kwargs): def register_default_webhook_events(sender, **kwargs):
return ( return (
@@ -350,6 +365,18 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.orders.waitinglist.voucher_assigned', 'pretix.event.orders.waitinglist.voucher_assigned',
_('Waiting list entry received voucher'), _('Waiting list entry received voucher'),
), ),
ParametrizedCustomerWebhookEvent(
'pretix.customer.created',
_('Customer account created'),
),
ParametrizedCustomerWebhookEvent(
'pretix.customer.changed',
_('Customer account changed'),
),
ParametrizedCustomerWebhookEvent(
'pretix.customer.anonymized',
_('Customer account anonymized'),
),
) )
+5 -5
View File
@@ -62,27 +62,27 @@ class NamespacedCache:
prefix = int(time.time()) prefix = int(time.time())
self.cache.set(self.prefixkey, prefix) self.cache.set(self.prefixkey, prefix)
def set(self, key: str, value: str, timeout: int=300): def set(self, key: str, value: any, timeout: int=300):
return self.cache.set(self._prefix_key(key), value, timeout) return self.cache.set(self._prefix_key(key), value, timeout)
def get(self, key: str) -> str: def get(self, key: str) -> any:
return self.cache.get(self._prefix_key(key, known_prefix=self._last_prefix)) return self.cache.get(self._prefix_key(key, known_prefix=self._last_prefix))
def get_or_set(self, key: str, default: Callable, timeout=300) -> str: def get_or_set(self, key: str, default: Callable, timeout=300) -> any:
return self.cache.get_or_set( return self.cache.get_or_set(
self._prefix_key(key, known_prefix=self._last_prefix), self._prefix_key(key, known_prefix=self._last_prefix),
default=default, default=default,
timeout=timeout timeout=timeout
) )
def get_many(self, keys: List[str]) -> Dict[str, str]: def get_many(self, keys: List[str]) -> Dict[str, any]:
values = self.cache.get_many([self._prefix_key(key) for key in keys]) values = self.cache.get_many([self._prefix_key(key) for key in keys])
newvalues = {} newvalues = {}
for k, v in values.items(): for k, v in values.items():
newvalues[self._strip_prefix(k)] = v newvalues[self._strip_prefix(k)] = v
return newvalues return newvalues
def set_many(self, values: Dict[str, str], timeout=300): def set_many(self, values: Dict[str, any], timeout=300):
newvalues = {} newvalues = {}
for k, v in values.items(): for k, v in values.items():
newvalues[self._prefix_key(k)] = v newvalues[self._prefix_key(k)] = v
+10 -2
View File
@@ -134,8 +134,11 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def template_name(self): def template_name(self):
raise NotImplementedError() raise NotImplementedError()
def compile_markdown(self, plaintext):
return markdown_compile_email(plaintext)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str:
body_md = markdown_compile_email(plain_body) body_md = self.compile_markdown(plain_body)
htmlctx = { htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME, 'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL, 'site_url': settings.SITE_URL,
@@ -153,7 +156,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
if plain_signature: if plain_signature:
signature_md = plain_signature.replace('\n', '<br>\n') signature_md = plain_signature.replace('\n', '<br>\n')
signature_md = markdown_compile_email(signature_md) signature_md = self.compile_markdown(signature_md)
htmlctx['signature'] = signature_md htmlctx['signature'] = signature_md
if order: if order:
@@ -666,6 +669,11 @@ def base_placeholders(sender, **kwargs):
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts), lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
_("Mr Doe"), _("Mr Doe"),
)) ))
ph.append(SimpleFunctionalMailTextPlaceholder(
"name", ["waiting_list_entry"],
lambda waiting_list_entry: waiting_list_entry.name or "",
_("Mr Doe"),
))
ph.append(SimpleFunctionalMailTextPlaceholder( ph.append(SimpleFunctionalMailTextPlaceholder(
"name_for_salutation", ["position_or_address"], "name_for_salutation", ["position_or_address"],
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)), lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
+4
View File
@@ -549,7 +549,9 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('End date')) headers.append(_('End date'))
headers += [ headers += [
_('Product'), _('Product'),
_('Product ID'),
_('Variation'), _('Variation'),
_('Variation ID'),
_('Price'), _('Price'),
_('Tax rate'), _('Tax rate'),
_('Tax rule'), _('Tax rule'),
@@ -656,7 +658,9 @@ class OrderListExporter(MultiSheetListExporter):
row.append('') row.append('')
row += [ row += [
str(op.item), str(op.item),
str(op.item_id),
str(op.variation) if op.variation else '', str(op.variation) if op.variation else '',
str(op.variation_id) if op.variation_id else '',
op.price, op.price,
op.tax_rate, op.tax_rate,
str(op.tax_rule) if op.tax_rule else '', str(op.tax_rule) if op.tax_rule else '',
+4 -8
View File
@@ -500,14 +500,14 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
file = BytesIO(data['content']) file = BytesIO(data['content'])
try: try:
image = Image.open(file) image = Image.open(file, formats=settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
# verify() must be called immediately after the constructor. # verify() must be called immediately after the constructor.
image.verify() image.verify()
# We want to do more than just verify(), so we need to re-open the file # We want to do more than just verify(), so we need to re-open the file
if hasattr(file, 'seek'): if hasattr(file, 'seek'):
file.seek(0) file.seek(0)
image = Image.open(file) image = Image.open(file, formats=settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
# load() is a potential DoS vector (see Django bug #18520), so we verify the size first # load() is a potential DoS vector (see Django bug #18520), so we verify the size first
if image.width > 10_000 or image.height > 10_000: if image.width > 10_000 or image.height > 10_000:
@@ -566,7 +566,7 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
return f return f
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
kwargs.setdefault('ext_whitelist', (".png", ".jpg", ".jpeg", ".jfif", ".tif", ".tiff", ".bmp")) kwargs.setdefault('ext_whitelist', settings.FILE_UPLOAD_EXTENSIONS_QUESTION_IMAGE)
kwargs.setdefault('max_size', settings.FILE_UPLOAD_MAX_SIZE_IMAGE) kwargs.setdefault('max_size', settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -826,11 +826,7 @@ class BaseQuestionsForm(forms.Form):
help_text=help_text, help_text=help_text,
initial=initial.file if initial else None, initial=initial.file if initial else None,
widget=UploadedFileWidget(position=pos, event=event, answer=initial), widget=UploadedFileWidget(position=pos, event=event, answer=initial),
ext_whitelist=( ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
".png", ".jpg", ".gif", ".jpeg", ".pdf", ".txt", ".docx", ".gif", ".svg",
".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages",
".bmp", ".tif", ".tiff"
),
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER, max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
) )
elif q.type == Question.TYPE_DATE: elif q.type == Question.TYPE_DATE:
+12
View File
@@ -60,6 +60,18 @@ def replace_arabic_numbers(inp):
return inp.translate(table) return inp.translate(table)
def format_placeholders_help_text(placeholders, event=None):
placeholders = [(k, v.render_sample(event) if event else v) for k, v in placeholders.items()]
placeholders.sort(key=lambda x: x[0])
phs = [
'<button type="button" class="content-placeholder" title="%s">{%s}</button>' % (_("Sample: %s") % v if v else "", k)
for k, v in placeholders
]
return _('Available placeholders: {list}').format(
list=' '.join(phs)
)
class DatePickerWidget(forms.DateInput): class DatePickerWidget(forms.DateInput):
def __init__(self, attrs=None, date_format=None): def __init__(self, attrs=None, date_format=None):
attrs = attrs or {} attrs = attrs or {}
+1 -1
View File
@@ -264,7 +264,7 @@ def metric_values():
# Metrics from redis # Metrics from redis
if settings.HAS_REDIS: if settings.HAS_REDIS:
for key, value in redis.hscan_iter(REDIS_KEY): for key, value in redis.hscan_iter(REDIS_KEY, count=1000):
dkey = key.decode("utf-8") dkey = key.decode("utf-8")
splitted = dkey.split("{", 2) splitted = dkey.split("{", 2)
value = float(value.decode("utf-8")) value = float(value.decode("utf-8"))
+2
View File
@@ -271,6 +271,8 @@ class SecurityMiddleware(MiddlewareMixin):
(url.url_name == "event.checkout" and url.kwargs['step'] == "payment") (url.url_name == "event.checkout" and url.kwargs['step'] == "payment")
): ):
h['script-src'].append('https://pay.google.com') h['script-src'].append('https://pay.google.com')
h['frame-src'].append('https://pay.google.com')
h['connect-src'].append('https://google.com/pay')
if settings.LOG_CSP: if settings.LOG_CSP:
h['report-uri'] = ["/csp_report/"] h['report-uri'] = ["/csp_report/"]
if 'Content-Security-Policy' in resp: if 'Content-Security-Policy' in resp:
@@ -0,0 +1,34 @@
# Generated by Django 4.2.4 on 2023-08-28 12:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0244_mediumkeyset"),
]
operations = [
migrations.AddField(
model_name="discount",
name="benefit_apply_to_addons",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="discount",
name="benefit_ignore_voucher_discounted",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="discount",
name="benefit_limit_products",
field=models.ManyToManyField(
related_name="benefit_discounts", to="pretixbase.item"
),
),
migrations.AddField(
model_name="discount",
name="benefit_same_products",
field=models.BooleanField(default=True),
),
]
+1 -1
View File
@@ -97,7 +97,7 @@ def _transactions_mark_order_dirty(order_id, using=None):
if getattr(dirty_transactions, 'order_ids', None) is None: if getattr(dirty_transactions, 'order_ids', None) is None:
dirty_transactions.order_ids = set() dirty_transactions.order_ids = set()
if _check_for_dirty_orders not in [func for savepoint_id, func in conn.run_on_commit]: if _check_for_dirty_orders not in [func for (savepoint_id, func, *__) in conn.run_on_commit]:
transaction.on_commit(_check_for_dirty_orders, using) transaction.on_commit(_check_for_dirty_orders, using)
dirty_transactions.order_ids.clear() # This is necessary to clean up after old threads with rollbacked transactions dirty_transactions.order_ids.clear() # This is necessary to clean up after old threads with rollbacked transactions
+7 -3
View File
@@ -265,16 +265,16 @@ class CheckinList(LoggedModel):
# * in pretix.helpers.jsonlogic_boolalg # * in pretix.helpers.jsonlogic_boolalg
# * in checkinrules.js # * in checkinrules.js
# * in libpretixsync # * in libpretixsync
# * in pretixscan-ios (in the future) # * in pretixscan-ios
top_level_operators = { top_level_operators = {
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and' '<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
} }
allowed_operators = top_level_operators | { allowed_operators = top_level_operators | {
'buildTime', 'objectList', 'lookup', 'var', 'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'
} }
allowed_vars = { allowed_vars = {
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days', 'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
'minutes_since_last_entry', 'minutes_since_first_entry', 'minutes_since_last_entry', 'minutes_since_first_entry', 'gate',
} }
if not rules or not isinstance(rules, dict): if not rules or not isinstance(rules, dict):
return rules return rules
@@ -299,6 +299,10 @@ class CheckinList(LoggedModel):
raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.') raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.')
return rules return rules
if operator in ('entries_since', 'entries_before'):
if len(values) != 1 or "buildTime" not in values[0]:
raise ValidationError(f'Operator "{operator}" takes exactly one "buildTime" argument.')
if operator in ('or', 'and') and seen_nonbool: if operator in ('or', 'and') and seen_nonbool:
raise ValidationError('You cannot use OR/AND logic on a level below a comparison operator.') raise ValidationError('You cannot use OR/AND logic on a level below a comparison operator.')
+74 -23
View File
@@ -99,7 +99,7 @@ class Discount(LoggedModel):
) )
condition_apply_to_addons = models.BooleanField( condition_apply_to_addons = models.BooleanField(
default=True, default=True,
verbose_name=_("Apply to add-on products"), verbose_name=_("Count add-on products"),
help_text=_("Discounts never apply to bundled products"), help_text=_("Discounts never apply to bundled products"),
) )
condition_ignore_voucher_discounted = models.BooleanField( condition_ignore_voucher_discounted = models.BooleanField(
@@ -107,7 +107,7 @@ class Discount(LoggedModel):
verbose_name=_("Ignore products discounted by a voucher"), verbose_name=_("Ignore products discounted by a voucher"),
help_text=_("If this option is checked, products that already received a discount through a voucher will not " help_text=_("If this option is checked, products that already received a discount through a voucher will not "
"be considered for this discount. However, products that use a voucher only to e.g. unlock a " "be considered for this discount. However, products that use a voucher only to e.g. unlock a "
"hidden product or gain access to sold-out quota will still receive the discount."), "hidden product or gain access to sold-out quota will still be considered."),
) )
condition_min_count = models.PositiveIntegerField( condition_min_count = models.PositiveIntegerField(
verbose_name=_('Minimum number of matching products'), verbose_name=_('Minimum number of matching products'),
@@ -120,6 +120,19 @@ class Discount(LoggedModel):
default=Decimal('0.00'), default=Decimal('0.00'),
) )
benefit_same_products = models.BooleanField(
default=True,
verbose_name=_("Apply discount to same set of products"),
help_text=_("By default, the discount is applied across the same selection of products than the condition for "
"the discount given above. If you want, you can however also select a different selection of "
"products.")
)
benefit_limit_products = models.ManyToManyField(
'Item',
verbose_name=_("Apply discount to specific products"),
related_name='benefit_discounts',
blank=True
)
benefit_discount_matching_percent = models.DecimalField( benefit_discount_matching_percent = models.DecimalField(
verbose_name=_('Percentual discount on matching products'), verbose_name=_('Percentual discount on matching products'),
decimal_places=2, decimal_places=2,
@@ -139,6 +152,18 @@ class Discount(LoggedModel):
blank=True, blank=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
) )
benefit_apply_to_addons = models.BooleanField(
default=True,
verbose_name=_("Apply to add-on products"),
help_text=_("Discounts never apply to bundled products"),
)
benefit_ignore_voucher_discounted = models.BooleanField(
default=False,
verbose_name=_("Ignore products discounted by a voucher"),
help_text=_("If this option is checked, products that already received a discount through a voucher will not "
"be discounted. However, products that use a voucher only to e.g. unlock a hidden product or gain "
"access to sold-out quota will still receive the discount."),
)
# more feature ideas: # more feature ideas:
# - max_usages_per_order # - max_usages_per_order
@@ -187,6 +212,14 @@ class Discount(LoggedModel):
'on a minimum value.') 'on a minimum value.')
) )
if data.get('subevent_mode') == cls.SUBEVENT_MODE_DISTINCT and not data.get('benefit_same_products'):
raise ValidationError(
{'benefit_same_products': [
_('You cannot apply the discount to a different set of products if the discount is only valid '
'for bookings of different dates.')
]}
)
def allow_delete(self): def allow_delete(self):
return not self.orderposition_set.exists() return not self.orderposition_set.exists()
@@ -197,6 +230,7 @@ class Discount(LoggedModel):
'condition_min_value': self.condition_min_value, 'condition_min_value': self.condition_min_value,
'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches, 'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches,
'subevent_mode': self.subevent_mode, 'subevent_mode': self.subevent_mode,
'benefit_same_products': self.benefit_same_products,
}) })
def is_available_by_time(self, now_dt=None) -> bool: def is_available_by_time(self, now_dt=None) -> bool:
@@ -207,14 +241,14 @@ class Discount(LoggedModel):
return False return False
return True return True
def _apply_min_value(self, positions, idx_group, result): def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result):
if self.condition_min_value and sum(positions[idx][2] for idx in idx_group) < self.condition_min_value: if self.condition_min_value and sum(positions[idx][2] for idx in condition_idx_group) < self.condition_min_value:
return return
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches: if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
raise ValueError('Validation invariant violated.') raise ValueError('Validation invariant violated.')
for idx in idx_group: for idx in benefit_idx_group:
previous_price = positions[idx][2] previous_price = positions[idx][2]
new_price = round_decimal( new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'), previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
@@ -222,8 +256,8 @@ class Discount(LoggedModel):
) )
result[idx] = new_price result[idx] = new_price
def _apply_min_count(self, positions, idx_group, result): def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result):
if len(idx_group) < self.condition_min_count: if len(condition_idx_group) < self.condition_min_count:
return return
if not self.condition_min_count or self.condition_min_value: if not self.condition_min_count or self.condition_min_value:
@@ -233,15 +267,17 @@ class Discount(LoggedModel):
if not self.condition_min_count: if not self.condition_min_count:
raise ValueError('Validation invariant violated.') raise ValueError('Validation invariant violated.')
idx_group = sorted(idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only # Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3 # want to match multiples of 3
consume_idx = idx_group[:len(idx_group) // self.condition_min_count * self.condition_min_count] n_groups = min(len(condition_idx_group) // self.condition_min_count, len(benefit_idx_group))
benefit_idx = idx_group[:len(idx_group) // self.condition_min_count * self.benefit_only_apply_to_cheapest_n_matches] consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
else: else:
consume_idx = idx_group consume_idx = condition_idx_group
benefit_idx = idx_group benefit_idx = benefit_idx_group
for idx in benefit_idx: for idx in benefit_idx:
previous_price = positions[idx][2] previous_price = positions[idx][2]
@@ -276,7 +312,7 @@ class Discount(LoggedModel):
limit_products = {p.pk for p in self.condition_limit_products.all()} limit_products = {p.pk for p in self.condition_limit_products.all()}
# First, filter out everything not even covered by our product scope # First, filter out everything not even covered by our product scope
initial_candidates = [ condition_candidates = [
idx idx
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items() for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if ( if (
@@ -286,11 +322,25 @@ class Discount(LoggedModel):
) )
] ]
if self.benefit_same_products:
benefit_candidates = list(condition_candidates)
else:
benefit_products = {p.pk for p in self.benefit_limit_products.all()}
benefit_candidates = [
idx
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if (
item_id in benefit_products and
(self.benefit_apply_to_addons or not is_addon_to) and
(not self.benefit_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
)
]
if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events
if self.condition_min_count: if self.condition_min_count:
self._apply_min_count(positions, initial_candidates, result) self._apply_min_count(positions, condition_candidates, benefit_candidates, result)
else: else:
self._apply_min_value(positions, initial_candidates, result) self._apply_min_value(positions, condition_candidates, benefit_candidates, result)
elif self.subevent_mode == self.SUBEVENT_MODE_SAME: elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx): def key(idx):
@@ -299,17 +349,18 @@ class Discount(LoggedModel):
# Build groups of candidates with the same subevent, then apply our regular algorithm # Build groups of candidates with the same subevent, then apply our regular algorithm
# to each group # to each group
_groups = groupby(sorted(initial_candidates, key=key), key=key) _groups = groupby(sorted(condition_candidates, key=key), key=key)
candidate_groups = [list(g) for k, g in _groups] candidate_groups = [(k, list(g)) for k, g in _groups]
for g in candidate_groups: for subevent_id, g in candidate_groups:
benefit_g = [idx for idx in benefit_candidates if positions[idx][1] == subevent_id]
if self.condition_min_count: if self.condition_min_count:
self._apply_min_count(positions, g, result) self._apply_min_count(positions, g, benefit_g, result)
else: else:
self._apply_min_value(positions, g, result) self._apply_min_value(positions, g, benefit_g, result)
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT: elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
if self.condition_min_value: if self.condition_min_value or not self.benefit_same_products:
raise ValueError('Validation invariant violated.') raise ValueError('Validation invariant violated.')
# Build optimal groups of candidates with distinct subevents, then apply our regular algorithm # Build optimal groups of candidates with distinct subevents, then apply our regular algorithm
@@ -336,7 +387,7 @@ class Discount(LoggedModel):
candidates = [] candidates = []
cardinality = None cardinality = None
for se, l in subevent_to_idx.items(): for se, l in subevent_to_idx.items():
l = [ll for ll in l if ll in initial_candidates and ll not in current_group] l = [ll for ll in l if ll in condition_candidates and ll not in current_group]
if cardinality and len(l) != cardinality: if cardinality and len(l) != cardinality:
continue continue
if se not in {positions[idx][1] for idx in current_group}: if se not in {positions[idx][1] for idx in current_group}:
@@ -373,5 +424,5 @@ class Discount(LoggedModel):
break break
for g in candidate_groups: for g in candidate_groups:
self._apply_min_count(positions, g, result) self._apply_min_count(positions, g, g, result)
return result return result
+7 -8
View File
@@ -743,12 +743,7 @@ class Event(EventMixin, LoggedModel):
return ObjectRelatedCache(self) return ObjectRelatedCache(self)
def lock(self): def lock(self):
""" raise NotImplementedError("this method has been removed")
Returns a contextmanager that can be used to lock an event for bookings.
"""
from pretix.base.services import locking
return locking.LockManager(self)
def get_mail_backend(self, timeout=None): def get_mail_backend(self, timeout=None):
""" """
@@ -907,14 +902,18 @@ class Event(EventMixin, LoggedModel):
self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q) self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q)
for d in Discount.objects.filter(event=other).prefetch_related('condition_limit_products'): for d in Discount.objects.filter(event=other).prefetch_related('condition_limit_products'):
items = list(d.condition_limit_products.all()) c_items = list(d.condition_limit_products.all())
b_items = list(d.benefit_limit_products.all())
d.pk = None d.pk = None
d.event = self d.event = self
d.save(force_insert=True) d.save(force_insert=True)
d.log_action('pretix.object.cloned') d.log_action('pretix.object.cloned')
for i in items: for i in c_items:
if i.pk in item_map: if i.pk in item_map:
d.condition_limit_products.add(item_map[i.pk]) d.condition_limit_products.add(item_map[i.pk])
for i in b_items:
if i.pk in item_map:
d.benefit_limit_products.add(item_map[i.pk])
question_map = {} question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'): for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
+9 -4
View File
@@ -43,6 +43,7 @@ from typing import Optional, Tuple
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import dateutil.parser import dateutil.parser
import django_redis
from dateutil.tz import datetime_exists from dateutil.tz import datetime_exists
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -57,7 +58,6 @@ from django.utils.functional import cached_property
from django.utils.timezone import is_naive, make_aware, now from django.utils.timezone import is_naive, make_aware, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries.fields import Country from django_countries.fields import Country
from django_redis import get_redis_connection
from django_scopes import ScopedManager from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField, I18nTextField from i18nfield.fields import I18nCharField, I18nTextField
@@ -1463,7 +1463,7 @@ class Question(LoggedModel):
(TYPE_PHONENUMBER, _("Phone number")), (TYPE_PHONENUMBER, _("Phone number")),
) )
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME] UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_PHONENUMBER] ASK_DURING_CHECKIN_UNSUPPORTED = []
event = models.ForeignKey( event = models.ForeignKey(
Event, Event,
@@ -1910,8 +1910,13 @@ class Quota(LoggedModel):
def rebuild_cache(self, now_dt=None): def rebuild_cache(self, now_dt=None):
if settings.HAS_REDIS: if settings.HAS_REDIS:
rc = get_redis_connection("redis") rc = django_redis.get_redis_connection("redis")
rc.hdel(f'quotas:{self.event_id}:availabilitycache', str(self.pk)) p = rc.pipeline()
p.hdel(f'quotas:{self.event_id}:availabilitycache', str(self.pk))
p.hdel(f'quotas:{self.event_id}:availabilitycache:nocw', str(self.pk))
p.hdel(f'quotas:{self.event_id}:availabilitycache:igcl', str(self.pk))
p.hdel(f'quotas:{self.event_id}:availabilitycache:nocw:igcl', str(self.pk))
p.execute()
self.availability(now_dt=now_dt) self.availability(now_dt=now_dt)
def availability( def availability(
+1 -3
View File
@@ -88,9 +88,7 @@ class LogEntry(models.Model):
class Meta: class Meta:
ordering = ('-datetime', '-id') ordering = ('-datetime', '-id')
index_together = [ indexes = [models.Index(fields=["datetime", "id"])]
['datetime', 'id']
]
def display(self): def display(self):
from ..signals import logentry_display from ..signals import logentry_display
+4 -1
View File
@@ -121,7 +121,10 @@ class ReusableMedium(LoggedModel):
class Meta: class Meta:
unique_together = (("identifier", "type", "organizer"),) unique_together = (("identifier", "type", "organizer"),)
index_together = (("identifier", "type", "organizer"), ("updated", "id")) indexes = [
models.Index(fields=("identifier", "type", "organizer")),
models.Index(fields=("updated", "id")),
]
ordering = "identifier", "type", "organizer" ordering = "identifier", "type", "organizer"
+44 -26
View File
@@ -37,10 +37,13 @@ import copy
import hashlib import hashlib
import json import json
import logging import logging
import operator
import string import string
from collections import Counter from collections import Counter
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from decimal import Decimal from decimal import Decimal
from functools import reduce
from time import sleep
from typing import Any, Dict, List, Union from typing import Any, Dict, List, Union
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -75,7 +78,6 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Customer, User from pretix.base.models import Customer, User
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.locking import LOCK_TIMEOUT, NoLockManager
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import order_gracefully_delete from pretix.base.signals import order_gracefully_delete
@@ -83,6 +85,7 @@ from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import format_map from ...helpers.format import format_map
from ...helpers.names import build_name from ...helpers.names import build_name
from ...testutils.middleware import debugflags_var
from ._transactions import ( from ._transactions import (
_fail, _transactions_mark_order_clean, _transactions_mark_order_dirty, _fail, _transactions_mark_order_clean, _transactions_mark_order_dirty,
) )
@@ -270,9 +273,9 @@ class Order(LockModel, LoggedModel):
verbose_name = _("Order") verbose_name = _("Order")
verbose_name_plural = _("Orders") verbose_name_plural = _("Orders")
ordering = ("-datetime", "-pk") ordering = ("-datetime", "-pk")
index_together = [ indexes = [
["datetime", "id"], models.Index(fields=["datetime", "id"]),
["last_modified", "id"], models.Index(fields=["last_modified", "id"]),
] ]
def __str__(self): def __str__(self):
@@ -825,7 +828,7 @@ class Order(LockModel, LoggedModel):
if cp.has_checkin: if cp.has_checkin:
return False return False
if self.event.settings.get('invoice_address_asked', as_type=bool): if self.event.settings.get('invoice_address_asked', as_type=bool) or self.event.settings.get('invoice_name_required', as_type=bool):
return True return True
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool) ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
for cp in positions: for cp in positions:
@@ -923,7 +926,7 @@ class Order(LockModel, LoggedModel):
else: else:
return expires return expires
def _can_be_paid(self, count_waitinglist=True, ignore_date=False, force=False) -> Union[bool, str]: def _can_be_paid(self, count_waitinglist=True, ignore_date=False, force=False, lock=False) -> Union[bool, str]:
error_messages = { error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the " 'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
"payment settings is over."), "payment settings is over."),
@@ -944,10 +947,11 @@ class Order(LockModel, LoggedModel):
if not self.event.settings.get('payment_term_accept_late') and not ignore_date and not force: if not self.event.settings.get('payment_term_accept_late') and not ignore_date and not force:
return error_messages['late'] return error_messages['late']
return self._is_still_available(count_waitinglist=count_waitinglist, force=force) return self._is_still_available(count_waitinglist=count_waitinglist, force=force, lock=lock)
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False, def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, lock=False, force=False,
check_voucher_usage=False, check_memberships=False) -> Union[bool, str]: check_voucher_usage=False, check_memberships=False) -> Union[bool, str]:
from pretix.base.services.locking import lock_objects
from pretix.base.services.memberships import ( from pretix.base.services.memberships import (
validate_memberships_in_order, validate_memberships_in_order,
) )
@@ -966,10 +970,21 @@ class Order(LockModel, LoggedModel):
try: try:
if check_memberships: if check_memberships:
try: try:
validate_memberships_in_order(self.customer, positions, self.event, lock=False, testmode=self.testmode) validate_memberships_in_order(self.customer, positions, self.event, lock=lock, testmode=self.testmode)
except ValidationError as e: except ValidationError as e:
raise Quota.QuotaExceededException(e.message) raise Quota.QuotaExceededException(e.message)
for cp in positions:
cp._cached_quotas = list(cp.quotas) if not force else []
if lock:
lock_objects(
[q for q in reduce(operator.or_, (set(cp._cached_quotas) for cp in positions), set()) if q.size is not None] +
[op.voucher for op in positions if op.voucher and not force] +
[op.seat for op in positions if op.seat],
shared_lock_objects=[self.event]
)
for i, op in enumerate(positions): for i, op in enumerate(positions):
if op.seat: if op.seat:
if not op.seat.is_available(ignore_orderpos=op): if not op.seat.is_available(ignore_orderpos=op):
@@ -994,7 +1009,7 @@ class Order(LockModel, LoggedModel):
voucher=op.voucher.code voucher=op.voucher.code
)) ))
quotas = list(op.quotas) quotas = op._cached_quotas
if len(quotas) == 0: if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'].format( raise Quota.QuotaExceededException(error_messages['unavailable'].format(
item=str(op.item) + (' - ' + str(op.variation) if op.variation else '') item=str(op.item) + (' - ' + str(op.variation) if op.variation else '')
@@ -1016,6 +1031,9 @@ class Order(LockModel, LoggedModel):
)) ))
except Quota.QuotaExceededException as e: except Quota.QuotaExceededException as e:
return str(e) return str(e)
if 'sleep-after-quota-check' in debugflags_var.get():
sleep(2)
return True return True
def send_mail(self, subject: Union[str, LazyI18nString], template: Union[str, LazyI18nString], def send_mail(self, subject: Union[str, LazyI18nString], template: Union[str, LazyI18nString],
@@ -1246,7 +1264,7 @@ class QuestionAnswer(models.Model):
@property @property
def is_image(self): def is_image(self):
return any(self.file.name.lower().endswith(e) for e in ('.jpg', '.png', '.gif', '.tiff', '.bmp', '.jpeg')) return any(self.file.name.lower().endswith(e) for e in settings.FILE_UPLOAD_EXTENSIONS_QUESTION_IMAGE)
@property @property
def file_name(self): def file_name(self):
@@ -1647,9 +1665,10 @@ class OrderPayment(models.Model):
return self.order.event.get_payment_providers(cached=True).get(self.provider) return self.order.event.get_payment_providers(cached=True).get(self.provider)
@transaction.atomic() @transaction.atomic()
def _mark_paid_inner(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False): def _mark_paid_inner(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False, lock=False):
from pretix.base.signals import order_paid from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force) can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force,
lock=lock)
if can_be_paid is not True: if can_be_paid is not True:
self.order.log_action('pretix.event.order.quotaexceeded', { self.order.log_action('pretix.event.order.quotaexceeded', {
'message': can_be_paid 'message': can_be_paid
@@ -1676,7 +1695,7 @@ class OrderPayment(models.Model):
""" """
Marks the order as failed and sets info to ``info``, but only if the order is in ``created`` or ``pending`` Marks the order as failed and sets info to ``info``, but only if the order is in ``created`` or ``pending``
state. This is equivalent to setting ``state`` to ``OrderPayment.PAYMENT_STATE_FAILED`` and logging a failure, state. This is equivalent to setting ``state`` to ``OrderPayment.PAYMENT_STATE_FAILED`` and logging a failure,
but it adds strong database logging since we do not want to report a failure for an order that has just but it adds strong database locking since we do not want to report a failure for an order that has just
been marked as paid. been marked as paid.
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``). :param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
""" """
@@ -1780,25 +1799,24 @@ class OrderPayment(models.Model):
)) ))
return return
self._mark_order_paid(count_waitinglist, send_mail, force, user, auth, mail_text, ignore_date, lock, payment_sum - refund_sum, with transaction.atomic():
generate_invoice) self._mark_order_paid(count_waitinglist, send_mail, force, user, auth, mail_text, ignore_date, lock, payment_sum - refund_sum,
generate_invoice)
def _mark_order_paid(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='', def _mark_order_paid(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
ignore_date=False, lock=True, payment_refund_sum=0, allow_generate_invoice=True): ignore_date=False, lock=True, payment_refund_sum=0, allow_generate_invoice=True):
from pretix.base.services.invoices import ( from pretix.base.services.invoices import (
generate_invoice, invoice_qualified, generate_invoice, invoice_qualified,
) )
from pretix.base.services.locking import LOCK_TRUST_WINDOW
if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(seconds=LOCK_TIMEOUT * 2)) or not lock: if lock and self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(seconds=LOCK_TRUST_WINDOW):
# Performance optimization. In this case, there's really no reason to lock everything and an atomic # Performance optimization. In this case, there's really no reason to lock everything and an atomic
# database transaction is more than enough. # database transaction is more than enough.
lockfn = NoLockManager lock = False
else:
lockfn = self.order.event.lock
with lockfn(): self._mark_paid_inner(force, count_waitinglist, user, auth, overpaid=payment_refund_sum > self.order.total,
self._mark_paid_inner(force, count_waitinglist, user, auth, overpaid=payment_refund_sum > self.order.total, ignore_date=ignore_date, lock=lock)
ignore_date=ignore_date)
invoice = None invoice = None
if invoice_qualified(self.order) and allow_generate_invoice: if invoice_qualified(self.order) and allow_generate_invoice:
@@ -2611,7 +2629,7 @@ class OrderPosition(AbstractPosition):
with language(self.order.locale, self.order.event.settings.region): with language(self.order.locale, self.order.event.settings.region):
email_template = self.event.settings.mail_text_resend_link email_template = self.event.settings.mail_text_resend_link
email_context = get_email_context(event=self.order.event, order=self.order, position=self) email_context = get_email_context(event=self.order.event, order=self.order, position=self)
email_subject = self.event.settings.mail_subject_resend_link email_subject = self.event.settings.mail_subject_resend_link_attendee
self.send_mail( self.send_mail(
email_subject, email_template, email_context, email_subject, email_template, email_context,
'pretix.event.order.email.resend', user=user, auth=auth, 'pretix.event.order.email.resend', user=user, auth=auth,
@@ -2756,8 +2774,8 @@ class Transaction(models.Model):
class Meta: class Meta:
ordering = 'datetime', 'pk' ordering = 'datetime', 'pk'
index_together = [ indexes = [
['datetime', 'id'] models.Index(fields=['datetime', 'id'])
] ]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
+11 -4
View File
@@ -340,10 +340,17 @@ class TaxRule(LoggedModel):
rules = self._custom_rules rules = self._custom_rules
if invoice_address: if invoice_address:
for r in rules: for r in rules:
if r['country'] == 'EU' and not is_eu_country(invoice_address.country): if r['country'] == 'ZZ': # Rule: Any country
continue pass
if r['country'] not in ('ZZ', 'EU') and r['country'] != str(invoice_address.country): elif r['country'] == 'EU': # Rule: Any EU country
continue if not is_eu_country(invoice_address.country):
continue
elif '-' in r['country']: # Rule: Specific country and state
if r['country'] != str(invoice_address.country) + '-' + str(invoice_address.state):
continue
else: # Rule: Specific country
if r['country'] != str(invoice_address.country):
continue
if r['address_type'] == 'individual' and invoice_address.is_business: if r['address_type'] == 'individual' and invoice_address.is_business:
continue continue
if r['address_type'] in ('business', 'business_vat_id') and not invoice_address.is_business: if r['address_type'] in ('business', 'business_vat_id') and not invoice_address.is_business:
+16 -7
View File
@@ -435,28 +435,37 @@ class Voucher(LoggedModel):
@staticmethod @staticmethod
def clean_quota_check(data, cnt, old_instance, event, quota, item, variation): def clean_quota_check(data, cnt, old_instance, event, quota, item, variation):
from ..services.locking import lock_objects
from ..services.quotas import QuotaAvailability
old_quotas = Voucher.clean_quota_get_ignored(old_instance) old_quotas = Voucher.clean_quota_get_ignored(old_instance)
if event.has_subevents and data.get('block_quota') and not data.get('subevent'): if event.has_subevents and data.get('block_quota') and not data.get('subevent'):
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.')) raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
if quota: if quota:
if quota in old_quotas: new_quotas = {quota}
return
else:
avail = quota.availability(count_waitinglist=False)
elif item and item.has_variations and not variation: elif item and item.has_variations and not variation:
raise ValidationError(_('You can only block quota if you specify a specific product variation. ' raise ValidationError(_('You can only block quota if you specify a specific product variation. '
'Otherwise it might be unclear which quotas to block.')) 'Otherwise it might be unclear which quotas to block.'))
elif item and variation: elif item and variation:
avail = variation.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent')) new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
elif item and not item.has_variations: elif item and not item.has_variations:
avail = item.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent')) new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
else: else:
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve ' raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
'tickets.')) 'tickets.'))
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < cnt): if not (new_quotas - old_quotas):
return
lock_objects([q for q in (new_quotas - old_quotas) if q.size is not None], shared_lock_objects=[event])
qa = QuotaAvailability(count_waitinglist=False)
qa.queue(*(new_quotas - old_quotas))
qa.compute()
if any(r[0] != Quota.AVAILABILITY_OK or (r[1] is not None and r[1] < cnt) for r in qa.results.values()):
raise ValidationError(_('You cannot create a voucher that blocks quota as the selected product or ' raise ValidationError(_('You cannot create a voucher that blocks quota as the selected product or '
'quota is currently sold out or completely reserved.')) 'quota is currently sold out or completely reserved.'))
+1 -1
View File
@@ -805,7 +805,7 @@ class QuestionColumn(ImportColumn):
return self.q.clean_answer(value) return self.q.clean_answer(value)
def assign(self, value, order, position, invoice_address, **kwargs): def assign(self, value, order, position, invoice_address, **kwargs):
if value: if value is not None:
if not hasattr(order, '_answers'): if not hasattr(order, '_answers'):
order._answers = [] order._answers = []
if isinstance(value, QuestionOption): if isinstance(value, QuestionOption):
+83 -37
View File
@@ -336,6 +336,12 @@ class BasePaymentProvider:
help_text=_('Users will not be able to choose this payment provider after the given date.'), help_text=_('Users will not be able to choose this payment provider after the given date.'),
required=False, required=False,
)), )),
('_availability_start',
RelativeDateField(
label=_('Available from'),
help_text=_('Users will not be able to choose this payment provider before the given date.'),
required=False,
)),
('_total_min', ('_total_min',
forms.DecimalField( forms.DecimalField(
label=_('Minimum order total'), label=_('Minimum order total'),
@@ -441,6 +447,13 @@ class BasePaymentProvider:
'Share this link with customers who should use this payment method.' 'Share this link with customers who should use this payment method.'
), ),
)), )),
('_prevent_reminder_mail',
forms.BooleanField(
label=_('Do not send a payment reminder mail'),
help_text=_('Users will not receive a reminder mail to pay for their order before it expires if '
'they have chosen this payment method.'),
required=False,
)),
]) ])
d['_restricted_countries']._as_type = list d['_restricted_countries']._as_type = list
d['_restrict_to_sales_channels']._as_type = list d['_restrict_to_sales_channels']._as_type = list
@@ -497,6 +510,14 @@ class BasePaymentProvider:
if order.status == Order.STATUS_PAID: if order.status == Order.STATUS_PAID:
return _('paid') return _('paid')
def prevent_reminder_mail(self, order: Order, payment: OrderPayment) -> bool:
"""
This is called when a periodic task runs and sends out reminder mails to orders that have not been paid yet
and are soon expiring.
The default implementation returns the content of the _prevent_reminder_mail configuration variable (a boolean value).
"""
return self.settings.get('_prevent_reminder_mail', as_type=bool, default=False)
@property @property
def payment_form_fields(self) -> dict: def payment_form_fields(self) -> dict:
""" """
@@ -539,40 +560,65 @@ class BasePaymentProvider:
return form return form
def _is_still_available(self, now_dt=None, cart_id=None, order=None): def _absolute_availability_date(self, rel_date, cart_id=None, order=None, aggregate_fn=min):
if not rel_date:
return None
if self.event.has_subevents and cart_id:
dates = [
rel_date.datetime(se).date()
for se in self.event.subevents.filter(
id__in=CartPosition.objects.filter(
cart_id=cart_id, event=self.event
).values_list('subevent', flat=True)
)
]
return aggregate_fn(dates) if dates else None
elif self.event.has_subevents and order:
dates = [
rel_date.datetime(se).date()
for se in self.event.subevents.filter(
id__in=order.positions.values_list('subevent', flat=True)
)
]
return aggregate_fn(dates) if dates else None
elif self.event.has_subevents:
raise NotImplementedError('Payment provider is not subevent-ready.')
else:
return rel_date.datetime(self.event).date()
def _is_available_by_time(self, now_dt=None, cart_id=None, order=None):
now_dt = now_dt or now() now_dt = now_dt or now()
tz = ZoneInfo(self.event.settings.timezone) tz = ZoneInfo(self.event.settings.timezone)
availability_date = self.settings.get('_availability_date', as_type=RelativeDateWrapper) try:
if availability_date: availability_start = self._absolute_availability_date(
if self.event.has_subevents and cart_id: self.settings.get('_availability_start', as_type=RelativeDateWrapper),
dates = [ cart_id,
availability_date.datetime(se).date() order,
for se in self.event.subevents.filter( # In an event series, we use min() for the start as well. This might be inconsistent with using min() for
id__in=CartPosition.objects.filter( # for the end, but makes it harder to put one self into a situation where no payment provider is available.
cart_id=cart_id, event=self.event aggregate_fn=min
).values_list('subevent', flat=True) )
)
]
availability_date = min(dates) if dates else None
elif self.event.has_subevents and order:
dates = [
availability_date.datetime(se).date()
for se in self.event.subevents.filter(
id__in=order.positions.values_list('subevent', flat=True)
)
]
availability_date = min(dates) if dates else None
elif self.event.has_subevents:
logger.error('Payment provider is not subevent-ready.')
return False
else:
availability_date = availability_date.datetime(self.event).date()
if availability_date: if availability_start:
return availability_date >= now_dt.astimezone(tz).date() if availability_start > now_dt.astimezone(tz).date():
return False
return True availability_end = self._absolute_availability_date(
self.settings.get('_availability_date', as_type=RelativeDateWrapper),
cart_id,
order,
aggregate_fn=min
)
if availability_end:
if availability_end < now_dt.astimezone(tz).date():
return False
return True
except NotImplementedError:
logger.exception('Unable to check availability')
return False
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
""" """
@@ -581,9 +627,9 @@ class BasePaymentProvider:
user will not be able to select this payment method. This will only be called user will not be able to select this payment method. This will only be called
during checkout, not on retrying. during checkout, not on retrying.
The default implementation checks for the _availability_date setting to be either unset or in the future The default implementation checks for the ``_availability_date`` setting to be either unset or in the future
and for the _total_max and _total_min requirements to be met. It also checks the ``_restrict_countries`` and for the ``_availability_from``, ``_total_max``, and ``_total_min`` requirements to be met. It also checks
and ``_restrict_to_sales_channels`` setting. the ``_restrict_countries`` and ``_restrict_to_sales_channels`` setting.
:param total: The total value without the payment method fee, after taxes. :param total: The total value without the payment method fee, after taxes.
@@ -592,7 +638,7 @@ class BasePaymentProvider:
The ``total`` parameter has been added. For backwards compatibility, this method is called again The ``total`` parameter has been added. For backwards compatibility, this method is called again
without this parameter if it raises a ``TypeError`` on first try. without this parameter if it raises a ``TypeError`` on first try.
""" """
timing = self._is_still_available(cart_id=get_or_create_cart_id(request)) timing = self._is_available_by_time(cart_id=get_or_create_cart_id(request))
pricing = True pricing = True
if (self.settings._total_max is not None or self.settings._total_min is not None) and total is None: if (self.settings._total_max is not None or self.settings._total_min is not None) and total is None:
@@ -742,7 +788,7 @@ class BasePaymentProvider:
the amount of money that should be paid. the amount of money that should be paid.
If you need any special behavior, you can return a string containing the URL the user will be redirected to. If you need any special behavior, you can return a string containing the URL the user will be redirected to.
If you are done with your process you should return the user to the order's detail page. Redirection is not If you are done with your process you should return the user to the order's detail page. Redirection is only
allowed if you set ``execute_payment_needs_user`` to ``True``. allowed if you set ``execute_payment_needs_user`` to ``True``.
If the payment is completed, you should call ``payment.confirm()``. Please note that this might If the payment is completed, you should call ``payment.confirm()``. Please note that this might
@@ -776,8 +822,8 @@ class BasePaymentProvider:
Will be called to check whether it is allowed to change the payment method of Will be called to check whether it is allowed to change the payment method of
an order to this one. an order to this one.
The default implementation checks for the _availability_date setting to be either unset or in the future, The default implementation checks for the ``_availability_date`` setting to be either unset or in the future,
as well as for the _total_max, _total_min and _restricted_countries settings. as well as for the ``_availability_from``, ``_total_max``, ``_total_min``, and ``_restricted_countries`` settings.
:param order: The order object :param order: The order object
""" """
@@ -804,7 +850,7 @@ class BasePaymentProvider:
if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']): if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
return False return False
return self._is_still_available(order=order) return self._is_available_by_time(order=order)
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]: def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]:
""" """
+7 -4
View File
@@ -108,7 +108,10 @@ DEFAULT_VARIABLES = OrderedDict((
("positionid", { ("positionid", {
"label": _("Order position number"), "label": _("Order position number"),
"editor_sample": "1", "editor_sample": "1",
"evaluate": lambda orderposition, order, event: str(orderposition.positionid) "evaluate": lambda orderposition, order, event: str(orderposition.positionid),
# There is no performance gain in using evaluate_bulk here, but we want to make sure it is used somewhere
# in core to make sure we notice if the implementation of the API breaks.
"evaluate_bulk": lambda orderpositions: [str(p.positionid) for p in orderpositions],
}), }),
("order_positionid", { ("order_positionid", {
"label": _("Order code and position number"), "label": _("Order code and position number"),
@@ -521,7 +524,7 @@ def images_from_questions(sender, *args, **kwargs):
else: else:
a = op.answers.filter(question_id=question_id).first() or a a = op.answers.filter(question_id=question_id).first() or a
if not a or not a.file or not any(a.file.name.lower().endswith(e) for e in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff")): if not a or not a.file or not any(a.file.name.lower().endswith(e) for e in settings.FILE_UPLOAD_EXTENSIONS_QUESTION_IMAGE):
return None return None
else: else:
if etag: if etag:
@@ -699,10 +702,10 @@ def get_seat(op: OrderPosition):
def generate_compressed_addon_list(op, order, event): def generate_compressed_addon_list(op, order, event):
itemcount = defaultdict(int) itemcount = defaultdict(int)
addons = ( addons = [p for p in (
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {}) op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
else op.addons.select_related('item', 'variation') else op.addons.select_related('item', 'variation')
) ) if not p.canceled]
for pos in addons: for pos in addons:
itemcount[pos.item, pos.variation] += 1 itemcount[pos.item, pos.variation] += 1
+49 -34
View File
@@ -36,6 +36,7 @@ import uuid
from collections import Counter, defaultdict, namedtuple from collections import Counter, defaultdict, namedtuple
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from decimal import Decimal from decimal import Decimal
from time import sleep
from typing import List, Optional from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError from celery.exceptions import MaxRetriesExceededError
@@ -62,7 +63,7 @@ from pretix.base.models.orders import OrderFee
from pretix.base.models.tax import TaxRule from pretix.base.models.tax import TaxRule
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.locking import LockTimeoutException, lock_objects
from pretix.base.services.pricing import ( from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, get_price, apply_discounts, get_line_price, get_listed_price, get_price,
is_included_for_free, is_included_for_free,
@@ -76,6 +77,7 @@ from pretix.celery_app import app
from pretix.presale.signals import ( from pretix.presale.signals import (
checkout_confirm_messages, fee_calculation_for_cart, checkout_confirm_messages, fee_calculation_for_cart,
) )
from pretix.testutils.middleware import debugflags_var
class CartError(Exception): class CartError(Exception):
@@ -1073,23 +1075,43 @@ class CartManager:
) )
return err return err
@transaction.atomic(durable=True)
def _perform_operations(self): def _perform_operations(self):
full_lock_required = any(getattr(o, 'seat', False) for o in self._operations) and self.event.settings.seating_minimal_distance > 0
if full_lock_required:
# We lock the entire event in this case since we don't want to deal with fine-granular locking
# in the case of seating distance enforcement
lock_objects([self.event])
else:
lock_objects(
[q for q, d in self._quota_diff.items() if q.size is not None and d > 0] +
[v for v, d in self._voucher_use_diff.items() if d > 0] +
[getattr(o, 'seat', False) for o in self._operations if getattr(o, 'seat', False)],
shared_lock_objects=[self.event]
)
vouchers_ok = self._get_voucher_availability() vouchers_ok = self._get_voucher_availability()
quotas_ok = _get_quota_availability(self._quota_diff, self.now_dt) quotas_ok = _get_quota_availability(self._quota_diff, self.now_dt)
err = None err = None
new_cart_positions = [] new_cart_positions = []
deleted_positions = set()
err = err or self._check_min_max_per_product() err = err or self._check_min_max_per_product()
self._operations.sort(key=lambda a: self.order[type(a)]) self._operations.sort(key=lambda a: self.order[type(a)])
seats_seen = set() seats_seen = set()
if 'sleep-after-quota-check' in debugflags_var.get():
sleep(2)
for iop, op in enumerate(self._operations): for iop, op in enumerate(self._operations):
if isinstance(op, self.RemoveOperation): if isinstance(op, self.RemoveOperation):
if op.position.expires > self.now_dt: if op.position.expires > self.now_dt:
for q in op.position.quotas: for q in op.position.quotas:
quotas_ok[q] += 1 quotas_ok[q] += 1
op.position.addons.all().delete() addons = op.position.addons.all()
deleted_positions |= {a.pk for a in addons}
addons.delete()
deleted_positions.add(op.position.pk)
op.position.delete() op.position.delete()
elif isinstance(op, (self.AddOperation, self.ExtendOperation)): elif isinstance(op, (self.AddOperation, self.ExtendOperation)):
@@ -1239,20 +1261,28 @@ class CartManager:
if op.seat and not op.seat.is_available(ignore_cart=op.position, sales_channel=self._sales_channel, if op.seat and not op.seat.is_available(ignore_cart=op.position, sales_channel=self._sales_channel,
ignore_voucher_id=op.position.voucher_id): ignore_voucher_id=op.position.voucher_id):
err = err or error_messages['seat_unavailable'] err = err or error_messages['seat_unavailable']
op.position.addons.all().delete()
addons = op.position.addons.all()
deleted_positions |= {a.pk for a in addons}
deleted_positions.add(op.position.pk)
addons.delete()
op.position.delete() op.position.delete()
elif available_count == 1: elif available_count == 1:
op.position.expires = self._expiry op.position.expires = self._expiry
op.position.listed_price = op.listed_price op.position.listed_price = op.listed_price
op.position.price_after_voucher = op.price_after_voucher op.position.price_after_voucher = op.price_after_voucher
# op.position.price will be updated by recompute_final_prices_and_taxes() # op.position.price will be updated by recompute_final_prices_and_taxes()
try: if op.position.pk not in deleted_positions:
op.position.save(force_update=True, update_fields=['expires', 'listed_price', 'price_after_voucher']) try:
except DatabaseError: op.position.save(force_update=True, update_fields=['expires', 'listed_price', 'price_after_voucher'])
# Best effort... The position might have been deleted in the meantime! except DatabaseError:
pass # Best effort... The position might have been deleted in the meantime!
pass
elif available_count == 0: elif available_count == 0:
op.position.addons.all().delete() addons = op.position.addons.all()
deleted_positions |= {a.pk for a in addons}
deleted_positions.add(op.position.pk)
addons.delete()
op.position.delete() op.position.delete()
else: else:
raise AssertionError("ExtendOperation cannot affect more than one item") raise AssertionError("ExtendOperation cannot affect more than one item")
@@ -1277,22 +1307,11 @@ class CartManager:
p.save() p.save()
_save_answers(p, {}, p._answers) _save_answers(p, {}, p._answers)
CartPosition.objects.bulk_create([p for p in new_cart_positions if not getattr(p, '_answers', None) and not p.pk]) CartPosition.objects.bulk_create([p for p in new_cart_positions if not getattr(p, '_answers', None) and not p.pk])
if 'sleep-before-commit' in debugflags_var.get():
sleep(2)
return err return err
def _require_locking(self):
if self._voucher_use_diff:
# If any vouchers are used, we lock to make sure we don't redeem them to often
return True
if self._quota_diff and any(q.size is not None for q in self._quota_diff):
# If any quotas are affected that are not unlimited, we lock
return True
if any(getattr(o, 'seat', False) for o in self._operations):
return True
return False
def recompute_final_prices_and_taxes(self): def recompute_final_prices_and_taxes(self):
positions = sorted(list(self.positions), key=lambda op: -(op.addon_to_id or 0)) positions = sorted(list(self.positions), key=lambda op: -(op.addon_to_id or 0))
diff = Decimal('0.00') diff = Decimal('0.00')
@@ -1331,18 +1350,14 @@ class CartManager:
err = self.extend_expired_positions() or err err = self.extend_expired_positions() or err
err = err or self._check_min_per_voucher() err = err or self._check_min_per_voucher()
lockfn = NoLockManager self.now_dt = now()
if self._require_locking():
lockfn = self.event.lock
with lockfn() as now_dt: self._extend_expiry_of_valid_existing_positions()
with transaction.atomic(): err = self._perform_operations() or err
self.now_dt = now_dt self.recompute_final_prices_and_taxes()
self._extend_expiry_of_valid_existing_positions()
err = self._perform_operations() or err if err:
self.recompute_final_prices_and_taxes() raise CartError(err)
if err:
raise CartError(err)
def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None): def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
+109 -21
View File
@@ -87,7 +87,7 @@ def _build_time(t=None, value=None, ev=None, now_dt=None):
def _logic_annotate_for_graphic_explain(rules, ev, rule_data, now_dt): def _logic_annotate_for_graphic_explain(rules, ev, rule_data, now_dt):
logic_environment = _get_logic_environment(ev, now_dt) logic_environment = _get_logic_environment(ev, rule_data, now_dt)
event = ev if isinstance(ev, Event) else ev.event event = ev if isinstance(ev, Event) else ev.event
def _evaluate_inners(r): def _evaluate_inners(r):
@@ -112,6 +112,8 @@ def _logic_annotate_for_graphic_explain(rules, ev, rule_data, now_dt):
val = str(event.items.get(pk=val)) val = str(event.items.get(pk=val))
elif var == "variation": elif var == "variation":
val = str(ItemVariation.objects.get(item__event=event, pk=val)) val = str(ItemVariation.objects.get(item__event=event, pk=val))
elif var == "gate":
val = str(event.organizer.gates.get(pk=val))
elif isinstance(val, datetime): elif isinstance(val, datetime):
val = date_format(val.astimezone(ev.timezone), "SHORT_DATETIME_FORMAT") val = date_format(val.astimezone(ev.timezone), "SHORT_DATETIME_FORMAT")
return {"var": var, "__result": val} return {"var": var, "__result": val}
@@ -152,7 +154,7 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
get in before 17:00". In the middle of the night it would switch to "You can only get in after 09:00". get in before 17:00". In the middle of the night it would switch to "You can only get in after 09:00".
""" """
now_dt = now_dt or now() now_dt = now_dt or now()
logic_environment = _get_logic_environment(ev, now_dt) logic_environment = _get_logic_environment(ev, rule_data, now_dt)
_var_values = {'False': False, 'True': True} _var_values = {'False': False, 'True': True}
_var_explanations = {} _var_explanations = {}
@@ -174,15 +176,22 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
_var_values[new_var_name] = result _var_values[new_var_name] = result
if not result: if not result:
# Operator returned false, let's dig deeper # Operator returned false, let's dig deeper
if "var" not in values[0]: if "var" in values[0]:
if isinstance(values[0]["var"], list):
values[0]["var"] = values[0]["var"][0]
_var_explanations[new_var_name] = {
'operator': operator,
'var': values[0]["var"],
'rhs': values[1:],
}
elif "entries_since" in values[0] or "entries_before" in values[0]:
_var_explanations[new_var_name] = {
'operator': operator,
'var': values[0],
'rhs': values[1:],
}
else:
raise ValueError("Binary operators should be normalized to have a variable on their left-hand side") raise ValueError("Binary operators should be normalized to have a variable on their left-hand side")
if isinstance(values[0]["var"], list):
values[0]["var"] = values[0]["var"][0]
_var_explanations[new_var_name] = {
'operator': operator,
'var': values[0]["var"],
'rhs': values[1:],
}
return {'var': new_var_name} return {'var': new_var_name}
try: try:
rules = _evaluate_inners(rules) rules = _evaluate_inners(rules)
@@ -249,11 +258,17 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
elif var == 'product' or var == 'variation': elif var == 'product' or var == 'variation':
var_weights[vname] = (1000, 0) var_weights[vname] = (1000, 0)
var_texts[vname] = _('Ticket type not allowed') var_texts[vname] = _('Ticket type not allowed')
elif var in ('entries_number', 'entries_today', 'entries_days', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday'): elif var == 'gate':
var_weights[vname] = (500, 0)
var_texts[vname] = _('Wrong entrance gate')
elif var in ('entries_number', 'entries_today', 'entries_days', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday') \
or (isinstance(var, dict) and ("entries_since" in var or "entries_before" in var)):
w = { w = {
'minutes_since_first_entry': 80, 'minutes_since_first_entry': 80,
'minutes_since_last_entry': 90, 'minutes_since_last_entry': 90,
'entries_days': 100, 'entries_days': 100,
'entries_since': 110,
'entries_before': 110,
'entries_number': 120, 'entries_number': 120,
'entries_today': 140, 'entries_today': 140,
'now_isoweekday': 210, 'now_isoweekday': 210,
@@ -272,8 +287,24 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
'entries_days': _('number of days with an entry'), 'entries_days': _('number of days with an entry'),
'entries_number': _('number of entries'), 'entries_number': _('number of entries'),
'entries_today': _('number of entries today'), 'entries_today': _('number of entries today'),
'entries_since': _('number of entries since {datetime}'),
'entries_before': _('number of entries before {datetime}'),
'now_isoweekday': _('week day'), 'now_isoweekday': _('week day'),
} }
if isinstance(var, dict) and ("entries_since" in var or "entries_before" in var):
varname = list(var.keys())[0]
cutoff = _build_time(*var[varname][0]['buildTime'], ev=ev, now_dt=now_dt).astimezone(ev.timezone)
if abs(now_dt - cutoff) < timedelta(hours=12):
compare_to_text = date_format(cutoff, 'TIME_FORMAT')
else:
compare_to_text = date_format(cutoff, 'SHORT_DATETIME_FORMAT')
l[varname] = str(l[varname].format(datetime=compare_to_text))
var = varname
var_result = getattr(rule_data, var)(cutoff)
else:
var_result = rule_data[var]
compare_to = rhs[0] compare_to = rhs[0]
penalty = 0 penalty = 0
@@ -290,7 +321,7 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
# These are "technical" comparisons without real meaning, we don't want to show them. # These are "technical" comparisons without real meaning, we don't want to show them.
penalty = 1000 penalty = 1000
var_weights[vname] = (w[var] + operator_weights.get(operator, 0) + penalty, abs(compare_to - rule_data[var])) var_weights[vname] = (w[var] + operator_weights.get(operator, 0) + penalty, abs(compare_to - var_result))
if var == 'now_isoweekday': if var == 'now_isoweekday':
compare_to = { compare_to = {
@@ -337,12 +368,14 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
return ', '.join(var_texts[v] for v in paths_with_min_weight[0] if not _var_values[v]) return ', '.join(var_texts[v] for v in paths_with_min_weight[0] if not _var_values[v])
def _get_logic_environment(ev, now_dt): def _get_logic_environment(ev, rule_data, now_dt):
# Every change to our supported JSON logic must be done # Every change to our supported JSON logic must be done
# * in pretix.base.services.checkin # * in pretix.base.services.checkin
# * in pretix.base.models.checkin # * in pretix.base.models.checkin
# * in pretix.helpers.jsonlogic_boolalg
# * in checkinrules.js # * in checkinrules.js
# * in libpretixsync # * in libpretixsync
# * in pretixscan-ios
def is_before(t1, t2, tolerance=None): def is_before(t1, t2, tolerance=None):
if tolerance: if tolerance:
@@ -357,14 +390,18 @@ def _get_logic_environment(ev, now_dt):
logic.add_operation('buildTime', partial(_build_time, ev=ev, now_dt=now_dt)) logic.add_operation('buildTime', partial(_build_time, ev=ev, now_dt=now_dt))
logic.add_operation('isBefore', is_before) logic.add_operation('isBefore', is_before)
logic.add_operation('isAfter', lambda t1, t2, tol=None: is_before(t2, t1, tol)) logic.add_operation('isAfter', lambda t1, t2, tol=None: is_before(t2, t1, tol))
logic.add_operation('entries_since', lambda t1: rule_data.entries_since(t1))
logic.add_operation('entries_before', lambda t1: rule_data.entries_before(t1))
return logic return logic
class LazyRuleVars: class LazyRuleVars:
def __init__(self, position, clist, dt): def __init__(self, position, clist, dt, gate):
self._position = position self._position = position
self._clist = clist self._clist = clist
self._dt = dt self._dt = dt
self._gate = gate
self.__cache = {}
def __getitem__(self, item): def __getitem__(self, item):
if item[0] != '_' and hasattr(self, item): if item[0] != '_' and hasattr(self, item):
@@ -380,6 +417,10 @@ class LazyRuleVars:
tz = self._clist.event.timezone tz = self._clist.event.timezone
return self._dt.astimezone(tz).isoweekday() return self._dt.astimezone(tz).isoweekday()
@property
def gate(self):
return self._gate.pk if self._gate else None
@property @property
def product(self): def product(self):
return self._position.item_id return self._position.item_id
@@ -398,6 +439,16 @@ class LazyRuleVars:
midnight = self._dt.astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0) midnight = self._dt.astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
return self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__gte=midnight).count() return self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__gte=midnight).count()
def entries_since(self, cutoff):
if ('entries_since', cutoff) not in self.__cache:
self.__cache['entries_since', cutoff] = self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__gte=cutoff).count()
return self.__cache['entries_since', cutoff]
def entries_before(self, cutoff):
if ('entries_before', cutoff) not in self.__cache:
self.__cache['entries_before', cutoff] = self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__lt=cutoff).count()
return self.__cache['entries_before', cutoff]
@cached_property @cached_property
def entries_days(self): def entries_days(self):
tz = self._clist.event.timezone tz = self._clist.event.timezone
@@ -446,8 +497,9 @@ class SQLLogic:
* Comparison operators (==, !=, ) never contain boolean operators (and, or) further down in the stack * Comparison operators (==, !=, ) never contain boolean operators (and, or) further down in the stack
""" """
def __init__(self, list): def __init__(self, list, gate=None):
self.list = list self.list = list
self.gate = gate
self.bool_ops = { self.bool_ops = {
"and": lambda *args: reduce(lambda total, arg: total & arg, args) if args else Q(), "and": lambda *args: reduce(lambda total, arg: total & arg, args) if args else Q(),
"or": lambda *args: reduce(lambda total, arg: total | arg, args) if args else Q(), "or": lambda *args: reduce(lambda total, arg: total | arg, args) if args else Q(),
@@ -463,7 +515,7 @@ class SQLLogic:
"isBefore": partial(self.comparison_to_q, operator=LowerThan, modifier=partial(tolerance, sign=1)), "isBefore": partial(self.comparison_to_q, operator=LowerThan, modifier=partial(tolerance, sign=1)),
"isAfter": partial(self.comparison_to_q, operator=GreaterThan, modifier=partial(tolerance, sign=-1)), "isAfter": partial(self.comparison_to_q, operator=GreaterThan, modifier=partial(tolerance, sign=-1)),
} }
self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var'} self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'}
def operation_to_expression(self, rule): def operation_to_expression(self, rule):
if not isinstance(rule, dict): if not isinstance(rule, dict):
@@ -511,6 +563,36 @@ class SQLLogic:
return [self.operation_to_expression(v) for v in values] return [self.operation_to_expression(v) for v in values]
elif operator == 'lookup': elif operator == 'lookup':
return int(values[1]) return int(values[1])
elif operator == 'entries_since':
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
datetime__gte=self.operation_to_expression(values[0]),
).values('position_id').order_by().annotate(
c=Count('*')
).values('c')
),
Value(0),
output_field=IntegerField()
)
elif operator == 'entries_before':
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
datetime__lt=self.operation_to_expression(values[0]),
).values('position_id').order_by().annotate(
c=Count('*')
).values('c')
),
Value(0),
output_field=IntegerField()
)
elif operator == 'var': elif operator == 'var':
if values[0] == 'now': if values[0] == 'now':
return Value(now().astimezone(timezone.utc)) return Value(now().astimezone(timezone.utc))
@@ -520,6 +602,8 @@ class SQLLogic:
return F('item_id') return F('item_id')
elif values[0] == 'variation': elif values[0] == 'variation':
return F('variation_id') return F('variation_id')
elif values[0] == 'gate':
return Value(self.gate.pk if self.gate else None)
elif values[0] == 'entries_number': elif values[0] == 'entries_number':
return Coalesce( return Coalesce(
Subquery( Subquery(
@@ -731,7 +815,8 @@ def _save_answers(op, answers, given_answers):
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False, def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False): raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False,
gate=None):
""" """
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
not valid at this time. not valid at this time.
@@ -746,6 +831,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
:param nonce: A random nonce to prevent race conditions. :param nonce: A random nonce to prevent race conditions.
:param datetime: The datetime of the checkin, defaults to now. :param datetime: The datetime of the checkin, defaults to now.
:param simulate: If true, the check-in is not saved. :param simulate: If true, the check-in is not saved.
:param gate: The gate the check-in was performed at.
""" """
# !!!!!!!!! # !!!!!!!!!
@@ -860,8 +946,8 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
) )
if type == Checkin.TYPE_ENTRY and clist.rules: if type == Checkin.TYPE_ENTRY and clist.rules:
rule_data = LazyRuleVars(op, clist, dt) rule_data = LazyRuleVars(op, clist, dt, gate=gate)
logic = _get_logic_environment(op.subevent or clist.event, now_dt=dt) logic = _get_logic_environment(op.subevent or clist.event, rule_data, now_dt=dt)
if not logic.apply(clist.rules, rule_data): if not logic.apply(clist.rules, rule_data):
if force: if force:
force_used = True force_used = True
@@ -885,8 +971,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
device = None device = None
if isinstance(auth, Device): if isinstance(auth, Device):
device = auth device = auth
if not gate:
gate = device.gate
last_cis = list(op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce')) last_cis = list(op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce', 'position_id'))
entry_allowed = ( entry_allowed = (
type == Checkin.TYPE_EXIT or type == Checkin.TYPE_EXIT or
clist.allow_multiple_entries or clist.allow_multiple_entries or
@@ -908,7 +996,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
list=clist, list=clist,
datetime=dt, datetime=dt,
device=device, device=device,
gate=device.gate if device else None, gate=gate,
nonce=nonce, nonce=nonce,
forced=force and (not entry_allowed or from_revoked_secret or force_used), forced=force and (not entry_allowed or from_revoked_secret or force_used),
force_sent=force, force_sent=force,
+92 -143
View File
@@ -20,31 +20,105 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import logging import logging
import time from itertools import groupby
import uuid
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import DatabaseError, connection
from django.utils.timezone import now from django.utils.timezone import now
from pretix.base.models import EventLock from pretix.base.models import Event, Membership, Quota, Seat, Voucher
from pretix.testutils.middleware import debugflags_var
logger = logging.getLogger('pretix.base.locking') logger = logging.getLogger('pretix.base.locking')
LOCK_TIMEOUT = 120
# A lock acquisition is aborted if it takes longer than LOCK_ACQUISITION_TIMEOUT to prevent connection starvation
LOCK_ACQUISITION_TIMEOUT = 3
# We make the assumption that it is safe to e.g. transform an order into a cart if the order has a lifetime of more than
# LOCK_TRUST_WINDOW into the future. In other words, we assume that a lock is never held longer than LOCK_TRUST_WINDOW.
# This assumption holds true for all in-request locks, since our gunicorn default settings kill a worker that takes
# longer than 60 seconds to process a request. It however does not hold true for celery tasks, especially long-running
# ones, so this does introduce *some* risk of incorrect locking.
LOCK_TRUST_WINDOW = 120
# These are different offsets for the different types of keys we want to lock
KEY_SPACES = {
Event: 1,
Quota: 2,
Seat: 3,
Voucher: 4,
Membership: 5
}
def pg_lock_key(obj):
"""
This maps the primary key space of multiple tables to a single bigint key space within postgres. It is not
an injective function, which is fine, as long as collisions are rare.
"""
keyspace = KEY_SPACES.get(type(obj))
objectid = obj.pk
if not keyspace:
raise ValueError(f"No key space defined for locking objects of type {type(obj)}")
assert isinstance(objectid, int)
# 64bit int: xxxxxxxx xxxxxxx xxxxxxx xxxxxxx xxxxxx xxxxxxx xxxxxxx xxxxxxx
# | objectid mod 2**48 | |index| |keysp.|
key = ((objectid % 281474976710656) << 16) | ((settings.DATABASE_ADVISORY_LOCK_INDEX % 256) << 8) | (keyspace % 256)
return key
class LockTimeoutException(Exception):
pass
def lock_objects(objects, *, shared_lock_objects=None, replace_exclusive_with_shared_when_exclusive_are_more_than=20):
"""
Create an exclusive lock on the objects passed in `objects`. This function MUST be called within an atomic
transaction and SHOULD be called only once per transaction to prevent deadlocks.
A shared lock will be created on objects passed in `shared_lock_objects`.
If `objects` contains more than `replace_exclusive_with_shared_when_exclusive_are_more_than` objects, `objects`
will be ignored and `shared_lock_objects` will be used in its place and receive an exclusive lock.
The idea behind it is this: Usually we create a lock on every quota, voucher, or seat contained in an order.
However, this has a large performance penalty in case we have hundreds of locks required. Therefore, we always
place a shared lock in the event, and if we have too many affected objects, we fall back to event-level locks.
"""
if (not objects and not shared_lock_objects) or 'skip-locking' in debugflags_var.get():
return
if 'fail-locking' in debugflags_var.get():
raise LockTimeoutException()
if not connection.in_atomic_block:
raise RuntimeError(
"You cannot create locks outside of an transaction"
)
if 'postgresql' in settings.DATABASES['default']['ENGINE']:
shared_keys = set(pg_lock_key(obj) for obj in shared_lock_objects) if shared_lock_objects else set()
exclusive_keys = set(pg_lock_key(obj) for obj in objects)
if replace_exclusive_with_shared_when_exclusive_are_more_than and len(exclusive_keys) > replace_exclusive_with_shared_when_exclusive_are_more_than:
exclusive_keys = shared_keys
keys = sorted(list(shared_keys | exclusive_keys))
calls = ", ".join([
(f"pg_advisory_xact_lock({k})" if k in exclusive_keys else f"pg_advisory_xact_lock_shared({k})") for k in keys
])
try:
with connection.cursor() as cursor:
cursor.execute(f"SET LOCAL lock_timeout = '{LOCK_ACQUISITION_TIMEOUT}s';")
cursor.execute(f"SELECT {calls};")
cursor.execute("SET LOCAL lock_timeout = '0';") # back to default
except DatabaseError as e:
logger.warning(f"Waiting for locks timed out: {e} on SELECT {calls};")
raise LockTimeoutException()
else:
for model, instances in groupby(objects, key=lambda o: type(o)):
model.objects.select_for_update().filter(pk__in=[o.pk for o in instances])
class NoLockManager: class NoLockManager:
@@ -57,128 +131,3 @@ class NoLockManager:
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None: if exc_type is not None:
return False return False
class LockManager:
def __init__(self, event):
self.event = event
def __enter__(self):
lock_event(self.event)
return now()
def __exit__(self, exc_type, exc_val, exc_tb):
release_event(self.event)
if exc_type is not None:
return False
class LockTimeoutException(Exception):
pass
class LockReleaseException(LockTimeoutException):
pass
def lock_event(event):
"""
Issue a lock on this event so nobody can book tickets for this event until
you release the lock. Will retry 5 times on failure.
:raises LockTimeoutException: if the event is locked every time we try
to obtain the lock
"""
if hasattr(event, '_lock') and event._lock:
return True
if settings.HAS_REDIS:
return lock_event_redis(event)
else:
return lock_event_db(event)
def release_event(event):
"""
Release a lock placed by :py:meth:`lock()`. If the parameter force is not set to ``True``,
the lock will only be released if it was issued in _this_ python
representation of the database object.
:raises LockReleaseException: if we do not own the lock
"""
if not hasattr(event, '_lock') or not event._lock:
raise LockReleaseException('Lock is not owned by this thread')
if settings.HAS_REDIS:
return release_event_redis(event)
else:
return release_event_db(event)
def lock_event_db(event):
retries = 5
for i in range(retries):
with transaction.atomic():
dt = now()
l, created = EventLock.objects.get_or_create(event=event.id)
if created:
event._lock = l
return True
elif l.date < now() - timedelta(seconds=LOCK_TIMEOUT):
newtoken = str(uuid.uuid4())
updated = EventLock.objects.filter(event=event.id, token=l.token).update(date=dt, token=newtoken)
if updated:
l.token = newtoken
event._lock = l
return True
time.sleep(2 ** i / 100)
raise LockTimeoutException()
@transaction.atomic
def release_event_db(event):
if not hasattr(event, '_lock') or not event._lock:
raise LockReleaseException('Lock is not owned by this thread')
try:
lock = EventLock.objects.get(event=event.id, token=event._lock.token)
lock.delete()
event._lock = None
except EventLock.DoesNotExist:
raise LockReleaseException('Lock is no longer owned by this thread')
def redis_lock_from_event(event):
from django_redis import get_redis_connection
from redis.lock import Lock
if not hasattr(event, '_lock') or not event._lock:
rc = get_redis_connection("redis")
event._lock = Lock(redis=rc, name='pretix_event_%s' % event.id, timeout=LOCK_TIMEOUT)
return event._lock
def lock_event_redis(event):
from redis.exceptions import RedisError
lock = redis_lock_from_event(event)
retries = 5
for i in range(retries):
try:
if lock.acquire(blocking=False):
return True
except RedisError:
logger.exception('Error locking an event')
raise LockTimeoutException()
time.sleep(2 ** i / 100)
raise LockTimeoutException()
def release_event_redis(event):
from redis import RedisError
lock = redis_lock_from_event(event)
try:
lock.release()
except RedisError:
logger.exception('Error releasing an event lock')
raise LockReleaseException()
event._lock = None
+11 -8
View File
@@ -36,7 +36,7 @@ from pretix.base.models import (
from pretix.base.models.orders import Transaction from pretix.base.models.orders import Transaction
from pretix.base.orderimport import get_all_columns from pretix.base.orderimport import get_all_columns
from pretix.base.services.invoices import generate_invoice, invoice_qualified from pretix.base.services.invoices import generate_invoice, invoice_qualified
from pretix.base.services.locking import NoLockManager from pretix.base.services.locking import lock_objects
from pretix.base.services.tasks import ProfiledEventTask from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.signals import order_paid, order_placed from pretix.base.signals import order_paid, order_placed
from pretix.celery_app import app from pretix.celery_app import app
@@ -88,7 +88,6 @@ def setif(record, obj, attr, setting):
def import_orders(event: Event, fileid: str, settings: dict, locale: str, user) -> None: def import_orders(event: Event, fileid: str, settings: dict, locale: str, user) -> None:
cf = CachedFile.objects.get(id=fileid) cf = CachedFile.objects.get(id=fileid)
user = User.objects.get(pk=user) user = User.objects.get(pk=user)
seats_used = False
with language(locale, event.settings.region): with language(locale, event.settings.region):
cols = get_all_columns(event) cols = get_all_columns(event)
parsed = parse_csv(cf.file) parsed = parse_csv(cf.file)
@@ -118,6 +117,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction # Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
# shorter. We'll see what works better in reality… # shorter. We'll see what works better in reality…
lock_seats = []
for i, record in enumerate(data): for i, record in enumerate(data):
try: try:
if order is None or settings['orders'] == 'many': if order is None or settings['orders'] == 'many':
@@ -135,7 +135,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
position.attendee_name_parts = {'_scheme': event.settings.name_scheme} position.attendee_name_parts = {'_scheme': event.settings.name_scheme}
position.meta_info = {} position.meta_info = {}
if position.seat is not None: if position.seat is not None:
seats_used = True lock_seats.append(position.seat)
order._positions.append(position) order._positions.append(position)
position.assign_pseudonymization_id() position.assign_pseudonymization_id()
@@ -147,12 +147,15 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
_('Invalid data in row {row}: {message}').format(row=i, message=str(e)) _('Invalid data in row {row}: {message}').format(row=i, message=str(e))
) )
# We don't support vouchers, quotas, or memberships here, so we only need to lock if seats
# are in use
lockfn = event.lock if seats_used else NoLockManager
try: try:
with lockfn(), transaction.atomic(): with transaction.atomic():
# We don't support vouchers, quotas, or memberships here, so we only need to lock if seats are in use
if lock_seats:
lock_objects(lock_seats, shared_lock_objects=[event])
for s in lock_seats:
if not s.is_available():
raise ImportError(_('The seat you selected has already been taken. Please select a different seat.'))
save_transactions = [] save_transactions = []
for o in orders: for o in orders:
o.total = sum([c.price for c in o._positions]) # currently no support for fees o.total = sum([c.price for c in o._positions]) # currently no support for fees
+238 -186
View File
@@ -35,10 +35,13 @@
import json import json
import logging import logging
import operator
import sys import sys
from collections import Counter, defaultdict, namedtuple from collections import Counter, defaultdict, namedtuple
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from decimal import Decimal from decimal import Decimal
from functools import reduce
from time import sleep
from typing import List, Optional from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError from celery.exceptions import MaxRetriesExceededError
@@ -82,7 +85,9 @@ from pretix.base.services import tickets
from pretix.base.services.invoices import ( from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified, generate_cancellation, generate_invoice, invoice_qualified,
) )
from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.locking import (
LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects,
)
from pretix.base.services.mail import SendMailException from pretix.base.services.mail import SendMailException
from pretix.base.services.memberships import ( from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order, create_membership, validate_memberships_in_order,
@@ -102,6 +107,7 @@ from pretix.celery_app import app
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
from pretix.helpers.periodic import minimum_interval from pretix.helpers.periodic import minimum_interval
from pretix.testutils.middleware import debugflags_var
class OrderError(Exception): class OrderError(Exception):
@@ -209,9 +215,9 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
if order.status != Order.STATUS_CANCELED: if order.status != Order.STATUS_CANCELED:
raise OrderError(_('The order was not canceled.')) raise OrderError(_('The order was not canceled.'))
with order.event.lock() as now_dt: with transaction.atomic():
is_available = order._is_still_available(now_dt, count_waitinglist=False, check_voucher_usage=True, is_available = order._is_still_available(now(), count_waitinglist=False, check_voucher_usage=True,
check_memberships=True, force=force) check_memberships=True, lock=True, force=force)
if is_available is True: if is_available is True:
if order.payment_refund_sum >= order.total: if order.payment_refund_sum >= order.total:
order.status = Order.STATUS_PAID order.status = Order.STATUS_PAID
@@ -220,29 +226,28 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
order.cancellation_date = None order.cancellation_date = None
order.set_expires(now(), order.set_expires(now(),
order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()])) order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
with transaction.atomic(): order.save(update_fields=['expires', 'status', 'cancellation_date'])
order.save(update_fields=['expires', 'status', 'cancellation_date']) order.log_action(
order.log_action( 'pretix.event.order.reactivated',
'pretix.event.order.reactivated', user=user,
user=user, auth=auth,
auth=auth, data={
data={ 'expires': order.expires,
'expires': order.expires, }
} )
) for position in order.positions.all():
for position in order.positions.all(): if position.voucher:
if position.voucher: Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') + 1))
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') + 1))
for gc in position.issued_gift_cards.all(): for gc in position.issued_gift_cards.all():
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk) gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
gc.transactions.create(value=position.price, order=order, acceptor=order.event.organizer) gc.transactions.create(value=position.price, order=order, acceptor=order.event.organizer)
break break
for m in position.granted_memberships.all(): for m in position.granted_memberships.all():
m.canceled = False m.canceled = False
m.save() m.save()
order.create_transactions() order.create_transactions()
else: else:
raise OrderError(is_available) raise OrderError(is_available)
@@ -264,7 +269,6 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, valid_if_p
if new_date < now(): if new_date < now():
raise OrderError(_('The new expiry date needs to be in the future.')) raise OrderError(_('The new expiry date needs to be in the future.'))
@transaction.atomic
def change(was_expired=True): def change(was_expired=True):
old_date = order.expires old_date = order.expires
order.expires = new_date order.expires = new_date
@@ -302,11 +306,11 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, valid_if_p
generate_invoice(order) generate_invoice(order)
order.create_transactions() order.create_transactions()
if order.status == Order.STATUS_PENDING: with transaction.atomic():
change(was_expired=False) if order.status == Order.STATUS_PENDING:
else: change(was_expired=False)
with order.event.lock() as now_dt: else:
is_available = order._is_still_available(now_dt, count_waitinglist=False, force=force) is_available = order._is_still_available(now(), count_waitinglist=False, lock=True, force=force)
if is_available is True: if is_available is True:
change(was_expired=True) change(was_expired=True)
else: else:
@@ -334,9 +338,8 @@ def mark_order_expired(order, user=None, auth=None):
order = Order.objects.get(pk=order) order = Order.objects.get(pk=order)
if isinstance(user, int): if isinstance(user, int):
user = User.objects.get(pk=user) user = User.objects.get(pk=user)
with order.event.lock(): order.status = Order.STATUS_EXPIRED
order.status = Order.STATUS_EXPIRED order.save(update_fields=['status'])
order.save(update_fields=['status'])
order.log_action('pretix.event.order.expired', user=user, auth=auth) order.log_action('pretix.event.order.expired', user=user, auth=auth)
i = order.invoices.filter(is_cancellation=False).last() i = order.invoices.filter(is_cancellation=False).last()
@@ -439,9 +442,8 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
if not order.require_approval or not order.status == Order.STATUS_PENDING: if not order.require_approval or not order.status == Order.STATUS_PENDING:
raise OrderError(_('This order is not pending approval.')) raise OrderError(_('This order is not pending approval.'))
with order.event.lock(): order.status = Order.STATUS_CANCELED
order.status = Order.STATUS_CANCELED order.save(update_fields=['status'])
order.save(update_fields=['status'])
order.log_action('pretix.event.order.denied', user=user, auth=auth, data={ order.log_action('pretix.event.order.denied', user=user, auth=auth, data={
'comment': comment 'comment': comment
@@ -521,51 +523,49 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
m.save() m.save()
if cancellation_fee: if cancellation_fee:
with order.event.lock(): for position in order.positions.all():
for position in order.positions.all(): if position.voucher:
if position.voucher: Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) position.canceled = True
position.canceled = True assign_ticket_secret(
assign_ticket_secret( event=order.event, position=position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
event=order.event, position=position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False )
) position.save(update_fields=['canceled', 'secret'])
position.save(update_fields=['canceled', 'secret']) new_fee = cancellation_fee
new_fee = cancellation_fee for fee in order.fees.all():
for fee in order.fees.all(): if keep_fees and fee in keep_fees:
if keep_fees and fee in keep_fees: new_fee -= fee.value
new_fee -= fee.value
else:
fee.canceled = True
fee.save(update_fields=['canceled'])
if new_fee:
f = OrderFee(
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
value=new_fee,
tax_rule=order.event.settings.tax_rate_default,
order=order,
)
f._calculate_tax()
f.save()
if cancellation_fee > order.total:
raise OrderError(_('The cancellation fee cannot be higher than the total amount of this order.'))
elif order.payment_refund_sum < cancellation_fee:
order.status = Order.STATUS_PENDING
order.set_expires()
else: else:
order.status = Order.STATUS_PAID fee.canceled = True
order.total = cancellation_fee fee.save(update_fields=['canceled'])
order.cancellation_date = now()
order.save(update_fields=['status', 'cancellation_date', 'total']) if new_fee:
f = OrderFee(
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
value=new_fee,
tax_rule=order.event.settings.tax_rate_default,
order=order,
)
f._calculate_tax()
f.save()
if cancellation_fee > order.total:
raise OrderError(_('The cancellation fee cannot be higher than the total amount of this order.'))
elif order.payment_refund_sum < cancellation_fee:
order.status = Order.STATUS_PENDING
order.set_expires()
else:
order.status = Order.STATUS_PAID
order.total = cancellation_fee
order.cancellation_date = now()
order.save(update_fields=['status', 'cancellation_date', 'total'])
if cancel_invoice and i: if cancel_invoice and i:
invoices.append(generate_invoice(order)) invoices.append(generate_invoice(order))
else: else:
with order.event.lock(): order.status = Order.STATUS_CANCELED
order.status = Order.STATUS_CANCELED order.cancellation_date = now()
order.cancellation_date = now() order.save(update_fields=['status', 'cancellation_date'])
order.save(update_fields=['status', 'cancellation_date'])
for position in order.positions.all(): for position in order.positions.all():
assign_ticket_secret( assign_ticket_secret(
@@ -666,6 +666,27 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled)) sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled))
for cp in sorted_positions:
cp._cached_quotas = list(cp.quotas)
# Create locks
if any(cp.expires < now() + timedelta(seconds=LOCK_TRUST_WINDOW) for cp in sorted_positions):
# No need to perform any locking if the cart positions still guarantee everything long enough.
full_lock_required = any(
getattr(o, 'seat', False) for o in sorted_positions
) and event.settings.seating_minimal_distance > 0
if full_lock_required:
# We lock the entire event in this case since we don't want to deal with fine-granular locking
# in the case of seating distance enforcement
lock_objects([event])
else:
lock_objects(
[q for q in reduce(operator.or_, (set(cp._cached_quotas) for cp in sorted_positions), set()) if q.size is not None] +
[op.voucher for op in sorted_positions if op.voucher] +
[op.seat for op in sorted_positions if op.seat],
shared_lock_objects=[event]
)
# Check availability # Check availability
for i, cp in enumerate(sorted_positions): for i, cp in enumerate(sorted_positions):
if cp.pk in deleted_positions: if cp.pk in deleted_positions:
@@ -675,7 +696,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
err = err or error_messages['unavailable'] err = err or error_messages['unavailable']
delete(cp) delete(cp)
continue continue
quotas = list(cp.quotas) quotas = cp._cached_quotas
products_seen[cp.item] += 1 products_seen[cp.item] += 1
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order: if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
@@ -689,6 +710,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
if cp.voucher: if cp.voucher:
v_usages[cp.voucher] += 1 v_usages[cp.voucher] += 1
if cp.voucher not in v_avail: if cp.voucher not in v_avail:
cp.voucher.refresh_from_db(fields=['redeemed'])
redeemed_in_carts = CartPosition.objects.filter( redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt) Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
).exclude(cart_id=cp.cart_id) ).exclude(cart_id=cp.cart_id)
@@ -927,91 +949,87 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
payments = [] payments = []
sales_channel = get_all_sales_channels()[sales_channel] sales_channel = get_all_sales_channels()[sales_channel]
with transaction.atomic(): try:
validate_memberships_in_order(customer, positions, event, lock=True, testmode=event.testmode)
except ValidationError as e:
raise OrderError(e.message)
try: require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
validate_memberships_in_order(customer, positions, event, lock=True, testmode=event.testmode) try:
except ValidationError as e: fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
raise OrderError(e.message) except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked'])
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
require_approval = any(p.requires_approval(invoice_address=address) for p in positions) order = Order(
status=Order.STATUS_PENDING,
event=event,
email=email,
phone=(meta_info or {}).get('contact_form_data', {}).get('phone'),
datetime=now_dt,
locale=get_language_without_region(locale),
total=total,
testmode=True if sales_channel.testmode_supported and event.testmode else False,
meta_info=json.dumps(meta_info or {}),
require_approval=require_approval,
sales_channel=sales_channel.identifier,
customer=customer,
valid_if_pending=valid_if_pending,
)
if customer:
order.email_known_to_work = customer.is_verified
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
order.save()
if address:
if address.order is not None:
address.pk = None
address.order = order
address.save()
for fee in fees:
fee.order = order
try: try:
fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval) fee._calculate_tax()
except TaxRule.SaleNotAllowed: except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked']) raise OrderError(error_messages['country_blocked'])
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees]) if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
fee.save()
order = Order( # Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
status=Order.STATUS_PENDING, # pressed "Confirm purchase"? If not, we should better warn the user and show the confirmation page again.
event=event, # We used to have a *known* case where this happened is if a gift card is used in two concurrent sessions,
email=email, # but this is now a payment error instead. So currently this code branch is usually only triggered by bugs
phone=(meta_info or {}).get('contact_form_data', {}).get('phone'), # in other places (e.g. tax calculation).
datetime=now_dt, if shown_total is not None:
locale=get_language_without_region(locale), if Decimal(shown_total) != pending_sum:
total=total, raise OrderError(
testmode=True if sales_channel.testmode_supported and event.testmode else False, _('While trying to place your order, we noticed that the order total has changed. Either one of '
meta_info=json.dumps(meta_info or {}), 'the prices changed just now, or a gift card you used has been used in the meantime. Please '
require_approval=require_approval, 'check the prices below and try again.')
sales_channel=sales_channel.identifier, )
customer=customer,
valid_if_pending=valid_if_pending,
)
if customer:
order.email_known_to_work = customer.is_verified
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
order.save()
if address: if payment_requests and not order.require_approval:
if address.order is not None: for p in payment_requests:
address.pk = None if not p.get('multi_use_supported') or p['payment_amount'] > Decimal('0.00'):
address.order = order payments.append(order.payments.create(
address.save() state=OrderPayment.PAYMENT_STATE_CREATED,
provider=p['provider'],
amount=p['payment_amount'],
fee=p.get('fee'),
info=json.dumps(p['info_data']),
process_initiated=False,
))
order.save() orderpositions = OrderPosition.transform_cart_positions(positions, order)
order.create_transactions(positions=orderpositions, fees=fees, is_new=True)
for fee in fees: order.log_action('pretix.event.order.placed')
fee.order = order if order.require_approval:
try: order.log_action('pretix.event.order.placed.require_approval')
fee._calculate_tax() if meta_info:
except TaxRule.SaleNotAllowed: for msg in meta_info.get('confirm_messages', []):
raise OrderError(error_messages['country_blocked']) order.log_action('pretix.event.order.consent', data={'msg': msg})
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
fee.save()
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
# pressed "Confirm purchase"? If not, we should better warn the user and show the confirmation page again.
# We used to have a *known* case where this happened is if a gift card is used in two concurrent sessions,
# but this is now a payment error instead. So currently this code branch is usually only triggered by bugs
# in other places (e.g. tax calculation).
if shown_total is not None:
if Decimal(shown_total) != pending_sum:
raise OrderError(
_('While trying to place your order, we noticed that the order total has changed. Either one of '
'the prices changed just now, or a gift card you used has been used in the meantime. Please '
'check the prices below and try again.')
)
if payment_requests and not order.require_approval:
for p in payment_requests:
if not p.get('multi_use_supported') or p['payment_amount'] > Decimal('0.00'):
payments.append(order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider=p['provider'],
amount=p['payment_amount'],
fee=p.get('fee'),
info=json.dumps(p['info_data']),
process_initiated=False,
))
orderpositions = OrderPosition.transform_cart_positions(positions, order)
order.create_transactions(positions=orderpositions, fees=fees, is_new=True)
order.log_action('pretix.event.order.placed')
if order.require_approval:
order.log_action('pretix.event.order.placed.require_approval')
if meta_info:
for msg in meta_info.get('confirm_messages', []):
order.log_action('pretix.event.order.consent', data={'msg': msg})
order_placed.send(event, order=order) order_placed.send(event, order=order)
return order, payments return order, payments
@@ -1116,18 +1134,12 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
if result: if result:
valid_if_pending = True valid_if_pending = True
lockfn = NoLockManager
locked = False
if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2)) | Q(seat__isnull=False)).exists():
# Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date,
# creating this order shouldn't be prone to any race conditions and we don't need to lock the event.
locked = True
lockfn = event.lock
warnings = [] warnings = []
any_payment_failed = False any_payment_failed = False
with lockfn() as now_dt: now_dt = now()
err_out = None
with transaction.atomic(durable=True):
positions = list( positions = list(
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons') positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
) )
@@ -1136,16 +1148,28 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
raise OrderError(error_messages['empty']) raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions): if len(position_ids) != len(positions):
raise OrderError(error_messages['internal']) raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel, customer=customer)
order, payment_objs = _create_order(event, email, positions, now_dt, payment_requests,
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
shown_total=shown_total, customer=customer, valid_if_pending=valid_if_pending)
try: try:
for p in payment_objs: _check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel, customer=customer)
if p.provider == 'free': except OrderError as e:
p.confirm(send_mail=False, lock=not locked, generate_invoice=False) err_out = e # Don't raise directly to make sure transaction is committed, as it might have deleted things
except Quota.QuotaExceededException: else:
pass if 'sleep-after-quota-check' in debugflags_var.get():
sleep(2)
order, payment_objs = _create_order(event, email, positions, now_dt, payment_requests,
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
shown_total=shown_total, customer=customer, valid_if_pending=valid_if_pending)
try:
for p in payment_objs:
if p.provider == 'free':
# Passing lock=False is safe here because it's absolutely impossible for the order to be expired
# here before it is even committed.
p.confirm(send_mail=False, lock=False, generate_invoice=False)
except Quota.QuotaExceededException:
pass
if err_out:
raise err_out
# We give special treatment to GiftCardPayment here because our invoice renderer expects gift cards to already be # We give special treatment to GiftCardPayment here because our invoice renderer expects gift cards to already be
# processed, and because we historically treat gift card orders like free orders with regards to email texts. # processed, and because we historically treat gift card orders like free orders with regards to email texts.
@@ -1289,9 +1313,19 @@ def send_expiry_warnings(sender, **kwargs):
event_id = None event_id = None
for o in Order.objects.filter( for o in Order.objects.filter(
expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING, expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING,
datetime__lte=now() - timedelta(hours=2), require_approval=False datetime__lte=now() - timedelta(hours=2), require_approval=False
).only('pk', 'event_id', 'expires').order_by('event_id'): ).only('pk', 'event_id', 'expires').order_by('event_id'):
lp = o.payments.last()
if (
lp and
lp.state in [OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING] and
lp.payment_provider and
lp.payment_provider.prevent_reminder_mail(o, lp)
):
continue
if event_id != o.event_id: if event_id != o.event_id:
settings = o.event.settings settings = o.event.settings
days = cache.get_or_set('{}:{}:setting_mail_days_order_expire_warning'.format('event', o.event_id), days = cache.get_or_set('{}:{}:setting_mail_days_order_expire_warning'.format('event', o.event_id),
@@ -2476,6 +2510,11 @@ class OrderChangeManager:
split_order.status = Order.STATUS_PAID split_order.status = Order.STATUS_PAID
else: else:
split_order.status = Order.STATUS_PENDING split_order.status = Order.STATUS_PENDING
if self.order.status == Order.STATUS_PAID:
split_order.set_expires(
now(),
list(set(p.subevent_id for p in split_positions))
)
split_order.save() split_order.save()
if offset_amount > Decimal('0.00'): if offset_amount > Decimal('0.00'):
@@ -2643,6 +2682,19 @@ class OrderChangeManager:
except ValidationError as e: except ValidationError as e:
raise OrderError(e.message) raise OrderError(e.message)
def _create_locks(self):
full_lock_required = any(diff > 0 for diff in self._seatdiff.values()) and self.event.settings.seating_minimal_distance > 0
if full_lock_required:
# We lock the entire event in this case since we don't want to deal with fine-granular locking
# in the case of seating distance enforcement
lock_objects([self.event])
else:
lock_objects(
[q for q, d in self._quotadiff.items() if q.size is not None and d > 0] +
[s for s, d in self._seatdiff.items() if d > 0],
shared_lock_objects=[self.event]
)
def commit(self, check_quotas=True): def commit(self, check_quotas=True):
if self._committed: if self._committed:
# an order change can only be committed once # an order change can only be committed once
@@ -2662,17 +2714,17 @@ class OrderChangeManager:
self._payment_fee_diff() self._payment_fee_diff()
with transaction.atomic(): with transaction.atomic():
with self.order.event.lock(): if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID): if check_quotas:
if check_quotas: self._check_quotas()
self._check_quotas() self._check_seats()
self._check_seats() self._create_locks()
self._check_complete_cancel() self._check_complete_cancel()
self._check_and_lock_memberships() self._check_and_lock_memberships()
try: try:
self._perform_operations() self._perform_operations()
except TaxRule.SaleNotAllowed: except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked']) raise OrderError(self.error_messages['tax_rule_country_blocked'])
self._recalculate_total_and_payment_fee() self._recalculate_total_and_payment_fee()
self._check_paid_price_change() self._check_paid_price_change()
self._check_paid_to_free() self._check_paid_to_free()
+1 -1
View File
@@ -171,7 +171,7 @@ def apply_discounts(event: Event, sales_channel: str,
Q(available_until__isnull=True) | Q(available_until__gte=now()), Q(available_until__isnull=True) | Q(available_until__gte=now()),
sales_channels__contains=sales_channel, sales_channels__contains=sales_channel,
active=True, active=True,
).prefetch_related('condition_limit_products').order_by('position', 'pk') ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs: for discount in discount_qs:
result = discount.apply({ result = discount.apply({
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
+15 -12
View File
@@ -24,13 +24,13 @@ import time
from collections import Counter, defaultdict from collections import Counter, defaultdict
from itertools import zip_longest from itertools import zip_longest
import django_redis
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db.models import ( from django.db.models import (
Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, Value, When, Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, Value, When,
) )
from django.utils.timezone import now from django.utils.timezone import now
from django_redis import get_redis_connection
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Checkin, Order, OrderPosition, Quota, Voucher, CartPosition, Checkin, Order, OrderPosition, Quota, Voucher,
@@ -102,6 +102,12 @@ class QuotaAvailability:
self.count_waitinglist = defaultdict(int) self.count_waitinglist = defaultdict(int)
self.count_cart = defaultdict(int) self.count_cart = defaultdict(int)
self._cache_key_suffix = ""
if not self._count_waitinglist:
self._cache_key_suffix += ":nocw"
if self._ignore_closed:
self._cache_key_suffix += ":igcl"
self.sizes = {} self.sizes = {}
def queue(self, *quota): def queue(self, *quota):
@@ -121,17 +127,14 @@ class QuotaAvailability:
if self._full_results: if self._full_results:
raise ValueError("You cannot combine full_results and allow_cache.") raise ValueError("You cannot combine full_results and allow_cache.")
elif not self._count_waitinglist:
raise ValueError("If you set allow_cache, you need to set count_waitinglist.")
elif settings.HAS_REDIS: elif settings.HAS_REDIS:
rc = get_redis_connection("redis") rc = django_redis.get_redis_connection("redis")
quotas_by_event = defaultdict(list) quotas_by_event = defaultdict(list)
for q in [_q for _q in self._queue if _q.id in quota_ids_set]: for q in [_q for _q in self._queue if _q.id in quota_ids_set]:
quotas_by_event[q.event_id].append(q) quotas_by_event[q.event_id].append(q)
for eventid, evquotas in quotas_by_event.items(): for eventid, evquotas in quotas_by_event.items():
d = rc.hmget(f'quotas:{eventid}:availabilitycache', [str(q.pk) for q in evquotas]) d = rc.hmget(f'quotas:{eventid}:availabilitycache{self._cache_key_suffix}', [str(q.pk) for q in evquotas])
for redisval, q in zip(d, evquotas): for redisval, q in zip(d, evquotas):
if redisval is not None: if redisval is not None:
data = [rv for rv in redisval.decode().split(',')] data = [rv for rv in redisval.decode().split(',')]
@@ -164,12 +167,12 @@ class QuotaAvailability:
if not settings.HAS_REDIS or not quotas: if not settings.HAS_REDIS or not quotas:
return return
rc = get_redis_connection("redis") rc = django_redis.get_redis_connection("redis")
# We write the computed availability to redis in a per-event hash as # We write the computed availability to redis in a per-event hash as
# #
# quota_id -> (availability_state, availability_number, timestamp). # quota_id -> (availability_state, availability_number, timestamp).
# #
# We store this in a hash instead of inidividual values to avoid making two many redis requests # We store this in a hash instead of individual values to avoid making too many redis requests
# which would introduce latency. # which would introduce latency.
# The individual entries in the hash are "valid" for 120 seconds. This means in a typical peak scenario with # The individual entries in the hash are "valid" for 120 seconds. This means in a typical peak scenario with
@@ -179,16 +182,16 @@ class QuotaAvailability:
# these quotas. We choose 10 seconds since that should be well above the duration of a write. # these quotas. We choose 10 seconds since that should be well above the duration of a write.
lock_name = '_'.join([str(p) for p in sorted([q.pk for q in quotas])]) lock_name = '_'.join([str(p) for p in sorted([q.pk for q in quotas])])
if rc.exists(f'quotas:availabilitycachewrite:{lock_name}'): if rc.exists(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}'):
return return
rc.setex(f'quotas:availabilitycachewrite:{lock_name}', '1', 10) rc.setex(f'quotas:availabilitycachewrite:{lock_name}{self._cache_key_suffix}', '1', 10)
update = defaultdict(list) update = defaultdict(list)
for q in quotas: for q in quotas:
update[q.event_id].append(q) update[q.event_id].append(q)
for eventid, quotas in update.items(): for eventid, quotas in update.items():
rc.hmset(f'quotas:{eventid}:availabilitycache', { rc.hset(f'quotas:{eventid}:availabilitycache{self._cache_key_suffix}', mapping={
str(q.id): ",".join( str(q.id): ",".join(
[str(i) for i in self.results[q]] + [str(i) for i in self.results[q]] +
[str(int(time.time()))] [str(int(time.time()))]
@@ -197,7 +200,7 @@ class QuotaAvailability:
# To make sure old events do not fill up our redis instance, we set an expiry on the cache. However, we set it # To make sure old events do not fill up our redis instance, we set an expiry on the cache. However, we set it
# on 7 days even though we mostly ignore values older than 2 monites. The reasoning is that we have some places # on 7 days even though we mostly ignore values older than 2 monites. The reasoning is that we have some places
# where we set allow_cache_stale and use the old entries anyways to save on performance. # where we set allow_cache_stale and use the old entries anyways to save on performance.
rc.expire(f'quotas:{eventid}:availabilitycache', 3600 * 24 * 7) rc.expire(f'quotas:{eventid}:availabilitycache{self._cache_key_suffix}', 3600 * 24 * 7)
# We used to also delete item_quota_cache:* from the event cache here, but as the cache # We used to also delete item_quota_cache:* from the event cache here, but as the cache
# gets more complex, this does not seem worth it. The cache is only present for up to # gets more complex, this does not seem worth it. The cache is only present for up to
+36 -10
View File
@@ -22,15 +22,19 @@
import sys import sys
from datetime import timedelta from datetime import timedelta
from django.db.models import Exists, F, OuterRef, Q, Sum from django.db import transaction
from django.db.models import (
Exists, F, OuterRef, Prefetch, Q, Sum, prefetch_related_objects,
)
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.models import ( from pretix.base.models import (
Event, SeatCategoryMapping, User, WaitingListEntry, Event, EventMetaValue, SeatCategoryMapping, User, WaitingListEntry,
) )
from pretix.base.models.waitinglist import WaitingListException from pretix.base.models.waitinglist import WaitingListException
from pretix.base.services.locking import lock_objects
from pretix.base.services.tasks import EventTask from pretix.base.services.tasks import EventTask
from pretix.base.signals import periodic_task from pretix.base.signals import periodic_task
from pretix.celery_app import app from pretix.celery_app import app
@@ -59,8 +63,21 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0 ).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0
seats_available[(m.product_id, m.subevent_id)] = num_free_seets_for_product - num_valid_vouchers_for_product seats_available[(m.product_id, m.subevent_id)] = num_free_seets_for_product - num_valid_vouchers_for_product
qs = WaitingListEntry.objects.filter( prefetch_related_objects(
event=event, voucher__isnull=True [event.organizer],
'meta_properties'
)
prefetch_related_objects(
[event],
Prefetch(
'meta_values',
EventMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
)
)
qs = event.waitinglistentries.filter(
voucher__isnull=True
).select_related('item', 'variation', 'subevent').prefetch_related( ).select_related('item', 'variation', 'subevent').prefetch_related(
'item__quotas', 'variation__quotas' 'item__quotas', 'variation__quotas'
).order_by('-priority', 'created') ).order_by('-priority', 'created')
@@ -71,11 +88,23 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
sent = 0 sent = 0
with event.lock(): with transaction.atomic(durable=True):
quotas_by_item = {}
quotas = set()
for wle in qs:
if (wle.item_id, wle.variation_id, wle.subevent_id) not in quotas_by_item:
quotas_by_item[wle.item_id, wle.variation_id, wle.subevent_id] = list(
wle.variation.quotas.filter(subevent=wle.subevent)
if wle.variation
else wle.item.quotas.filter(subevent=wle.subevent)
)
wle._quotas = quotas_by_item[wle.item_id, wle.variation_id, wle.subevent_id]
quotas |= set(wle._quotas)
lock_objects(quotas, shared_lock_objects=[event])
for wle in qs: for wle in qs:
if (wle.item, wle.variation, wle.subevent) in gone: if (wle.item, wle.variation, wle.subevent) in gone:
continue continue
ev = (wle.subevent or event) ev = (wle.subevent or event)
if not ev.presale_is_running or (wle.subevent and not wle.subevent.active): if not ev.presale_is_running or (wle.subevent and not wle.subevent.active):
continue continue
@@ -90,9 +119,6 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
gone.add((wle.item, wle.variation, wle.subevent)) gone.add((wle.item, wle.variation, wle.subevent))
continue continue
quotas = (wle.variation.quotas.filter(subevent=wle.subevent)
if wle.variation
else wle.item.quotas.filter(subevent=wle.subevent))
availability = ( availability = (
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache, subevent=wle.subevent) wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache, subevent=wle.subevent)
if wle.variation if wle.variation
@@ -106,7 +132,7 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
continue continue
# Reduce affected quotas in cache # Reduce affected quotas in cache
for q in quotas: for q in wle._quotas:
quota_cache[q.pk] = ( quota_cache[q.pk] = (
quota_cache[q.pk][0] if quota_cache[q.pk][0] > 1 else 0, quota_cache[q.pk][0] if quota_cache[q.pk][0] > 1 else 0,
quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize
+7 -7
View File
@@ -1033,10 +1033,10 @@ DEFAULTS = {
widget=forms.RadioSelect, widget=forms.RadioSelect,
choices=( choices=(
('False', _('Do not generate invoices')), ('False', _('Do not generate invoices')),
('admin', _('Only manually in admin panel')), ('paid', _('Automatically after payment or when required by payment method')),
('True', _('Automatically before payment for all created orders')),
('user', _('Automatically on user request')), ('user', _('Automatically on user request')),
('True', _('Automatically for all created orders')), ('admin', _('Only manually in admin panel')),
('paid', _('Automatically on payment or when required by payment method')),
), ),
help_text=_("Invoices will never be automatically generated for free orders.") help_text=_("Invoices will never be automatically generated for free orders.")
) )
@@ -2793,7 +2793,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': ExtFileField, 'form_class': ExtFileField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_('Header image'), label=_('Header image'),
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_IMAGE,
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE, max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
help_text=_('If you provide a logo image, we will by default not show your event name and date ' help_text=_('If you provide a logo image, we will by default not show your event name and date '
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You ' 'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
@@ -2836,7 +2836,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': ExtFileField, 'form_class': ExtFileField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_('Header image'), label=_('Header image'),
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_IMAGE,
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE, max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
help_text=_('If you provide a logo image, we will by default not show your organization name ' help_text=_('If you provide a logo image, we will by default not show your organization name '
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You ' 'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
@@ -2876,7 +2876,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': ExtFileField, 'form_class': ExtFileField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_('Social media image'), label=_('Social media image'),
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_IMAGE,
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE, max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. ' help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. '
'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like ' 'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like '
@@ -2897,7 +2897,7 @@ Your {organizer} team""")) # noqa: W291
'form_class': ExtFileField, 'form_class': ExtFileField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_('Logo image'), label=_('Logo image'),
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_IMAGE,
required=False, required=False,
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE, max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.') help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
+2
View File
@@ -210,6 +210,8 @@ def slow_delete(qs, batch_size=1000, sleep_time=.5, progress_callback=None, prog
break break
if total_deleted >= 0.8 * batch_size: if total_deleted >= 0.8 * batch_size:
time.sleep(sleep_time) time.sleep(sleep_time)
if progress_callback and progress_total:
progress_callback((progress_offset + total_deleted) / progress_total)
return total_deleted return total_deleted
+5 -1
View File
@@ -683,12 +683,16 @@ dictionaries as values that contain keys like in the following example::
"product": { "product": {
"label": _("Product name"), "label": _("Product name"),
"editor_sample": _("Sample product"), "editor_sample": _("Sample product"),
"evaluate": lambda orderposition, order, event: str(orderposition.item) "evaluate": lambda orderposition, order, event: str(orderposition.item),
"evaluate_bulk": lambda orderpositions: [str(op.item) for op in orderpositions],
} }
} }
The ``evaluate`` member will be called with the order position, order and event as arguments. The event might The ``evaluate`` member will be called with the order position, order and event as arguments. The event might
also be a subevent, if applicable. also be a subevent, if applicable.
The ``evaluate_bulk`` member is optional but can significantly improve performance in some situations because you
can perform database fetches in bulk instead of single queries for every position.
""" """
+3 -3
View File
@@ -292,7 +292,7 @@ class LinkifyAndCleanExtension(Extension):
) )
def markdown_compile_email(source): def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes=ALLOWED_ATTRIBUTES):
linker = bleach.Linker( linker = bleach.Linker(
url_re=URL_RE, url_re=URL_RE,
email_re=EMAIL_RE, email_re=EMAIL_RE,
@@ -306,8 +306,8 @@ def markdown_compile_email(source):
EmailNl2BrExtension(), EmailNl2BrExtension(),
LinkifyAndCleanExtension( LinkifyAndCleanExtension(
linker, linker,
tags=ALLOWED_TAGS, tags=allowed_tags,
attributes=ALLOWED_ATTRIBUTES, attributes=allowed_attributes,
protocols=ALLOWED_PROTOCOLS, protocols=ALLOWED_PROTOCOLS,
strip=False, strip=False,
) )
+2
View File
@@ -394,6 +394,8 @@ class SerializerDateFrameField(serializers.CharField):
resolve_timeframe_to_dates_inclusive(now(), data, timezone.utc) resolve_timeframe_to_dates_inclusive(now(), data, timezone.utc)
except: except:
raise ValidationError("Invalid date frame") raise ValidationError("Invalid date frame")
else:
return data
def to_representation(self, value): def to_representation(self, value):
if value is None: if value is None:
+5
View File
@@ -29,6 +29,7 @@ from celery.result import AsyncResult
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.http import HttpResponse, JsonResponse, QueryDict from django.http import HttpResponse, JsonResponse, QueryDict
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.test import RequestFactory from django.test import RequestFactory
@@ -217,6 +218,7 @@ class AsyncFormView(AsyncMixin, FormView):
known_errortypes = ['ValidationError'] known_errortypes = ['ValidationError']
expected_exceptions = (ValidationError,) expected_exceptions = (ValidationError,)
task_base = ProfiledEventTask task_base = ProfiledEventTask
atomic_execute = False
def async_set_progress(self, percentage): def async_set_progress(self, percentage):
if not self._task_self.request.called_directly: if not self._task_self.request.called_directly:
@@ -263,6 +265,9 @@ class AsyncFormView(AsyncMixin, FormView):
form.is_valid() form.is_valid()
return view_instance.async_form_valid(self, form) return view_instance.async_form_valid(self, form)
if cls.atomic_execute:
async_execute = transaction.atomic(async_execute)
cls.async_execute = app.task( cls.async_execute = app.task(
base=cls.task_base, base=cls.task_base,
bind=True, bind=True,
+39 -9
View File
@@ -44,6 +44,7 @@ from django.forms.utils import from_current_timezone
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.text import format_lazy
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes.forms import SafeModelMultipleChoiceField from django_scopes.forms import SafeModelMultipleChoiceField
@@ -51,6 +52,7 @@ from django_scopes.forms import SafeModelMultipleChoiceField
from pretix.helpers.hierarkey import clean_filename from pretix.helpers.hierarkey import clean_filename
from ...base.forms import I18nModelForm from ...base.forms import I18nModelForm
from ...helpers.i18n import get_language_score
from ...helpers.images import ( from ...helpers.images import (
IMAGE_EXTS, validate_uploaded_file_for_valid_image, IMAGE_EXTS, validate_uploaded_file_for_valid_image,
) )
@@ -127,7 +129,7 @@ class ClearableBasenameFileInput(forms.ClearableFileInput):
@property @property
def is_img(self): def is_img(self):
return any(self.file.name.lower().endswith(e) for e in ('.jpg', '.jpeg', '.png', '.gif')) return any(self.file.name.lower().endswith(e) for e in settings.FILE_UPLOAD_EXTENSIONS_IMAGE)
def __str__(self): def __str__(self):
if hasattr(self.file, 'display_name'): if hasattr(self.file, 'display_name'):
@@ -300,18 +302,44 @@ class SlugWidget(forms.TextInput):
class MultipleLanguagesWidget(forms.CheckboxSelectMultiple): class MultipleLanguagesWidget(forms.CheckboxSelectMultiple):
template_name = 'pretixcontrol/multi_languages_select.html'
option_template_name = 'pretixcontrol/multi_languages_widget.html' option_template_name = 'pretixcontrol/multi_languages_widget.html'
def sort(self): def sort(self):
self.choices = sorted(self.choices, key=lambda l: ( def filter_and_sort(choices, languages, cond=True):
return sorted(
[c for c in choices if (c[0] in languages) == cond],
key=lambda c: str(c[1])
)
self.choices = (
( (
0 if l[0] in settings.LANGUAGES_OFFICIAL '',
else ( filter_and_sort(self.choices, settings.LANGUAGES_OFFICIAL)
1 if l[0] not in settings.LANGUAGES_INCUBATING ),
else 2 (
) (
), str(l[1]) _('Community translations'),
)) format_lazy(
_('These translations are not maintained by the pretix team. We cannot vouch for their correctness '
'and new or recently changed features might not be translated and will show in English instead. '
'You can <a href="{translate_url}" target="_blank">help translating</a>.'),
translate_url='https://translate.pretix.eu'
),
'fa fa-group'
),
filter_and_sort(self.choices, settings.LANGUAGES_OFFICIAL.union(settings.LANGUAGES_INCUBATING), False)
),
(
(
_('Development only'),
_('These translations are still in progress. These languages can currently only be selected on development '
'installations of pretix, not in production.'),
'fa fa-flask text-danger'
),
filter_and_sort(self.choices, settings.LANGUAGES_INCUBATING)
)
)
self.choices = [c for c in self.choices if len(c[1])]
def options(self, name, value, attrs=None): def options(self, name, value, attrs=None):
self.sort() self.sort()
@@ -325,6 +353,8 @@ class MultipleLanguagesWidget(forms.CheckboxSelectMultiple):
opt = super().create_option(name, value, label, selected, index, subindex, attrs) opt = super().create_option(name, value, label, selected, index, subindex, attrs)
opt['official'] = value in settings.LANGUAGES_OFFICIAL opt['official'] = value in settings.LANGUAGES_OFFICIAL
opt['incubating'] = value in settings.LANGUAGES_INCUBATING opt['incubating'] = value in settings.LANGUAGES_INCUBATING
base_score = get_language_score("de")
opt['score'] = round(get_language_score(value) / base_score * 100)
return opt return opt
+24
View File
@@ -32,6 +32,7 @@ from django_scopes.forms import (
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Gate
from pretix.base.models.checkin import Checkin, CheckinList from pretix.base.models.checkin import Checkin, CheckinList
from pretix.control.forms import ItemMultipleChoiceField from pretix.control.forms import ItemMultipleChoiceField
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
@@ -201,3 +202,26 @@ class CheckinListSimulatorForm(forms.Form):
initial=True, initial=True,
required=False, required=False,
) )
gate = SafeModelChoiceField(
label=_('Gate'),
empty_label=_('All gates'),
queryset=Gate.objects.none(),
required=False
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.fields['gate'].queryset = self.event.organizer.gates.all()
self.fields['gate'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.gates.select2', kwargs={
'organizer': self.event.organizer.slug,
}),
'data-placeholder': _('All gates'),
}
)
self.fields['gate'].widget.choices = self.fields['gate'].choices
self.fields['gate'].label = _('Gate')
+10 -1
View File
@@ -50,11 +50,16 @@ class DiscountForm(I18nModelForm):
'condition_ignore_voucher_discounted', 'condition_ignore_voucher_discounted',
'benefit_discount_matching_percent', 'benefit_discount_matching_percent',
'benefit_only_apply_to_cheapest_n_matches', 'benefit_only_apply_to_cheapest_n_matches',
'benefit_same_products',
'benefit_limit_products',
'benefit_apply_to_addons',
'benefit_ignore_voucher_discounted',
] ]
field_classes = { field_classes = {
'available_from': SplitDateTimeField, 'available_from': SplitDateTimeField,
'available_until': SplitDateTimeField, 'available_until': SplitDateTimeField,
'condition_limit_products': ItemMultipleChoiceField, 'condition_limit_products': ItemMultipleChoiceField,
'benefit_limit_products': ItemMultipleChoiceField,
} }
widgets = { widgets = {
'subevent_mode': forms.RadioSelect, 'subevent_mode': forms.RadioSelect,
@@ -64,11 +69,14 @@ class DiscountForm(I18nModelForm):
'data-inverse-dependency': '<[name$=all_products]', 'data-inverse-dependency': '<[name$=all_products]',
'class': 'scrolling-multiple-choice', 'class': 'scrolling-multiple-choice',
}), }),
'benefit_limit_products': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice',
}),
'benefit_only_apply_to_cheapest_n_matches': forms.NumberInput( 'benefit_only_apply_to_cheapest_n_matches': forms.NumberInput(
attrs={ attrs={
'data-display-dependency': '#id_condition_min_count', 'data-display-dependency': '#id_condition_min_count',
} }
) ),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -85,6 +93,7 @@ class DiscountForm(I18nModelForm):
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
) )
self.fields['condition_limit_products'].queryset = self.event.items.all() self.fields['condition_limit_products'].queryset = self.event.items.all()
self.fields['benefit_limit_products'].queryset = self.event.items.all()
self.fields['condition_min_count'].required = False self.fields['condition_min_count'].required = False
self.fields['condition_min_count'].widget.is_required = False self.fields['condition_min_count'].widget.is_required = False
self.fields['condition_min_value'].required = False self.fields['condition_min_value'].required = False
+46 -16
View File
@@ -38,6 +38,7 @@ from decimal import Decimal
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pycountry
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
@@ -48,7 +49,7 @@ from django.forms import (
) )
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone_name from django.utils.timezone import get_current_timezone_name
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
@@ -61,11 +62,13 @@ from pytz import common_timezones
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channels
from pretix.base.email import get_available_placeholders from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.models import Event, Organizer, TaxRule, Team from pretix.base.models import Event, Organizer, TaxRule, Team
from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.settings import ( from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings, COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES,
PERSON_NAME_TITLE_GROUPS, validate_event_settings,
) )
from pretix.base.validators import multimail_validate from pretix.base.validators import multimail_validate
from pretix.control.forms import ( from pretix.control.forms import (
@@ -898,6 +901,27 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
) )
self.fields['invoice_numbers_counter_length'].validators.append(MaxValueValidator(15)) self.fields['invoice_numbers_counter_length'].validators.append(MaxValueValidator(15))
pps = [str(pp.verbose_name) for pp in event.get_payment_providers().values() if pp.requires_invoice_immediately]
if pps:
generate_paid_help_text = _('An invoice will be issued before payment if the customer selects one of the following payment methods: {list}').format(
list=', '.join(pps)
)
else:
generate_paid_help_text = _('None of the currently configured payment methods will cause an invoice to be issued before payment.')
generate_choices = list(DEFAULTS['invoice_generate']['form_kwargs']['choices'])
idx = [i for i, t in enumerate(generate_choices) if t[0] == 'paid'][0]
generate_choices[idx] = (
'paid',
format_html(
'{} <span class="label label-success">{}</span><br><span class="text-muted">{}</span>',
generate_choices[idx][1],
_('Recommended'),
generate_paid_help_text
)
)
self.fields['invoice_generate'].choices = generate_choices
def contains_web_channel_validate(val): def contains_web_channel_validate(val):
if "web" not in val: if "web" not in val:
@@ -1273,12 +1297,12 @@ class MailSettingsForm(SettingsForm):
'mail_subject_order_placed_require_approval': ['event', 'order'], 'mail_subject_order_placed_require_approval': ['event', 'order'],
'mail_text_order_approved': ['event', 'order'], 'mail_text_order_approved': ['event', 'order'],
'mail_subject_order_approved': ['event', 'order'], 'mail_subject_order_approved': ['event', 'order'],
'mail_text_order_approved_attendee': ['event', 'order'], 'mail_text_order_approved_attendee': ['event', 'order', 'position'],
'mail_subject_order_approved_attendee': ['event', 'order'], 'mail_subject_order_approved_attendee': ['event', 'order', 'position'],
'mail_text_order_approved_free': ['event', 'order'], 'mail_text_order_approved_free': ['event', 'order'],
'mail_subject_order_approved_free': ['event', 'order'], 'mail_subject_order_approved_free': ['event', 'order'],
'mail_text_order_approved_free_attendee': ['event', 'order'], 'mail_text_order_approved_free_attendee': ['event', 'order', 'position'],
'mail_subject_order_approved_free_attendee': ['event', 'order'], 'mail_subject_order_approved_free_attendee': ['event', 'order', 'position'],
'mail_text_order_denied': ['event', 'order', 'comment'], 'mail_text_order_denied': ['event', 'order', 'comment'],
'mail_subject_order_denied': ['event', 'order', 'comment'], 'mail_subject_order_denied': ['event', 'order', 'comment'],
'mail_text_order_paid': ['event', 'order', 'payment_info'], 'mail_text_order_paid': ['event', 'order', 'payment_info'],
@@ -1308,7 +1332,7 @@ class MailSettingsForm(SettingsForm):
'mail_subject_download_reminder_attendee': ['event', 'order', 'position'], 'mail_subject_download_reminder_attendee': ['event', 'order', 'position'],
'mail_text_resend_link': ['event', 'order'], 'mail_text_resend_link': ['event', 'order'],
'mail_subject_resend_link': ['event', 'order'], 'mail_subject_resend_link': ['event', 'order'],
'mail_subject_resend_link_attendee': ['event', 'order'], 'mail_subject_resend_link_attendee': ['event', 'order', 'position'],
'mail_text_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'], 'mail_text_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'],
'mail_subject_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'], 'mail_subject_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'],
'mail_text_resend_all_links': ['event', 'orders'], 'mail_text_resend_all_links': ['event', 'orders'],
@@ -1317,19 +1341,14 @@ class MailSettingsForm(SettingsForm):
} }
def _set_field_placeholders(self, fn, base_parameters): def _set_field_placeholders(self, fn, base_parameters):
phs = [ placeholders = get_available_placeholders(self.event, base_parameters)
'{%s}' % p ht = format_placeholders_help_text(placeholders, self.event)
for p in sorted(get_available_placeholders(self.event, base_parameters).keys())
]
ht = _('Available placeholders: {list}').format(
list=', '.join(phs)
)
if self.fields[fn].help_text: if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht) self.fields[fn].help_text += ' ' + str(ht)
else: else:
self.fields[fn].help_text = ht self.fields[fn].help_text = ht
self.fields[fn].validators.append( self.fields[fn].validators.append(
PlaceholderValidator(phs) PlaceholderValidator(['{%s}' % p for p in placeholders.keys()])
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -1428,9 +1447,20 @@ class CountriesAndEU(CachedCountries):
cache_subkey = 'with_any_or_eu' cache_subkey = 'with_any_or_eu'
class CountriesAndEUAndStates(CountriesAndEU):
def __iter__(self):
for country_code, country_name in super().__iter__():
yield country_code, country_name
if country_code in COUNTRIES_WITH_STATE_IN_ADDRESS:
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[country_code]
yield from sorted(((state.code, country_name + " - " + state.name)
for state in pycountry.subdivisions.get(country_code=country_code)
if state.type in types), key=lambda s: s[1])
class TaxRuleLineForm(I18nForm): class TaxRuleLineForm(I18nForm):
country = LazyTypedChoiceField( country = LazyTypedChoiceField(
choices=CountriesAndEU(), choices=CountriesAndEUAndStates(),
required=False required=False
) )
address_type = forms.ChoiceField( address_type = forms.ChoiceField(
+59 -4
View File
@@ -1732,8 +1732,8 @@ class CheckinListAttendeeFilterForm(FilterForm):
'-timestamp': (OrderBy(F('last_entry'), nulls_last=True, descending=True), '-order__code'), '-timestamp': (OrderBy(F('last_entry'), nulls_last=True, descending=True), '-order__code'),
'item': ('item__name', 'variation__value', 'order__code'), 'item': ('item__name', 'variation__value', 'order__code'),
'-item': ('-item__name', '-variation__value', '-order__code'), '-item': ('-item__name', '-variation__value', '-order__code'),
'seat': ('seat__sorting_rank', 'seat__guid'), 'seat': ('seat__sorting_rank', 'seat__seat_guid'),
'-seat': ('-seat__sorting_rank', '-seat__guid'), '-seat': ('-seat__sorting_rank', '-seat__seat_guid'),
'date': ('subevent__date_from', 'subevent__id', 'order__code'), 'date': ('subevent__date_from', 'subevent__id', 'order__code'),
'-date': ('-subevent__date_from', 'subevent__id', '-order__code'), '-date': ('-subevent__date_from', 'subevent__id', '-order__code'),
'name': {'_order': F('display_name').asc(nulls_first=True), 'name': {'_order': F('display_name').asc(nulls_first=True),
@@ -1940,7 +1940,7 @@ class VoucherFilterForm(FilterForm):
'item__category__position', 'item__category__position',
'item__category', 'item__category',
'item__position', 'item__position',
'item__variation__position', 'variation__position',
'quota__name', 'quota__name',
), ),
'subevent': 'subevent__date_from', 'subevent': 'subevent__date_from',
@@ -1950,7 +1950,7 @@ class VoucherFilterForm(FilterForm):
'-item__category__position', '-item__category__position',
'-item__category', '-item__category',
'-item__position', '-item__position',
'-item__variation__position', '-variation__position',
'-quota__name', '-quota__name',
) )
} }
@@ -2397,6 +2397,61 @@ class CheckinFilterForm(FilterForm):
return qs return qs
class CheckinListFilterForm(FilterForm):
orders = {
"name": ("name", "subevent__date_from", "pk"),
"-name": ("-name", "-subevent__date_from", "-pk"),
"subevent": ("subevent__date_from", "name", "pk"),
"-subevent": ("-subevent__date_from", "-name", "-pk"),
}
subevent = forms.ModelChoiceField(
label=pgettext_lazy('subevent', 'Date'),
queryset=SubEvent.objects.none(),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates')
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop("event")
super().__init__(*args, **kwargs)
if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'All dates')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
elif 'subevent':
del self.fields['subevent']
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('subevent'):
qs = qs.filter(subevent=fdata.get('subevent'))
if fdata.get("ordering"):
ob = self.orders[fdata.get('ordering')]
if isinstance(ob, dict):
ob = dict(ob)
o = ob.pop('_order')
qs = qs.annotate(**ob).order_by(*get_deterministic_ordering(OrderPosition, [o]))
elif isinstance(ob, (list, tuple)):
qs = qs.order_by(*get_deterministic_ordering(OrderPosition, ob))
else:
qs = qs.order_by(*get_deterministic_ordering(OrderPosition, [ob]))
else:
qs = qs.order_by("subevent__date_from", "name", "pk")
return qs.distinct()
class DeviceFilterForm(FilterForm): class DeviceFilterForm(FilterForm):
orders = { orders = {
'name': Upper('name'), 'name': Upper('name'),
+4 -2
View File
@@ -86,12 +86,14 @@ class GlobalSettingsForm(SettingsForm):
('leaflet_tiles', forms.CharField( ('leaflet_tiles', forms.CharField(
required=False, required=False,
label=_("Leaflet tiles URL pattern"), label=_("Leaflet tiles URL pattern"),
help_text=_("e.g. {sample}").format(sample="https://a.tile.openstreetmap.org/{z}/{x}/{y}.png") help_text=_("e.g. {sample}").format(sample="https://tile.openstreetmap.org/{z}/{x}/{y}.png")
)), )),
('leaflet_tiles_attribution', forms.CharField( ('leaflet_tiles_attribution', forms.CharField(
required=False, required=False,
label=_("Leaflet tiles attribution"), label=_("Leaflet tiles attribution"),
help_text=_("e.g. {sample}").format(sample='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors') help_text=_("e.g. {sample}").format(
sample='&copy; &lt;a href=&quot;https://www.openstreetmap.org/copyright&quot;&gt;OpenStreetMap&lt;/a&gt; contributors'
)
)), )),
]) ])
responses = register_global_settings.send(self) responses = register_global_settings.send(self)
-5
View File
@@ -461,11 +461,6 @@ class ItemCreateForm(I18nModelForm):
) )
if self.cleaned_data.get('copy_from'): if self.cleaned_data.get('copy_from'):
for mv in self.cleaned_data['copy_from'].meta_values.all():
mv.pk = None
mv.item = instance
mv.save(force_insert=True)
for question in self.cleaned_data['copy_from'].questions.all(): for question in self.cleaned_data['copy_from'].questions.all():
question.items.add(instance) question.items.add(instance)
question.log_action('pretix.event.question.changed', user=self.user, data={ question.log_action('pretix.event.question.changed', user=self.user, data={
+23 -18
View File
@@ -54,7 +54,7 @@ from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator from pretix.base.forms import I18nModelForm, PlaceholderValidator
from pretix.base.forms.questions import WrappedPhoneNumberPrefixWidget from pretix.base.forms.questions import WrappedPhoneNumberPrefixWidget
from pretix.base.forms.widgets import ( from pretix.base.forms.widgets import (
DatePickerWidget, SplitDateTimePickerWidget, DatePickerWidget, SplitDateTimePickerWidget, format_placeholders_help_text,
) )
from pretix.base.models import ( from pretix.base.models import (
Invoice, InvoiceAddress, ItemAddOn, Order, OrderFee, OrderPosition, Invoice, InvoiceAddress, ItemAddOn, Order, OrderFee, OrderPosition,
@@ -159,7 +159,7 @@ class ReactivateOrderForm(ForceQuotaConfirmationForm):
pass pass
class CancelForm(ForceQuotaConfirmationForm): class CancelForm(forms.Form):
send_email = forms.BooleanField( send_email = forms.BooleanField(
required=False, required=False,
label=_('Notify customer by email'), label=_('Notify customer by email'),
@@ -188,6 +188,7 @@ class CancelForm(ForceQuotaConfirmationForm):
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("instance")
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency) change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency)
self.fields['cancellation_fee'].widget.attrs['placeholder'] = floatformat( self.fields['cancellation_fee'].widget.attrs['placeholder'] = floatformat(
@@ -205,6 +206,20 @@ class CancelForm(ForceQuotaConfirmationForm):
return val return val
class DenyForm(forms.Form):
send_email = forms.BooleanField(
required=False,
label=_('Notify customer by email'),
initial=True
)
comment = forms.CharField(
label=_('Comment (will be sent to the user)'),
help_text=_('Will be included in the notification email when the respective placeholder is present in the '
'configured email text.'),
required=False,
)
class MarkPaidForm(ConfirmPaymentForm): class MarkPaidForm(ConfirmPaymentForm):
send_email = forms.BooleanField( send_email = forms.BooleanField(
required=False, required=False,
@@ -662,19 +677,14 @@ class OrderMailForm(forms.Form):
) )
def _set_field_placeholders(self, fn, base_parameters): def _set_field_placeholders(self, fn, base_parameters):
phs = [ placeholders = get_available_placeholders(self.order.event, base_parameters)
'{%s}' % p ht = format_placeholders_help_text(placeholders, self.order.event)
for p in sorted(get_available_placeholders(self.order.event, base_parameters).keys())
]
ht = _('Available placeholders: {list}').format(
list=', '.join(phs)
)
if self.fields[fn].help_text: if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht) self.fields[fn].help_text += ' ' + str(ht)
else: else:
self.fields[fn].help_text = ht self.fields[fn].help_text = ht
self.fields[fn].validators.append( self.fields[fn].validators.append(
PlaceholderValidator(phs) PlaceholderValidator(['{%s}' % p for p in placeholders.keys()])
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -857,19 +867,14 @@ class EventCancelForm(forms.Form):
send_waitinglist_message = forms.CharField() send_waitinglist_message = forms.CharField()
def _set_field_placeholders(self, fn, base_parameters): def _set_field_placeholders(self, fn, base_parameters):
phs = [ placeholders = get_available_placeholders(self.event, base_parameters)
'{%s}' % p ht = format_placeholders_help_text(placeholders, self.event)
for p in sorted(get_available_placeholders(self.event, base_parameters).keys())
]
ht = _('Available placeholders: {list}').format(
list=', '.join(phs)
)
if self.fields[fn].help_text: if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht) self.fields[fn].help_text += ' ' + str(ht)
else: else:
self.fields[fn].help_text = ht self.fields[fn].help_text = ht
self.fields[fn].validators.append( self.fields[fn].validators.append(
PlaceholderValidator(phs) PlaceholderValidator(['{%s}' % p for p in placeholders.keys()])
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
+8 -11
View File
@@ -63,7 +63,9 @@ from pretix.base.forms.questions import (
NamePartsFormField, WrappedPhoneNumberPrefixWidget, get_country_by_locale, NamePartsFormField, WrappedPhoneNumberPrefixWidget, get_country_by_locale,
get_phone_prefix, get_phone_prefix,
) )
from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.forms.widgets import (
SplitDateTimePickerWidget, format_placeholders_help_text,
)
from pretix.base.models import ( from pretix.base.models import (
Customer, Device, EventMetaProperty, Gate, GiftCard, GiftCardAcceptance, Customer, Device, EventMetaProperty, Gate, GiftCard, GiftCardAcceptance,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium, Team, Membership, MembershipType, OrderPosition, Organizer, ReusableMedium, Team,
@@ -420,7 +422,7 @@ class OrganizerSettingsForm(SettingsForm):
organizer_logo_image = ExtFileField( organizer_logo_image = ExtFileField(
label=_('Header image'), label=_('Header image'),
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_IMAGE,
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE, max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
required=False, required=False,
help_text=_('If you provide a logo image, we will by default not show your organization name ' help_text=_('If you provide a logo image, we will by default not show your organization name '
@@ -430,7 +432,7 @@ class OrganizerSettingsForm(SettingsForm):
) )
favicon = ExtFileField( favicon = ExtFileField(
label=_('Favicon'), label=_('Favicon'),
ext_whitelist=(".ico", ".png", ".jpg", ".gif", ".jpeg"), ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_FAVICON,
required=False, required=False,
max_size=settings.FILE_UPLOAD_MAX_SIZE_FAVICON, max_size=settings.FILE_UPLOAD_MAX_SIZE_FAVICON,
help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. ' help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. '
@@ -568,19 +570,14 @@ class MailSettingsForm(SettingsForm):
return placeholders return placeholders
def _set_field_placeholders(self, fn, base_parameters): def _set_field_placeholders(self, fn, base_parameters):
phs = [ placeholders = self._get_sample_context(base_parameters)
'{%s}' % p ht = format_placeholders_help_text(placeholders)
for p in sorted(self._get_sample_context(base_parameters).keys())
]
ht = _('Available placeholders: {list}').format(
list=', '.join(phs)
)
if self.fields[fn].help_text: if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht) self.fields[fn].help_text += ' ' + str(ht)
else: else:
self.fields[fn].help_text = ht self.fields[fn].help_text = ht
self.fields[fn].validators.append( self.fields[fn].validators.append(
PlaceholderValidator(phs) PlaceholderValidator(['{%s}' % p for p in placeholders.keys()])
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
+8 -8
View File
@@ -46,6 +46,7 @@ from django_scopes.forms import SafeModelChoiceField
from pretix.base.email import get_available_placeholders from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator from pretix.base.forms import I18nModelForm, PlaceholderValidator
from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.models import Item, Voucher from pretix.base.models import Item, Voucher
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
@@ -289,19 +290,15 @@ class VoucherBulkForm(VoucherForm):
Recipient = namedtuple('Recipient', 'email number name tag') Recipient = namedtuple('Recipient', 'email number name tag')
def _set_field_placeholders(self, fn, base_parameters): def _set_field_placeholders(self, fn, base_parameters):
phs = [ placeholders = get_available_placeholders(self.instance.event, base_parameters)
'{%s}' % p ht = format_placeholders_help_text(placeholders, self.instance.event)
for p in sorted(get_available_placeholders(self.instance.event, base_parameters).keys())
]
ht = _('Available placeholders: {list}').format(
list=', '.join(phs)
)
if self.fields[fn].help_text: if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht) self.fields[fn].help_text += ' ' + str(ht)
else: else:
self.fields[fn].help_text = ht self.fields[fn].help_text = ht
self.fields[fn].validators.append( self.fields[fn].validators.append(
PlaceholderValidator(phs) PlaceholderValidator(['{%s}' % p for p in placeholders.keys()])
) )
class Meta: class Meta:
@@ -340,6 +337,9 @@ class VoucherBulkForm(VoucherForm):
def clean_send_recipients(self): def clean_send_recipients(self):
raw = self.cleaned_data['send_recipients'] raw = self.cleaned_data['send_recipients']
if self.cleaned_data.get('send', None) is False:
# No need to validate addresses if the section was turned off
return []
if not raw: if not raw:
return [] return []
r = raw.split('\n') r = raw.split('\n')
+1
View File
@@ -341,6 +341,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'), 'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'), 'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'),
'pretix.giftcards.acceptance.acceptor.removed': _('A gift card acceptor has been removed.'),
'pretix.giftcards.acceptance.issuer.removed': _('A gift card issuer has been removed or declined.'), 'pretix.giftcards.acceptance.issuer.removed': _('A gift card issuer has been removed or declined.'),
'pretix.giftcards.acceptance.issuer.accepted': _('A new gift card issuer has been accepted.'), 'pretix.giftcards.acceptance.issuer.accepted': _('A new gift card issuer has been accepted.'),
'pretix.webhook.created': _('The webhook has been created.'), 'pretix.webhook.created': _('The webhook has been created.'),
@@ -29,6 +29,8 @@
id="product-select2">{% url "control:event.items.select2" event=request.event.slug organizer=request.organizer.slug %}</script> id="product-select2">{% url "control:event.items.select2" event=request.event.slug organizer=request.organizer.slug %}</script>
<script type="text/plain" <script type="text/plain"
id="variations-select2">{% url "control:event.items.variations.select2" event=request.event.slug organizer=request.organizer.slug %}</script> id="variations-select2">{% url "control:event.items.variations.select2" event=request.event.slug organizer=request.organizer.slug %}</script>
<script type="text/plain"
id="gates-select2">{% url "control:organizer.gates.select2" organizer=request.organizer.slug %}</script>
{% csrf_token %} {% csrf_token %}
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
<div class="tabbed-form"> <div class="tabbed-form">
@@ -1,5 +1,7 @@
{% extends "pretixcontrol/items/base.html" %} {% extends "pretixcontrol/items/base.html" %}
{% load i18n %} {% load i18n %}
{% load bootstrap3 %}
{% load urlreplace %}
{% block title %}{% trans "Check-in lists" %}{% endblock %} {% block title %}{% trans "Check-in lists" %}{% endblock %}
{% block inside %} {% block inside %}
<h1>{% trans "Check-in lists" %}</h1> <h1>{% trans "Check-in lists" %}</h1>
@@ -27,11 +29,26 @@
{% endblocktrans %} {% endblocktrans %}
</p> </p>
{% if request.event.has_subevents %} {% if request.event.has_subevents %}
<form class="form-inline helper-display-inline" action="" method="get"> <div class="panel panel-default">
<form class="form-inline helper-display-inline" action="" method="get"> <div class="panel-heading">
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %} <h3 class="panel-title">{% trans "Filter" %}</h3>
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
{% if filter_form.subevent %}
<div class="col-md-12 col-sm-12 col-xs-12">
{% bootstrap_field filter_form.subevent %}
</div>
{% endif %}
</div>
<div class="text-right">
<button class="btn btn-primary btn-lg" type="submit">
<span class="fa fa-filter"></span>
{% trans "Filter" %}
</button>
</div>
</form> </form>
</form> </div>
{% endif %} {% endif %}
{% if checkinlists|length == 0 %} {% if checkinlists|length == 0 %}
<div class="empty-collection"> <div class="empty-collection">
@@ -47,7 +64,8 @@
{% if "can_change_event_settings" in request.eventpermset %} {% if "can_change_event_settings" in request.eventpermset %}
<a href="{% url "control:event.orders.checkinlists.add" organizer=request.event.organizer.slug event=request.event.slug %}" <a href="{% url "control:event.orders.checkinlists.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new check-in list" %}</a> class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new check-in list" %}
</a>
{% endif %} {% endif %}
{% if can_change_organizer_settings %} {% if can_change_organizer_settings %}
<a href="{% url "control:organizer.devices" organizer=request.organizer.slug %}" <a href="{% url "control:organizer.devices" organizer=request.organizer.slug %}"
@@ -69,10 +87,18 @@
<table class="table table-hover table-quotas"> <table class="table table-hover table-quotas">
<thead> <thead>
<tr> <tr>
<th>{% trans "Check-in lists" %}</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 "Checked in" %}</th> <th>{% trans "Checked in" %}</th>
{% if request.event.has_subevents %} {% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}</th> <th>
{% trans "Date" context "subevent" %}
<a href="?{% url_replace request 'ordering' '-subevent' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'subevent' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% endif %} {% endif %}
<th class="iconcol">{% trans "Automated check-in" %}</th> <th class="iconcol">{% trans "Automated check-in" %}</th>
<th>{% trans "Products" %}</th> <th>{% trans "Products" %}</th>
@@ -83,7 +109,8 @@
{% for cl in checkinlists %} {% for cl in checkinlists %}
<tr> <tr>
<td> <td>
<strong><a href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}">{{ cl.name }}</a></strong> <strong><a
href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}">{{ cl.name }}</a></strong>
</td> </td>
<td> <td>
<div class="quotabox availability"> <div class="quotabox availability">
@@ -92,14 +119,16 @@
</div> </div>
</div> </div>
<div class="numbers"> <div class="numbers">
{{ cl.checkin_count|default_if_none:"0" }} / {{ cl.position_count|default_if_none:"0" }} {{ cl.checkin_count|default_if_none:"0" }} /
{{ cl.position_count|default_if_none:"0" }}
</div> </div>
</div> </div>
</td> </td>
{% if request.event.has_subevents %} {% if request.event.has_subevents %}
{% if cl.subevent %} {% if cl.subevent %}
<td> <td>
{{ cl.subevent.name }} {{ cl.subevent.get_date_range_display }} {{ cl.subevent.date_from|date:"TIME_FORMAT" }} {{ cl.subevent.name }} {{ cl.subevent.get_date_range_display }}
{{ cl.subevent.date_from|date:"TIME_FORMAT" }}
</td> </td>
{% else %} {% else %}
<td> <td>
@@ -127,17 +156,20 @@
{% endif %} {% endif %}
</td> </td>
<td class="text-right flip"> <td class="text-right flip">
<a href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}" class="btn btn-default btn-sm"><i class="fa fa-eye"></i></a> <a href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}"
class="btn btn-default btn-sm"><i class="fa fa-eye"></i></a>
{% if "can_change_event_settings" in request.eventpermset %} {% if "can_change_event_settings" in request.eventpermset %}
<a href="{% url "control:event.orders.checkinlists.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ cl.id }}" <a href="{% url "control:event.orders.checkinlists.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ cl.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip"> class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span> <span class="fa fa-copy"></span>
</a> </a>
<a href="{% url "control:event.orders.checkinlists.simulator" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}" <a href="{% url "control:event.orders.checkinlists.simulator" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}"
title="{% trans "Check-in simulator" %}" data-toggle="tooltip" title="{% trans "Check-in simulator" %}" data-toggle="tooltip"
class="btn btn-default btn-sm"><i class="fa fa-flask"></i></a> class="btn btn-default btn-sm"><i class="fa fa-flask"></i></a>
<a href="{% url "control:event.orders.checkinlists.edit" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}" class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a> <a href="{% url "control:event.orders.checkinlists.edit" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}"
<a href="{% url "control:event.orders.checkinlists.delete" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a> class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
<a href="{% url "control:event.orders.checkinlists.delete" organizer=request.event.organizer.slug event=request.event.slug list=cl.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -31,6 +31,7 @@
{% bootstrap_field form.raw_barcode layout="control" %} {% bootstrap_field form.raw_barcode layout="control" %}
{% bootstrap_field form.datetime layout="control" %} {% bootstrap_field form.datetime layout="control" %}
{% bootstrap_field form.checkin_type layout="control" %} {% bootstrap_field form.checkin_type layout="control" %}
{% bootstrap_field form.gate layout="control" %}
{% bootstrap_field form.ignore_unpaid layout="control" %} {% bootstrap_field form.ignore_unpaid layout="control" %}
{% bootstrap_field form.questions_supported layout="control" %} {% bootstrap_field form.questions_supported layout="control" %}
<div class="row"> <div class="row">
@@ -0,0 +1,27 @@
{% load i18n %}
<ul class="dropdown-menu dropdown-menu-right">
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="png" %}{% if url %}?url={{ url|urlencode }}{% endif %}"
target="_blank" download>
{% blocktrans with filetype="PNG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="svg" %}{% if url %}?url={{ url|urlencode }}{% endif %}"
target="_blank" download>
{% blocktrans with filetype="SVG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="jpeg" %}{% if url %}?url={{ url|urlencode }}{% endif %}"
target="_blank" download>
{% blocktrans with filetype="JPG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="gif" %}{% if url %}?url={{ url|urlencode }}{% endif %}"
target="_blank" download>
{% blocktrans with filetype="GIF" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
</ul>
@@ -27,28 +27,7 @@
<button type="button" class="btn btn-default btn-xs dropdown-toggle" data-toggle="dropdown" title="{% trans "Create QR code" %}" aria-haspopup="true" aria-expanded="false"> <button type="button" class="btn btn-default btn-xs dropdown-toggle" data-toggle="dropdown" title="{% trans "Create QR code" %}" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-qrcode" aria-hidden="true"></i> <i class="fa fa-qrcode" aria-hidden="true"></i>
</button> </button>
<ul class="dropdown-menu dropdown-menu-right"> {% include "pretixcontrol/event/fragment_qr_dropdown.html" with url=0 %}
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="png" %}" target="_blank" download>
{% blocktrans with filetype="PNG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="svg" %}" target="_blank" download>
{% blocktrans with filetype="SVG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="jpeg" %}" target="_blank" download>
{% blocktrans with filetype="JPG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="gif" %}" target="_blank" download>
{% blocktrans with filetype="GIF" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
</ul>
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>
@@ -337,7 +337,7 @@
<div class="alert alert-info"> <div class="alert alert-info">
{% blocktrans trimmed %} {% blocktrans trimmed %}
The waiting list currently is not compatible with some advanced features of pretix such as The waiting list currently is not compatible with some advanced features of pretix such as
add-on products or product bundles. hidden products, add-on products or product bundles.
{% endblocktrans %} {% endblocktrans %}
</div> </div>
<div class="alert alert-info"> <div class="alert alert-info">
@@ -48,6 +48,12 @@
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "Benefit" context "discount" %}</legend> <legend>{% trans "Benefit" context "discount" %}</legend>
{% bootstrap_field form.benefit_same_products layout="control" %}
<div data-display-dependency="#id_benefit_same_products" data-inverse>
{% bootstrap_field form.benefit_limit_products layout="control" %}
{% bootstrap_field form.benefit_apply_to_addons layout="control" %}
{% bootstrap_field form.benefit_ignore_voucher_discounted layout="control" %}
</div>
{% bootstrap_field form.benefit_discount_matching_percent layout="control" addon_after="%" %} {% bootstrap_field form.benefit_discount_matching_percent layout="control" addon_after="%" %}
{% bootstrap_field form.benefit_only_apply_to_cheapest_n_matches layout="control" %} {% bootstrap_field form.benefit_only_apply_to_cheapest_n_matches layout="control" %}
</fieldset> </fieldset>
@@ -0,0 +1,5 @@
{% with id=widget.attrs.id %}<div{% if id %} id="{{ id }}"{% endif %} class="{{ widget.attrs.class }} checkbox">{% for group, options, index in widget.optgroups %}{% if group %}
<div class="checkbox-group"><div class="checkbox-group-legend"><span>{% if group.2 %}<i class="{{ group.2 }}" aria-hidden="true"></i> {% endif %}{{ group.0 }}</span></div><p class="text-muted">{{ group.1|safe }}</p>{% endif %}{% for option in options %}<div class="checkbox">
{% include option.template_name with widget=option %}</div>{% endfor %}{% if group %}
</div>{% endif %}{% endfor %}
</div>{% endwith %}
@@ -5,13 +5,12 @@
{% include "django/forms/widgets/input.html" %} {% include "django/forms/widgets/input.html" %}
{% if widget.wrap_label %} {% if widget.wrap_label %}
{{ widget.label }} {{ widget.label }}
{% if widget.incubating %} {% if widget.incubating or not widget.official %}
<span class="label label-danger" data-toggle="tooltip" title="{% trans "The translation for this language is still in progress. This language can currently only be selected on development installations of pretix, not in production." %}"> &nbsp;
{% trans "Translation in development" %} <span class="label label-{% if widget.score > 50 %}success{% else %}{% if widget.score > 30 %}info{% else %}default{% endif %}{% endif %}"
</span> data-toggle="tooltip"
{% elif not widget.official %} title="{% trans "This percentage of texts is translated across all parts of the system including most plugins. Even a low value might be enough if you only use specific features. Untranslated texts will show up in English." %}">
<span class="label label-warning" data-toggle="tooltip" title="{% trans "This translation is not maintained by the pretix team. We cannot vouch for its correctness and new or recently changed features might not be translated and will show in English instead. You can help translating at translate.pretix.eu." %}"> {{ widget.score }} %
{% trans "Unofficial translation" %}
</span> </span>
{% endif %} {% endif %}
</label> </label>
@@ -1,5 +1,6 @@
{% extends "pretixcontrol/event/base.html" %} {% extends "pretixcontrol/event/base.html" %}
{% load i18n %} {% load i18n %}
{% load bootstrap3 %}
{% block title %} {% block title %}
{% trans "Deny order" %} {% trans "Deny order" %}
{% endblock %} {% endblock %}
@@ -13,16 +14,7 @@
<form method="post" href=""> <form method="post" href="">
{% csrf_token %} {% csrf_token %}
<div class="checkbox"> {% bootstrap_form form %}
<label>
<input type="checkbox" name="send_email" value="on" checked="checked">
{% trans "Notify user by e-mail" %}
</label>
</div>
<p>
<label>{% trans "Comment (will be sent to the user)" %}</label>
<textarea name="comment" class="form-control" rows="5"></textarea>
</p>
<div class="row checkout-button-row"> <div class="row checkout-button-row">
<div class="col-md-4"> <div class="col-md-4">
<a class="btn btn-block btn-default btn-lg" <a class="btn btn-block btn-default btn-lg"
@@ -110,7 +110,7 @@
<input type="hidden" name="start-action" value="do_nothing"> <input type="hidden" name="start-action" value="do_nothing">
<input type="hidden" name="start-mode" value="partial"> <input type="hidden" name="start-mode" value="partial">
{% localize off %} {% localize off %}
<input type="hidden" name="start-partial_amount" value="{{ overpaid|floatformat:2 }}"> <input type="hidden" name="start-partial_amount" value="{{ overpaid|money_numberfield:request.event.currency }}">
{% endlocalize %} {% endlocalize %}
<input type="hidden" name="comment" value="{% trans "Refund for overpayment" %}"> <input type="hidden" name="comment" value="{% trans "Refund for overpayment" %}">
<div class="alert alert-warning"> <div class="alert alert-warning">
@@ -51,9 +51,9 @@
<div class="input-group"> <div class="input-group">
<input type="text" name="refund-{{ p.pk }}" <input type="text" name="refund-{{ p.pk }}"
{% if p.propose_refund %} {% if p.propose_refund %}
value="{{ p.propose_refund|floatformat:2 }}" value="{{ p.propose_refund|money_numberfield:request.event.currency }}"
{% else %} {% else %}
placeholder="{{ p.propose_refund|floatformat:2 }}" placeholder="{{ p.propose_refund|money_numberfield:request.event.currency }}"
{% endif %} {% endif %}
title="" class="form-control"> title="" class="form-control">
<span class="input-group-addon"> <span class="input-group-addon">
@@ -63,7 +63,7 @@
{% elif p.full_refund_possible %} {% elif p.full_refund_possible %}
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" name="refund-{{ p.pk }}" <input type="checkbox" name="refund-{{ p.pk }}"
value="{{ p.amount|floatformat:2 }}" value="{{ p.amount|money_numberfield:request.event.currency }}"
{% if p.propose_refund == p.amount %}checked{% endif %}> {% if p.propose_refund == p.amount %}checked{% endif %}>
{% trans "Full amount" %} ({{ p.amount|money:request.event.currency }}) {% trans "Full amount" %} ({{ p.amount|money:request.event.currency }})
</label> </label>
@@ -101,7 +101,7 @@
<td class="text-right flip refund-amount"> <td class="text-right flip refund-amount">
<div class="input-group"> <div class="input-group">
<input type="text" name="newrefund-{{ prov }}" <input type="text" name="newrefund-{{ prov }}"
placeholder="{{ 0|floatformat:2 }}" placeholder="{{ 0|money_numberfield:request.event.currency }}"
title="" class="form-control"> title="" class="form-control">
<span class="input-group-addon"> <span class="input-group-addon">
{{ request.event.currency }} {{ request.event.currency }}
@@ -119,7 +119,7 @@
<td class="text-right flip refund-amount"> <td class="text-right flip refund-amount">
<div class="input-group"> <div class="input-group">
<input type="text" name="refund-offsetting" <input type="text" name="refund-offsetting"
title="" class="form-control" placeholder="{{ 0|floatformat:2 }}"> title="" class="form-control" placeholder="{{ 0|money_numberfield:request.event.currency }}">
<span class="input-group-addon"> <span class="input-group-addon">
{{ request.event.currency }} {{ request.event.currency }}
</span> </span>
@@ -144,9 +144,9 @@
<input type="text" name="refund-new-giftcard" <input type="text" name="refund-new-giftcard"
title="" class="form-control" title="" class="form-control"
{% if giftcard_proposal %} {% if giftcard_proposal %}
value="{{ giftcard_proposal|floatformat:2 }}" value="{{ giftcard_proposal|money_numberfield:request.event.currency }}"
{% else %} {% else %}
placeholder="{{ giftcard_proposal|floatformat:2 }}" placeholder="{{ giftcard_proposal|money_numberfield:request.event.currency }}"
{% endif %} {% endif %}
> >
<span class="input-group-addon"> <span class="input-group-addon">
@@ -172,9 +172,9 @@
<div class="input-group"> <div class="input-group">
<input type="text" name="refund-manual" <input type="text" name="refund-manual"
{% if remainder %} {% if remainder %}
value="{{ remainder|floatformat:2 }}" value="{{ remainder|money_numberfield:request.event.currency }}"
{% else %} {% else %}
placeholder="{{ remainder|floatformat:2 }}" placeholder="{{ remainder|money_numberfield:request.event.currency }}"
{% endif %} {% endif %}
title="" class="form-control"> title="" class="form-control">
<span class="input-group-addon"> <span class="input-group-addon">
@@ -69,7 +69,7 @@
<td></td> <td></td>
<td class="text-right flip"> <td class="text-right flip">
<strong> <strong>
{{ sums.count }} {{ sums.sum_count }}
</strong> </strong>
</td> </td>
<td></td> <td></td>
@@ -0,0 +1,103 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% load bootstrap3 %}
{% block title %}{% trans "Modify orders" %}{% endblock %}
{% block content %}
<h1>{% trans "Modify orders" %}</h1>
<form action="{{ request.get_full_path }}" method="post" class="form-horizontal" data-asynctask
data-asynctask-long="">
{% csrf_token %}
<div class="alert alert-info">
{% blocktrans trimmed with label=label allowed=allowed.count total=total %}
The operation <strong>{{ label }}</strong> can be applied to <strong>{{ allowed }}</strong> of the
selected <strong>{{ total }}</strong> orders.
{% endblocktrans %}
</div>
{% if allowed %}
<div class="table-responsive">
<table class="table table-condensed table-hover table-orders">
<thead>
<tr>
<th>{% trans "Order code" %}</th>
<th>{% trans "User" %}</th>
<th>{% trans "Order date" %}</th>
<th class="text-right flip">{% trans "Order total" %}</th>
<th class="text-right flip">{% trans "Status" %}</th>
</tr>
</thead>
<tbody>
{% for o in allowed|slice:":50" %}
<tr>
<td>
<strong>
<a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=o.code %}">
{{ o.code }}</a>
</strong>
{% if o.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
</td>
<td>
{{ o.email|default_if_none:"" }}
{% if o.invoice_address.name %}
<br>{{ o.invoice_address.name }}
{% endif %}
</td>
<td>
<span class="fa fa-fw fa-{{ o.sales_channel_obj.icon }} text-muted"
data-toggle="tooltip" title="{% trans o.sales_channel_obj.verbose_name %}"></span>
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td class="text-right flip">
{{ o.total|money:request.event.currency }}
</td>
<td class="text-right flip">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
</tr>
{% endfor %}
{% if allowed.count > 50 %}
<tr>
<td></td>
<td></td>
<td></td>
<td></td>
<td></td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<p>
{% blocktrans trimmed %}
Do you want to continue?
{% endblocktrans %}
</p>
<div class="alert alert-warning">
<strong>
{% blocktrans trimmed %}
This operation cannot be reversed.
{% endblocktrans %}
</strong>
</div>
{% for k, l in request.POST.lists %}
{% if "bulkactionform" not in k %}
{% for v in l %}
<input type="hidden" name="{{ k }}" value="{{ v }}">
{% endfor %}
{% endif %}
{% endfor %}
{% bootstrap_form form layout="control" %}
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:event.orders" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
{% if allowed %}
<button type="submit" class="btn btn-primary btn-save" value="confirm" name="operation">
{{ label }}
</button>
{% endif %}
</div>
</form>
{% endblock %}
@@ -17,11 +17,12 @@
{% if not request.event.live %} {% if not request.event.live %}
<a href="{% url "control:event.live" event=request.event.slug organizer=request.event.organizer.slug %}" <a href="{% url "control:event.live" event=request.event.slug organizer=request.event.organizer.slug %}"
class="btn btn-primary btn-lg"> class="btn btn-primary btn-lg">
{% trans "Take your shop live" %} {% trans "Take your shop live" %}
</a> </a>
{% else %} {% else %}
<a href="{% eventurl request.event "presale:event.index" %}" class="btn btn-primary btn-lg" target="_blank"> <a href="{% eventurl request.event "presale:event.index" %}" class="btn btn-primary btn-lg"
target="_blank">
{% trans "Go to the ticket shop" %} {% trans "Go to the ticket shop" %}
</a> </a>
{% endif %} {% endif %}
@@ -40,9 +41,10 @@
</p> </p>
{% else %} {% else %}
<form class="form-inline" <form class="form-inline"
action="{% url "control:event.orders.go" event=request.event.slug organizer=request.event.organizer.slug %}"> action="{% url "control:event.orders.go" event=request.event.slug organizer=request.event.organizer.slug %}">
<p class="input-group"> <p class="input-group">
<input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}" autofocus> <input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}"
autofocus>
<span class="input-group-btn"> <span class="input-group-btn">
<button class="btn btn-primary" type="submit">{% trans "Go!" %}</button> <button class="btn btn-primary" type="submit">{% trans "Go!" %}</button>
</span> </span>
@@ -82,7 +84,8 @@
{% endif %} {% endif %}
</div> </div>
<div class="text-right flip"> <div class="text-right flip">
<a href="{% url "control:event.orders.search" event=request.event.slug organizer=request.event.organizer.slug %}" class="btn btn-default btn-lg"> <a href="{% url "control:event.orders.search" event=request.event.slug organizer=request.event.organizer.slug %}"
class="btn btn-default btn-lg">
{% trans "Advanced search" %} {% trans "Advanced search" %}
</a> </a>
<button class="btn btn-primary btn-lg" type="submit"> <button class="btn btn-primary btn-lg" type="submit">
@@ -99,134 +102,224 @@
{% blocktrans trimmed with question=filter_form.cleaned_data.question.question %} {% blocktrans trimmed with question=filter_form.cleaned_data.question.question %}
List filtered by answers to question "{{ question }}". List filtered by answers to question "{{ question }}".
{% endblocktrans %} {% endblocktrans %}
<a href="?{% url_replace request 'question' '' 'answer' ''%}" class="text-muted"> <a href="?{% url_replace request 'question' '' 'answer' '' %}" class="text-muted">
<span class="fa fa-times"></span> <span class="fa fa-times"></span>
{% trans "Remove filter" %} {% trans "Remove filter" %}
</a> </a>
</p> </p>
{% endif %} {% endif %}
<div class="table-responsive"> <form action="{% url "control:organizer.device.bulk_edit" organizer=request.organizer.slug %}" method="post">
<table class="table table-condensed table-hover table-orders"> {% csrf_token %}
<thead> {% for field in filter_form %}
<tr> {{ field.as_hidden }}
<th>{% trans "Order code" %} {% endfor %}
<a href="?{% url_replace request 'ordering' '-code' %}"><i class="fa fa-caret-down"></i></a> {% for form in filter_forms %}
<a href="?{% url_replace request 'ordering' 'code' %}"><i class="fa fa-caret-up"></i></a></th> {% for field in form %}
<th>{% trans "User" %} {{ field.as_hidden }}
<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 "Order date" %}
<a href="?{% url_replace request 'ordering' '-datetime' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'datetime' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th class="text-right flip">{% trans "Order paid / total" %}
<a href="?{% url_replace request 'ordering' '-total' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'total' %}"><i class="fa fa-caret-up"></i></a></th>
<th class="text-right flip">{% trans "Positions" %}</th>
<th class="text-right flip">{% 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>
</tr>
</thead>
<tbody>
{% for o in orders %}
<tr>
<td>
<strong>
<a
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=o.code %}">
{{ o.code }}</a>
</strong>
{% if o.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
{% if o.custom_followup_due %}
<span class="label label-danger">{% blocktrans with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}TODO {{ date }}{% endblocktrans %}</span>
{% elif o.custom_followup_at %}
<span class="label label-default">{% blocktrans with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}TODO {{ date }}{% endblocktrans %}</span>
{% endif %}
</td>
<td>
{{ o.email|default_if_none:"" }}
{% if o.invoice_address.name %}
<br>{{ o.invoice_address.name }}
{% endif %}
</td>
<td>
<span class="fa fa-fw fa-{{ o.sales_channel_obj.icon }} text-muted"
data-toggle="tooltip" title="{% trans o.sales_channel_obj.verbose_name %}"></span>
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td class="text-right flip">
{% if o.has_cancellation_request %}
<span class="label label-warning">{% trans "CANCELLATION REQUESTED" %}</span>
{% endif %}
{% if o.has_external_refund or o.has_pending_refund %}
<span class="label label-danger">{% trans "REFUND PENDING" %}</span>
{% elif o.has_pending_refund %}
<span class="label label-warning">{% trans "REFUND PENDING" %}</span>
{% endif %}
{% if o.is_overpaid %}
<span class="label label-warning">{% trans "OVERPAID" %}</span>
{% elif o.is_underpaid %}
<span class="label label-danger">{% trans "UNDERPAID" %}</span>
{% elif o.is_pending_with_full_payment %}
<span class="label label-danger">{% trans "FULLY PAID" %}</span>
{% endif %}
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
<span class="text-muted">
{% endif %}
{{ o.computed_payment_refund_sum|money:request.event.currency }} /
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
</span>
{% endif %}
{{ o.total|money:request.event.currency }}
{% if o.status == "c" and o.icnt %}
<br>
<span class="label label-warning">{% trans "INVOICE NOT CANCELED" %}</span>
{% endif %}
</td>
<td class="text-right flip">{{ o.pcnt|default_if_none:"0" }}</td>
<td class="text-right flip">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> {% endfor %}
{% if sums %} <div class="table-responsive">
<tfoot> <table class="table table-condensed table-hover table-orders">
<thead>
<tr> <tr>
<th>{% trans "Sum over all pages" %}</th> {% if "can_change_orders" in request.eventpermset %}
<th></th> <th>
<th> <label aria-label="{% trans "select all rows for batch-operation" %}"
{% blocktrans trimmed count s=sums.c %} class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
1 order </th>
{% plural %} {% endif %}
{{ s }} orders <th>{% trans "Order code" %}
{% endblocktrans %} <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>
<th class="text-right flip"> <th>{% trans "User" %}
{% if not filter_form.filtered %} <a href="?{% url_replace request 'ordering' '-email' %}"><i
<span class="fa fa-info-circle text-muted" data-toggle="tooltip" class="fa fa-caret-down"></i></a>
title='{% trans 'This sum includes canceled orders. For your ticket revenue, look at the "order overview".' %}'></span> <a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a>
{% if sums.s|default_if_none:"none" != "none" %}
{{ sums.s|money:request.event.currency }}
{% endif %}
{% endif %}
</th> </th>
<th class="text-right flip"> <th>{% trans "Order date" %}
{% if not filter_form.filtered %} <a href="?{% url_replace request 'ordering' '-datetime' %}"><i class="fa fa-caret-down"></i></a>
<span class="fa fa-info-circle text-muted" data-toggle="tooltip" <a href="?{% url_replace request 'ordering' 'datetime' %}"><i
title='{% trans 'This sum includes canceled orders. For your ticket revenue, look at the "order overview".' %}'></span> class="fa fa-caret-up"></i></a>
{% if sums.pc %} </th>
{{ sums.pc }} <th class="text-right flip">{% trans "Order paid / total" %}
{% endif %} <a href="?{% url_replace request 'ordering' '-total' %}"><i
{% endif %} class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'total' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th class="text-right flip">{% trans "Positions" %}</th>
<th class="text-right flip">{% 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>
<th></th>
</tr> </tr>
</tfoot> {% if page_obj.paginator.num_pages > 1 and "can_change_orders" in request.eventpermset %}
{% endif %} <tr class="table-select-all warning hidden">
</table> <td>
</div> <input type="checkbox" name="__ALL" id="__all"
data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="6">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for o in orders %}
<tr>
{% if "can_change_orders" in request.eventpermset %}
<td>
<label aria-label="{% trans "select row for batch-operation" %}"
class="batch-select-label"><input type="checkbox" name="order"
class="batch-select-checkbox"
value="{{ o.pk }}"/></label>
</td>
{% endif %}
<td>
<strong>
<a
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=o.code %}">
{{ o.code }}</a>
</strong>
{% if o.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
{% if o.custom_followup_due %}
<span class="label label-danger">{% blocktrans trimmed with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}TODO {{ date }}{% endblocktrans %}</span>
{% elif o.custom_followup_at %}
<span class="label label-default">{% blocktrans trimmed with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}TODO {{ date }}{% endblocktrans %}</span>
{% endif %}
</td>
<td>
{{ o.email|default_if_none:"" }}
{% if o.invoice_address.name %}
<br>{{ o.invoice_address.name }}
{% endif %}
</td>
<td>
<span class="fa fa-fw fa-{{ o.sales_channel_obj.icon }} text-muted"
data-toggle="tooltip" title="{% trans o.sales_channel_obj.verbose_name %}"></span>
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td class="text-right flip">
{% if o.has_cancellation_request %}
<span class="label label-warning">{% trans "CANCELLATION REQUESTED" %}</span>
{% endif %}
{% if o.has_external_refund or o.has_pending_refund %}
<span class="label label-danger">{% trans "REFUND PENDING" %}</span>
{% elif o.has_pending_refund %}
<span class="label label-warning">{% trans "REFUND PENDING" %}</span>
{% endif %}
{% if o.is_overpaid %}
<span class="label label-warning">{% trans "OVERPAID" %}</span>
{% elif o.is_underpaid %}
<span class="label label-danger">{% trans "UNDERPAID" %}</span>
{% elif o.is_pending_with_full_payment %}
<span class="label label-danger">{% trans "FULLY PAID" %}</span>
{% endif %}
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
<span class="text-muted">
{% endif %}
{{ o.computed_payment_refund_sum|money:request.event.currency }} /
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
</span>
{% endif %}
{{ o.total|money:request.event.currency }}
{% if o.status == "c" and o.icnt %}
<br>
<span class="label label-warning">{% trans "INVOICE NOT CANCELED" %}</span>
{% endif %}
</td>
<td class="text-right flip">{{ o.pcnt|default_if_none:"0" }}</td>
<td class="text-right flip">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
</tr>
{% endfor %}
</tbody>
{% if sums %}
<tfoot>
<tr>
<th></th>
<th>{% trans "Sum over all pages" %}</th>
<th></th>
<th>
{% blocktrans trimmed count s=sums.c %}
1 order
{% plural %}
{{ s }} orders
{% endblocktrans %}
</th>
<th class="text-right flip">
{% if not filter_form.filtered %}
<span class="fa fa-info-circle text-muted" data-toggle="tooltip"
title='{% trans 'This sum includes canceled orders. For your ticket revenue, look at the "order overview".' %}'></span>
{% if sums.s|default_if_none:"none" != "none" %}
{{ sums.s|money:request.event.currency }}
{% endif %}
{% endif %}
</th>
<th class="text-right flip">
{% if not filter_form.filtered %}
<span class="fa fa-info-circle text-muted" data-toggle="tooltip"
title='{% trans 'This sum includes canceled orders. For your ticket revenue, look at the "order overview".' %}'></span>
{% if sums.pc %}
{{ sums.pc }}
{% endif %}
{% endif %}
</th>
<th></th>
</tr>
</tfoot>
{% endif %}
</table>
</div>
{% if "can_change_orders" in request.eventpermset %}
<div class="batch-select-actions">
<div class="btn-group dropup">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
{% trans "Select action" %}
</button>
<ul class="dropdown-menu">
<li>
<button type="submit" class="btn"
formaction="{% url "control:event.orders.bulk.approve" organizer=request.organizer.slug event=request.event.slug %}">
<i class="fa fa-thumbs-up fa-fw text-green"></i>
{% trans "Approve" %}
</button>
</li>
<li>
<button type="submit" class="btn"
formaction="{% url "control:event.orders.bulk.deny" organizer=request.organizer.slug event=request.event.slug %}">
<i class="fa fa-thumbs-down fa-fw text-danger"></i>
{% trans "Deny" %}
</button>
</li>
{% if not request.event.settings.payment_term_expire_automatically %}
<li>
<button type="submit" class="btn"
formaction="{% url "control:event.orders.bulk.expire" organizer=request.organizer.slug event=request.event.slug %}">
<i class="fa fa-times fa-fw"></i>
{% trans "Mark as expired if overdue" %}
</button>
</li>
{% endif %}
<li>
<button type="submit" class="btn"
formaction="{% url "control:event.orders.bulk.delete" organizer=request.organizer.slug event=request.event.slug %}">
<i class="fa fa-trash fa-fw text-danger"></i>
{% trans "Delete (test mode only)" %}
</button>
</li>
</ul>
</div>
</div>
{% endif %}
</form>
{% include "pretixcontrol/pagination.html" %} {% include "pretixcontrol/pagination.html" %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -295,6 +295,11 @@
{% bootstrap_field sform.invoice_regenerate_allowed layout="control" %} {% bootstrap_field sform.invoice_regenerate_allowed layout="control" %}
</fieldset> </fieldset>
</div> </div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</div> </div>
<div class="col-xs-12 col-lg-2"> <div class="col-xs-12 col-lg-2">
<div class="panel panel-default"> <div class="panel panel-default">
@@ -307,10 +312,5 @@
</div> </div>
</div> </div>
</div> </div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form> </form>
{% endblock %} {% endblock %}
@@ -87,7 +87,7 @@
{% blocktrans trimmed %} {% blocktrans trimmed %}
You can invite other organizers to accept your gift cards. After you have done so, they need to go You can invite other organizers to accept your gift cards. After you have done so, they need to go
to the same page in their account and accept your invitation. Note that other organizers will be able to the same page in their account and accept your invitation. Note that other organizers will be able
to add money to gift cards as well that you will need to collect form them. It is your responsibility to add money to gift cards as well that you will need to collect from them. It is your responsibility
to handle the exchange of money to offset the transactions between the two organizers. to handle the exchange of money to offset the transactions between the two organizers.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
@@ -77,9 +77,9 @@
<span class="label label-warning">{% trans "TEST MODE" %}</span> <span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %} {% endif %}
{% if o.custom_followup_due %} {% if o.custom_followup_due %}
<span class="label label-danger">{% blocktrans with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}TODO {{ date }}{% endblocktrans %}</span> <span class="label label-danger">{% blocktrans trimmed with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}TODO {{ date }}{% endblocktrans %}</span>
{% elif o.custom_followup_at %} {% elif o.custom_followup_at %}
<span class="label label-default">{% blocktrans with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}TODO {{ date }}{% endblocktrans %}</span> <span class="label label-default">{% blocktrans trimmed with date=o.custom_followup_at|date:"SHORT_DATE_FORMAT" context "followup" %}TODO {{ date }}{% endblocktrans %}</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ o.event.name }}</td> <td>{{ o.event.name }}</td>
@@ -42,10 +42,18 @@
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label" for="id_url">{% trans "Voucher link" %}</label> <label class="col-md-3 control-label" for="id_url">{% trans "Voucher link" %}</label>
<div class="col-md-9"> <div class="col-md-9">
<input type="text" name="url" <div class="input-group">
value="{% abseventurl request.event "presale:event.redeem" %}?voucher={{ voucher.code|urlencode }}{% if voucher.subevent_id %}&subevent={{ voucher.subevent_id }}{% endif %}" <input type="text" name="url"
class="form-control" value="{{ url }}"
id="id_url" readonly> class="form-control"
id="id_url" readonly>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" title="{% trans "Create QR code" %}" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-qrcode" aria-hidden="true"></i>
</button>
{% include "pretixcontrol/event/fragment_qr_dropdown.html" with url=url %}
</div>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -106,10 +114,12 @@
</div> </div>
</div> </div>
</div> </div>
<div class="form-group submit-group"> {% if "can_change_vouchers" in request.eventpermset %}
<button type="submit" class="btn btn-primary btn-save"> <div class="form-group submit-group">
{% trans "Save" %} <button type="submit" class="btn btn-primary btn-save">
</button> {% trans "Save" %}
</div> </button>
</div>
{% endif %}
</form> </form>
{% endblock %} {% endblock %}
@@ -72,18 +72,22 @@
{% endif %} {% endif %}
</p> </p>
<a href="{% url "control:event.vouchers.add" organizer=request.event.organizer.slug event=request.event.slug %}" {% if "can_change_vouchers" in request.eventpermset %}
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new voucher" %}</a> <a href="{% url "control:event.vouchers.add" organizer=request.event.organizer.slug event=request.event.slug %}"
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new voucher" %}</a>
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create multiple new vouchers" %}</a> <a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create multiple new vouchers" %}</a>
{% endif %}
</div> </div>
{% else %} {% else %}
<p> <p>
<a href="{% url "control:event.vouchers.add" organizer=request.event.organizer.slug event=request.event.slug %}" {% if "can_change_vouchers" in request.eventpermset %}
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new voucher" %}</a> <a href="{% url "control:event.vouchers.add" organizer=request.event.organizer.slug event=request.event.slug %}"
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new voucher" %}</a>
class="btn btn-default"><i class="fa fa-plus"></i> <a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}"
{% trans "Create multiple new vouchers" %}</a> class="btn btn-default"><i class="fa fa-plus"></i>
{% trans "Create multiple new vouchers" %}</a>
{% endif %}
<a href="?{% url_replace request "download" "yes" %}" <a href="?{% url_replace request "download" "yes" %}"
class="btn btn-default"><i class="fa fa-download"></i> class="btn btn-default"><i class="fa fa-download"></i>
{% trans "Download list" %}</a> {% trans "Download list" %}</a>
@@ -94,11 +98,13 @@
<table class="table table-hover table-quotas"> <table class="table table-hover table-quotas">
<thead> <thead>
<tr> <tr>
<th> {% if "can_change_vouchers" in request.eventpermset %}
{% if "can_change_vouchers" in request.eventpermset %} <th>
<input type="checkbox" data-toggle-table /> <label aria-label="{% trans "select all rows for batch-operation" %}" class="batch-select-label">
{% endif %} <input type="checkbox" data-toggle-table />
</th> </label>
</th>
{% endif %}
<th> <th>
{% trans "Voucher code" %} {% trans "Voucher 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-down"></i></a>
@@ -137,11 +143,13 @@
<tbody> <tbody>
{% for v in vouchers %} {% for v in vouchers %}
<tr> <tr>
<td> {% if "can_change_vouchers" in request.eventpermset %}
{% if "can_change_vouchers" in request.eventpermset %} <td>
<input type="checkbox" name="voucher" class="" value="{{ v.pk }}"/> <label aria-label="{% trans "select row for batch-operation" %}" class="batch-select-label">
{% endif %} <input type="checkbox" name="voucher" class="batch-select-checkbox" value="{{ v.pk }}"/>
</td> </label>
</td>
{% endif %}
<td> <td>
{% if not v.is_active %}<del>{% endif %} {% if not v.is_active %}<del>{% endif %}
<strong><a href="{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}">{{ v.code }}</a></strong> <strong><a href="{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}">{{ v.code }}</a></strong>
@@ -182,11 +190,13 @@
</td> </td>
{% endif %} {% endif %}
<td class="text-right flip"> <td class="text-right flip">
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ v.id }}" {% if "can_change_vouchers" in request.eventpermset %}
class="btn btn-sm btn-default" title="{% trans "Use as a template for new vouchers" %}" data-toggle="tooltip"> <a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ v.id }}"
<span class="fa fa-copy"></span> class="btn btn-sm btn-default" title="{% trans "Use as a template for new vouchers" %}" data-toggle="tooltip">
</a> <span class="fa fa-copy"></span>
<a href="{% url "control:event.voucher.delete" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a> </a>
<a href="{% url "control:event.voucher.delete" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -194,9 +204,12 @@
</table> </table>
</div> </div>
{% if "can_change_vouchers" in request.eventpermset %} {% if "can_change_vouchers" in request.eventpermset %}
<button type="submit" class="btn btn-default btn-save" name="action" value="delete"> <div class="batch-select-actions">
{% trans "Delete selected" %} <button type="submit" class="btn btn-danger" name="action" value="delete">
</button> <i class="fa fa-trash" aria-hidden="true"></i>
{% trans "Delete selected" %}
</button>
</div>
{% endif %} {% endif %}
</form> </form>
{% include "pretixcontrol/pagination.html" %} {% include "pretixcontrol/pagination.html" %}
+4
View File
@@ -414,6 +414,10 @@ urlpatterns = [
re_path(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'), re_path(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'),
re_path(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'), re_path(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
re_path(r'^orders/$', orders.OrderList.as_view(), name='event.orders'), re_path(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
re_path(r'^orders/bulk/approve$', orders.OrderApproveBulkActionView.as_view(), name='event.orders.bulk.approve'),
re_path(r'^orders/bulk/deny$', orders.OrderDenyBulkActionView.as_view(), name='event.orders.bulk.deny'),
re_path(r'^orders/bulk/expire$', orders.OrderExpireBulkActionView.as_view(), name='event.orders.bulk.expire'),
re_path(r'^orders/bulk/delete$', orders.OrderDeleteBulkActionView.as_view(), name='event.orders.bulk.delete'),
re_path(r'^orders/search$', orders.OrderSearch.as_view(), name='event.orders.search'), re_path(r'^orders/search$', orders.OrderSearch.as_view(), name='event.orders.search'),
re_path(r'^dangerzone/$', event.DangerZone.as_view(), name='event.dangerzone'), re_path(r'^dangerzone/$', event.DangerZone.as_view(), name='event.dangerzone'),
re_path(r'^cancel/$', orders.EventCancel.as_view(), name='event.cancel'), re_path(r'^cancel/$', orders.EventCancel.as_view(), name='event.cancel'),

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