Compare commits

...

409 Commits

Author SHA1 Message Date
Richard Schreiber
c25ba29efe fix tests 2022-06-20 14:22:51 +02:00
Richard Schreiber
9d30034754 Fix: refresh from DB after API-patch-operations 2022-06-20 11:54:33 +02:00
Richard Schreiber
ec2da30c74 Upgrade django-phonenumber-field to 6.3.* 2022-06-20 10:57:31 +02:00
Raphael Michel
f3583488ef Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 85.2% (4049 of 4748 strings)

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

powered by weblate
2022-06-16 11:37:38 +02:00
Samir
57ee2280aa Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 85.2% (4049 of 4748 strings)

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

powered by weblate
2022-06-16 11:37:38 +02:00
Raphael Michel
75c069111e Add customized links to page footer (#2685)
Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2022-06-16 11:21:11 +02:00
Raphael Michel
54a4631e22 Thumbnails: Support animated GIFs (#2686)
Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2022-06-15 18:21:43 +02:00
Raphael Michel
8eaa8999e5 Docs: Add shipping plugin API 2022-06-15 17:06:29 +02:00
Richard Schreiber
979e02ec73 Improve free price input auto-checking/selecting items and addons 2022-06-15 13:25:31 +02:00
Raphael Michel
dfebcf5294 Add missing __init__ files 2022-06-14 14:20:07 +02:00
Raphael Michel
90891504fc Change handling of attach size to match earlier behaviour 2022-06-14 10:13:06 +02:00
Raphael Michel
b3383a24e8 Change default email attachment limit back to 5MB 2022-06-14 10:12:57 +02:00
Raphael Michel
bd299f9afb Fix crash during manual check-in (PRETIXEU-6WX) 2022-06-13 14:06:30 +02:00
Raphael Michel
9b7088f7fc Reduce number of SQL queries in API order creation 2022-06-13 12:05:14 +02:00
Raphael Michel
635344a32f Fix large number of SQL queries in check-in history 2022-06-13 11:38:42 +02:00
Raphael Michel
fc1d3f7fb1 Docs: Add new pretix-exhibitors endpoints 2022-06-10 14:45:18 +02:00
Raphael Michel
334c1c4b5e Hotfix JS issue 2022-06-10 13:00:01 +02:00
Raphael Michel
86085d9368 Allow to bulk-select many tickets to check in or out (#2678)
* Allow to bulk-select many tickets to check in or out

* Update tests

* Add permission test

* Update src/pretix/control/templates/pretixcontrol/checkin/index.html

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>

* Update src/pretix/static/pretixbase/js/asynctask.js

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>

* Remove console.warn

* Simplify stuff

* minor refactor

* fix missing checked-out success message

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2022-06-10 12:14:44 +02:00
Raphael Michel
f3a84c1d6e Make IE11 warning in backend bigger (#2680)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2022-06-10 12:13:30 +02:00
Raphael Michel
2a9eb2772a Add idempotency.query to all security profiles 2022-06-10 09:24:47 +02:00
Raphael Michel
9a08f7fec5 Spellcheck: Add Transaktions-ID to list of weird words 2022-06-09 17:58:15 +02:00
pretix translation bot
193842b43b Update translations (#2679)
Co-authored-by: Raphael Michel <michel@rami.io>
2022-06-09 17:57:38 +02:00
Raphael Michel
cd321f3e0a Remove more duplicate strings 2022-06-09 17:49:19 +02:00
Raphael Michel
00ad6f7d53 Reduce number of translatable strings 2022-06-09 17:46:56 +02:00
Raphael Michel
88770ed7b6 Adjust translation strings 2022-06-09 17:43:37 +02:00
Raphael Michel
2ec50f6184 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-06-09 17:07:44 +02:00
Raphael Michel
d45bc0f37b Fix text for free orders pending approval 2022-06-09 17:06:41 +02:00
Martin Gross
03a7a4e210 POS: Detail renderer for izettle_qrc POS payments 2022-06-09 16:52:55 +02:00
Martin Gross
8892fad228 POS: Detail renderer for adyen_legacy POS terminal payments 2022-06-09 16:40:48 +02:00
Martin Gross
b206509345 PPv2: ISU-Return: explicitly set incr cache key if not set 2022-06-08 13:30:12 +02:00
Martin Gross
fdee69cd69 PPv2: Revert ISU Return-Retry; Add access token cache invalidation on ISU return 2022-06-08 12:59:30 +02:00
pretix translation bot
0d5f3697a1 Update translations (#2677)
Co-authored-by: Martin Gross <martin@pc-coholic.de>
2022-06-07 18:34:44 +02:00
Martin Gross
50d4ed827d PPv2: Remove stray text suffix 2022-06-07 18:29:57 +02:00
Martin Gross
a0218093f2 Update po files
[CI skip]

Signed-off-by: Martin Gross <gross@rami.io>
2022-06-07 18:19:01 +02:00
Martin Gross
ea920fb67e PPv2: Change APM redirect text 2022-06-07 18:16:20 +02:00
Martin Gross
73f166c54a PPv2: Simplify "use the button/form below" on paypage. 2022-06-07 18:14:54 +02:00
Martin Gross
5b5cd72f80 PPv2: Load PayPal SDK in current cart language whenever possible 2022-06-07 18:11:34 +02:00
Martin Gross
87b3f91ad3 PPv2: ISU: Retry up to three times to retrieve connected merchant information before failing. 2022-06-07 17:25:39 +02:00
Raphael Michel
7cefd69b4e Order API: Do not lock event with infinite quota when creating an order (#2675) 2022-06-07 17:21:12 +02:00
Raphael Michel
597089a89b PayPal: Patch API client to cache access tokens (#2600) 2022-06-07 17:20:38 +02:00
Abdullah
b2c76a9e36 Translations: Update Arabic
Currently translated at 81.3% (3838 of 4718 strings)

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

powered by weblate
2022-06-07 17:04:31 +02:00
Raphael Michel
846be07a5e Fix ticket download date not being shown 2022-06-07 12:12:22 +02:00
Martin Gross
2d3cd8f3dc PPv2: Limit description and custom_id to 127 chars (Z#23101013) 2022-06-07 09:39:32 +02:00
Raphael Michel
0dfef2699f Silence ResponseError from redis (#2674) 2022-06-06 12:27:33 +02:00
dependabot[bot]
97e74e4afb Bump @babel/preset-env from 7.17.10 to 7.18.2 in /src/pretix/static/npm_dir (#2673)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@rami.io>
2022-06-06 11:53:43 +02:00
dependabot[bot]
fe04d97d51 Bump @babel/core from 7.17.10 to 7.18.2 in /src/pretix/static/npm_dir (#2672)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-06 11:52:44 +02:00
dependabot[bot]
911917c9d1 Bump rollup from 2.71.1 to 2.75.5 in /src/pretix/static/npm_dir (#2671)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-06 11:52:36 +02:00
dependabot[bot]
14bb17435b Bump @rollup/plugin-node-resolve from 13.2.1 to 13.3.0 in /src/pretix/static/npm_dir (#2670)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-06 11:52:29 +02:00
Mauro Amico
a8c78674bd Upgrade NodeJS to version 16 LTS (#2664) 2022-06-06 11:52:22 +02:00
Tommi
a49de96416 Translations: Update Italian
Currently translated at 18.1% (855 of 4718 strings)

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

powered by weblate
2022-06-06 11:51:51 +02:00
Marco Giacopuzzi
9f7dca8288 Translations: Update Italian
Currently translated at 18.1% (855 of 4718 strings)

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

powered by weblate
2022-06-06 11:51:51 +02:00
Ismael Menéndez Fernández
be9c40939e Translations: Update Galician
Currently translated at 11.8% (557 of 4718 strings)

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

powered by weblate
2022-06-06 11:51:51 +02:00
Aya Yabuki
eec092ef8d Translations: Update Japanese
Currently translated at 3.7% (176 of 4718 strings)

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

powered by weblate
2022-06-06 11:51:51 +02:00
Ismael Menéndez Fernández
8c35b1c1a7 Translations: Update Galician
Currently translated at 11.7% (553 of 4718 strings)

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

powered by weblate
2022-06-06 11:51:51 +02:00
Ismael Menéndez Fernández
61ad81277e Translations: Update Spanish
Currently translated at 63.4% (2995 of 4718 strings)

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

powered by weblate
2022-06-06 11:51:51 +02:00
Raphael Michel
d98cb6402c Improve handling of protected subevents during deletion 2022-06-03 14:05:20 +02:00
Raphael Michel
772a4ce494 Order list: Use constant width for sales channel icon 2022-06-03 14:05:20 +02:00
Raphael Michel
825673b0c5 Seating plan migration: Fix deletion of carts if addons are present 2022-06-03 14:05:20 +02:00
Martin Gross
ea6c698b3a PPv2: Call get_fees without explicit payment provider. 2022-06-01 12:20:28 +02:00
Martin Gross
d2d6a30623 PPv2: minor XHR/get_fees cleanup 2022-06-01 12:12:49 +02:00
Martin Gross
68097291ca PPv2: Include other fees than payment fees into the XHR-calculation 2022-06-01 12:10:58 +02:00
Martin Gross
a8286f77d8 PPv2: Fix fee calculation if no payment fee is present 2022-06-01 10:58:02 +02:00
Martin Gross
d8e96c16bb Add t.paypal.com to img-src CSP 2022-06-01 10:07:55 +02:00
Martin Gross
e20c2c56f0 PPv2: Surface error-messages if XHR-call fails 2022-05-31 19:23:57 +02:00
Martin Gross
823de60e8c PPv2: Make XHR view a proper view and not a TemplateView 2022-05-31 19:02:55 +02:00
Raphael Michel
25fb5fb741 Fix inconsistent translation 2022-05-31 16:48:56 +02:00
Martin Gross
017638cc29 PPv2: Only transmit the user's main language without any possible "-informal"-tags 2022-05-31 16:15:29 +02:00
Martin Gross
4e37acf8d4 PPv2: Do not run capture if PPOrder has not been approved by user. 2022-05-31 12:01:30 +02:00
Martin Gross
40d273e145 PayPal v2: Control-view: Show Capture ID instead of Order ID 2022-05-30 17:07:52 +02:00
Raphael Michel
88f4ee0f95 Event timeline: Always show effective end of sale 2022-05-30 16:47:25 +02:00
Raphael Michel
925b8334a9 PayPal: Migrate to Order v2 API and ISU authentication (#2493) (#2614)
Co-authored-by: Raphael Michel <michel@rami.io>
Co-authored-by: Martin Gross <gross@rami.io>
2022-05-30 15:44:22 +02:00
Raphael Michel
2e0be8c801 Allow to filter subevents by sales channel 2022-05-27 18:17:56 +02:00
Raphael Michel
6306b8e97d Bump to 4.11.0.dev0 2022-05-27 17:16:40 +02:00
Raphael Michel
927745ca13 Bump to 4.10.0 2022-05-27 17:15:58 +02:00
Raphael Michel
251b2c4b5c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4718 of 4718 strings)

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

powered by weblate
2022-05-27 16:25:59 +02:00
Raphael Michel
c1fd8a5b7b Translations: Update German
Currently translated at 100.0% (4718 of 4718 strings)

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

powered by weblate
2022-05-27 16:25:59 +02:00
Raphael Michel
7d9b002ef5 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4718 of 4718 strings)

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

powered by weblate
2022-05-27 16:25:59 +02:00
Raphael Michel
a03e2387b0 Translations: Update German
Currently translated at 100.0% (4718 of 4718 strings)

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

powered by weblate
2022-05-27 16:25:59 +02:00
Raphael Michel
3b12ab8b82 Remove print statement 2022-05-27 15:58:18 +02:00
Raphael Michel
63a6a8cfd3 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-05-27 15:57:32 +02:00
Raphael Michel
6ee649f91e PDF editor: Fix saving QR codes with custom i18n content 2022-05-27 14:16:09 +02:00
Ismael Menéndez Fernández
72646d00e7 Translations: Update Galician
Currently translated at 11.6% (551 of 4732 strings)

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

powered by weblate
2022-05-27 08:55:44 +02:00
Mie Frydensbjerg
fe24683495 Translations: Update Danish
Currently translated at 36.3% (1721 of 4732 strings)

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

powered by weblate
2022-05-27 08:55:44 +02:00
Ismael Menéndez Fernández
da425533cf Translations: Update Galician
Currently translated at 11.5% (546 of 4732 strings)

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

powered by weblate
2022-05-27 08:55:44 +02:00
Ismael Menéndez Fernández
2024fc8792 Translations: Update Spanish
Currently translated at 63.2% (2993 of 4732 strings)

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

powered by weblate
2022-05-27 08:55:44 +02:00
Mauro Amico
335e96d1c9 Translations: Update Italian
Currently translated at 17.7% (840 of 4732 strings)

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

powered by weblate
2022-05-27 08:55:44 +02:00
Emanat Institute
936bc882f0 Translations: Update Slovenian
Currently translated at 26.9% (1276 of 4732 strings)

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

powered by weblate
2022-05-27 08:55:44 +02:00
Ola Ola
7a749e2c56 Translations: Update Ukrainian
Currently translated at 76.7% (3632 of 4732 strings)

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

powered by weblate
2022-05-27 08:55:44 +02:00
hmontheline
7ed01f55f6 Translations: Update French
Currently translated at 47.6% (2257 of 4732 strings)

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

powered by weblate
2022-05-27 08:55:44 +02:00
Olha Dolinska
76fd1be397 Translations: Update Ukrainian
Currently translated at 76.3% (3615 of 4732 strings)

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

powered by weblate
2022-05-27 08:55:44 +02:00
Ismael Menéndez Fernández
5ab1116aed Translations: Update Galician
Currently translated at 11.4% (543 of 4732 strings)

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

powered by weblate
2022-05-27 08:55:44 +02:00
Ismael Menéndez Fernández
1437dde1e1 Translations: Update Spanish
Currently translated at 63.1% (2988 of 4732 strings)

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

powered by weblate
2022-05-27 08:55:44 +02:00
Raphael Michel
64aca08d34 Week calendar: Fix interference between mobile mode and widget 2022-05-25 17:29:56 +02:00
Raphael Michel
3790d04ed2 Add experimental API call to query idempotency logs 2022-05-25 13:00:31 +02:00
Raphael Michel
d1644e62f0 Remove a sentence if payment_pending_hidden is set 2022-05-24 11:15:50 +02:00
Raphael Michel
96a656bc8a Fix bug in test test_cartpos_create_with_voucher_invalid_subevent 2022-05-24 10:55:17 +02:00
Richard Schreiber
763c003487 Fix alignment of checkout success message (Z#2399724)
When a customized checkout_success_text is used, that has other elements than `<p>`, the alignment was off. This fixes the alignment for all types of elements, that are a direct child of the thank-you-element `.thank-you > *`.
2022-05-24 10:04:12 +02:00
Raphael Michel
81c251208c Cart API: Fix validation of subevent-bound vouchers 2022-05-23 17:55:14 +02:00
Richard Schreiber
9ca2c8894d Fix #2651 - Crash when editing add-on products after order 2022-05-23 13:21:11 +02:00
Raphael Michel
d4825d00fb Fix copying variations when copying items 2022-05-20 16:09:57 +02:00
Raphael Michel
591f5a75ef Fix error handling on add-ons selection step (#2659)
Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2022-05-19 13:27:28 +02:00
Dennis Lichtenthäler
15407732ea Translations: Update German
Currently translated at 100.0% (4732 of 4732 strings)

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

powered by weblate
2022-05-19 09:58:18 +02:00
Ola Ola
82534f49da Translations: Update Ukrainian
Currently translated at 76.3% (3615 of 4732 strings)

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

powered by weblate
2022-05-19 09:58:18 +02:00
Raphael Michel
6c7f76fe96 Orders API: Allow downloading tickets for pending orders (#2657)
Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2022-05-19 09:58:06 +02:00
Raphael Michel
08590f9d98 Explicitly store whether checkins were offline (#2617) 2022-05-17 14:32:14 +02:00
Raphael Michel
074252a9c0 SecretKeySettingsWidget: Fix issue during form validation 2022-05-17 13:56:38 +02:00
Raphael Michel
615f7ed2cf Translations: Update Ukrainian
Currently translated at 76.3% (3612 of 4732 strings)

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

powered by weblate
2022-05-13 17:13:05 +02:00
Iryna Loik
e3a4435356 Translations: Update Ukrainian
Currently translated at 76.3% (3612 of 4732 strings)

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

powered by weblate
2022-05-13 17:13:05 +02:00
Jonathan Berger
e8ec2a8d1f Translations: Update French
Currently translated at 47.6% (2254 of 4732 strings)

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

powered by weblate
2022-05-13 17:13:05 +02:00
hmontheline
17cac62c31 Translations: Update French
Currently translated at 47.6% (2254 of 4732 strings)

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

powered by weblate
2022-05-13 17:13:05 +02:00
Jonathan Berger
73beabedea Translations: Update French
Currently translated at 47.4% (2244 of 4732 strings)

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

powered by weblate
2022-05-13 17:13:05 +02:00
Iryna Loik
93f0e818e4 Translations: Update Ukrainian
Currently translated at 69.9% (3309 of 4732 strings)

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

powered by weblate
2022-05-13 17:13:05 +02:00
Iryna N
54ff5967fc Translations: Update Ukrainian
Currently translated at 69.9% (3309 of 4732 strings)

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

powered by weblate
2022-05-13 17:13:05 +02:00
Richard Schreiber
41b18b9419 Make customer identifier unique per organizer (#2647) 2022-05-13 16:40:34 +02:00
Raphael Michel
750a2511d5 Fix incorrect escaping of QR code secrets 2022-05-13 16:36:48 +02:00
Richard Schreiber
e1c6103dc4 Limit identifiers (Question, QuestionOption, Customer) to alphanum, dot, dash, and underscore 2022-05-12 17:24:17 +02:00
Richard Schreiber
5e88a3cfc3 PDF Editor: Fix CSS-selector for non-alphanum question identifiers (Z#2399663) 2022-05-12 14:01:00 +02:00
Iryna N
f9c71743d1 Translations: Update Ukrainian
Currently translated at 68.0% (3221 of 4732 strings)

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

powered by weblate
2022-05-12 10:00:16 +02:00
Raphael Michel
ed4bc87198 Show better error message if a gift card is used in apply_voucher 2022-05-11 17:06:20 +02:00
Richard Schreiber
351e06168e PDF Editor: set textfield to ??? to hint at unknown placeholder (Z#2399245) 2022-05-11 13:46:22 +02:00
Richard Schreiber
75dc134b45 PDF Editor: support Mac-like (CMD/metaKey) keyboard shortcuts 2022-05-11 13:44:50 +02:00
Richard Schreiber
53419b9e49 Fix check for mariadb 2022-05-11 13:41:45 +02:00
Raphael Michel
aca3e29bd2 Bump django-compressor to 3.1, get rid of annoying warnings (#2459) 2022-05-10 14:13:19 +02:00
Raphael Michel
2fcd6bb3f5 API: Support creating cart positions with vouchers (#2635) 2022-05-10 12:19:04 +02:00
Richard Schreiber
25313bf044 Add placeholder name_for_salutation to editor/PDF to improve handling of salutation „Mx“ (#2639)
* add name_for_salutation to editor/pdf

* improve order of fields

* add safe fallback for attendee_name_parts being None
2022-05-10 11:36:10 +02:00
Iryna Loik
7012605c9e Translations: Update Ukrainian
Currently translated at 63.6% (3012 of 4732 strings)

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

powered by weblate
2022-05-10 11:33:55 +02:00
Iryna N
5e8ce33470 Translations: Update Ukrainian
Currently translated at 84.5% (169 of 200 strings)

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

powered by weblate
2022-05-10 11:33:55 +02:00
Iryna N
4dfc037267 Translations: Update Ukrainian
Currently translated at 63.1% (2988 of 4732 strings)

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

powered by weblate
2022-05-10 11:33:55 +02:00
Raphael Michel
64ac69a81a Seating frame view: Allow to pass a voucher from query parameter 2022-05-10 11:31:27 +02:00
Richard Schreiber
40297b3d3f Localize salutation of invoice address in editor/PDFs 2022-05-10 11:22:54 +02:00
Raphael Michel
ead70686b4 Fix some language<>flag associations 2022-05-09 23:23:22 +02:00
Raphael Michel
d0051fbd43 Fix incorrect language code for Ukrainian 2022-05-09 19:05:10 +02:00
Raphael Michel
0672a32052 Add Ukrainian language 2022-05-09 17:52:19 +02:00
Raphael Michel
2371373415 Allow voucher access for pretixPOS 2022-05-09 17:52:19 +02:00
dependabot[bot]
0d9101e592 Bump @babel/core from 7.17.8 to 7.17.10 in /src/pretix/static/npm_dir (#2620)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-09 17:51:48 +02:00
dependabot[bot]
0d76b3ac8d Bump @babel/preset-env from 7.16.11 to 7.17.10 in /src/pretix/static/npm_dir (#2619)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-09 17:50:48 +02:00
dependabot[bot]
f9899b36db Bump @rollup/plugin-node-resolve from 13.1.3 to 13.2.1 in /src/pretix/static/npm_dir (#2618)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-09 17:50:39 +02:00
dependabot[bot]
69a9cf9c4a Bump rollup from 2.70.1 to 2.71.1 in /src/pretix/static/npm_dir (#2621)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-09 17:50:31 +02:00
Raphael Michel
60bf7571f3 Translations: Update Ukrainian
Currently translated at 60.1% (2846 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Олександра Сергіївна Миргородська
b682816447 Translations: Update Ukrainian
Currently translated at 60.1% (2846 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
b2e0ca554f Translations: Update Ukrainian
Currently translated at 60.1% (2846 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
97a9ed61a9 Translations: Update Ukrainian
Currently translated at 60.1% (2846 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Richard Schreiber
97fe10b399 Translations: Update Ukrainian
Currently translated at 60.1% (2846 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
hmontheline
3c37d6373b Translations: Update French
Currently translated at 47.4% (2243 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
d4124e95d2 Translations: Update Ukrainian
Currently translated at 56.5% (2678 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Ola Ola
e34da34872 Translations: Update Ukrainian
Currently translated at 56.5% (2677 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
6686df36a3 Translations: Update Ukrainian
Currently translated at 56.5% (2677 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Emanuele Signoretta
8ccca887db Translated on translate.pretix.eu (Italian)
Currently translated at 87.0% (174 of 200 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Emanuele Signoretta
94d5db767b Translations: Update Italian
Currently translated at 17.1% (812 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
8b9e88aa93 Translations: Update Ukrainian
Currently translated at 54.6% (2584 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
5afac69500 Translations: Update Ukrainian
Currently translated at 38.9% (1843 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
6acce86f73 Translations: Update Ukrainian
Currently translated at 38.9% (1843 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
4776921092 Translations: Update Ukrainian
Currently translated at 38.9% (1843 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
80f7d12800 Translations: Update Ukrainian
Currently translated at 32.7% (1552 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
404e537ae6 Translations: Update Ukrainian
Currently translated at 32.7% (1552 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
25d5634a58 Translations: Update Ukrainian
Currently translated at 32.5% (1539 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
79b88bbaed Translations: Update Ukrainian
Currently translated at 32.5% (1539 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
89695122bb Translations: Update Ukrainian
Currently translated at 32.4% (1535 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
58461cc510 Translations: Update Ukrainian
Currently translated at 32.4% (1535 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
7f8ec0814e Translations: Update Ukrainian
Currently translated at 32.3% (1532 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
47daedec00 Translations: Update Ukrainian
Currently translated at 32.3% (1530 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
0ea1a830ac Translations: Update Ukrainian
Currently translated at 32.3% (1530 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
b52eef3972 Translations: Update Ukrainian
Currently translated at 32.2% (1528 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
323954a293 Translations: Update Ukrainian
Currently translated at 32.2% (1528 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
e591e3cb3d Translations: Update Ukrainian
Currently translated at 32.2% (1526 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
8abeba8dd1 Translations: Update Ukrainian
Currently translated at 32.2% (1526 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
3e2bb6dbe4 Translations: Update Ukrainian
Currently translated at 32.1% (1521 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
ce21b4f969 Translations: Update Ukrainian
Currently translated at 32.1% (1521 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
fdf6c2c0d9 Translations: Update Ukrainian
Currently translated at 32.0% (1518 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
4b68ee5627 Translations: Update Ukrainian
Currently translated at 32.0% (1518 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
b899975e36 Translations: Update Ukrainian
Currently translated at 31.9% (1511 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
42b747166e Translations: Update Ukrainian
Currently translated at 31.9% (1511 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
440c23c4ab Translations: Update Ukrainian
Currently translated at 31.7% (1504 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
b399d0ab51 Translations: Update Ukrainian
Currently translated at 31.7% (1502 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
59e78cabbf Translations: Update Ukrainian
Currently translated at 31.7% (1502 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
25d752aa7f Translations: Update Ukrainian
Currently translated at 31.6% (1499 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
dd18fa9e8e Translations: Update Ukrainian
Currently translated at 31.6% (1499 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
a9581562cc Translations: Update Ukrainian
Currently translated at 31.6% (1497 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
2f48721a7b Translations: Update Ukrainian
Currently translated at 31.6% (1496 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
84c54d54f2 Translations: Update Ukrainian
Currently translated at 31.6% (1496 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
bf83bd58dc Translations: Update Ukrainian
Currently translated at 31.5% (1494 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
aa17fa230f Translations: Update Ukrainian
Currently translated at 31.5% (1493 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
f3ecfc32db Translations: Update Ukrainian
Currently translated at 31.5% (1492 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
b9aae4b851 Translations: Update Ukrainian
Currently translated at 31.5% (1492 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
57a19280dd Translations: Update Ukrainian
Currently translated at 31.4% (1487 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
2fa64c6fd4 Translations: Update Ukrainian
Currently translated at 31.4% (1487 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
4b70cf67b1 Translations: Update Ukrainian
Currently translated at 31.2% (1477 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
e0fee19456 Translations: Update Ukrainian
Currently translated at 31.2% (1477 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
abccf1e317 Translations: Update Ukrainian
Currently translated at 31.1% (1472 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
587f24738d Translations: Update Ukrainian
Currently translated at 31.1% (1472 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
51bcfca3f3 Translations: Update Ukrainian
Currently translated at 30.9% (1466 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
7e701c2459 Translations: Update Ukrainian
Currently translated at 30.9% (1466 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Olha Dolinska
89a6792999 Translations: Update Ukrainian
Currently translated at 30.9% (1465 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Олександра Сергіївна Миргородська
2cf4bcf71c Translations: Update Ukrainian
Currently translated at 30.9% (1465 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
0a31761d74 Translations: Update Ukrainian
Currently translated at 30.9% (1465 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
f96b7a5202 Translations: Update Ukrainian
Currently translated at 29.1% (1379 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
e381021bdd Translations: Update Ukrainian
Currently translated at 28.8% (1365 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Richard Schreiber
0c1555c76e Translations: Update Ukrainian
Currently translated at 28.8% (1365 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
661a03942c Translations: Update Ukrainian
Currently translated at 28.7% (1360 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Yuliya Palmova
eb1bd89b19 Translations: Update Ukrainian
Currently translated at 28.4% (1346 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
ab788e5792 Translations: Update Ukrainian
Currently translated at 28.4% (1346 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Yuliya Palmova
c9f04f8366 Translations: Update Ukrainian
Currently translated at 27.0% (1278 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
b50b21db08 Translations: Update Ukrainian
Currently translated at 27.0% (1278 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
702d85cde9 Translations: Update Ukrainian
Currently translated at 26.9% (1275 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Yuliya Palmova
dd6ed1d623 Translations: Update Ukrainian
Currently translated at 26.8% (1271 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
440fd49f65 Translations: Update Ukrainian
Currently translated at 26.8% (1271 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
f448a67705 Translations: Update Ukrainian
Currently translated at 26.5% (1255 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
7278bd755b Translations: Update Ukrainian
Currently translated at 26.5% (1255 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Viktoriia
e69ed2d0ae Translations: Update Ukrainian
Currently translated at 26.5% (1255 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
d8175ab867 Translations: Update Ukrainian
Currently translated at 25.5% (1208 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
hmontheline
fa08ed0292 Translations: Update Spanish
Currently translated at 62.9% (2980 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
hmontheline
a8221092e1 Translations: Update French
Currently translated at 47.3% (2242 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna Loik
8d449941d9 Translations: Update Ukrainian
Currently translated at 23.9% (1134 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
1f9159d81b Translations: Update Ukrainian
Currently translated at 23.9% (1134 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
aba8a5b813 Translated on translate.pretix.eu (Ukrainian)
Currently translated at 84.0% (168 of 200 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
a12214ae3a Translations: Update Ukrainian
Currently translated at 18.0% (853 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Viktoriia
e2279e1c79 Translations: Update Ukrainian
Currently translated at 9.6% (457 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
16d17fe78b Translations: Update Ukrainian
Currently translated at 9.6% (457 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
81af4bf1a5 Translations: Update Ukrainian
Currently translated at 8.8% (420 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
hmontheline
66efc5cce8 Translations: Update French
Currently translated at 47.3% (2240 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
bc9e4c91dd Translations: Update Ukrainian
Currently translated at 8.8% (419 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
hmontheline
898aeed8f4 Translations: Update French
Currently translated at 47.3% (2241 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Iryna N
0412f4465c Translations: Update Ukrainian
Currently translated at 6.0% (285 of 4732 strings)

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

powered by weblate
2022-05-09 17:50:05 +02:00
Richard Schreiber
2124161744 Localize salutation when creating PDFs (#2631) 2022-05-09 17:44:01 +02:00
Maico Timmerman
37230dd657 Widget: Fix trigger_close_callback() (#2626) 2022-05-09 17:43:50 +02:00
Richard Schreiber
9f515a4b4e Fix: converting old to new question placeholders in ticketlayouts 2022-05-06 12:23:29 +02:00
Richard Schreiber
ff5c649cfc Fix: Ignore string identifiers when converting old to new question placeholders 2022-05-05 17:40:17 +02:00
Richard Schreiber
dc0caed540 Badges: Ignore trimBox when using background PDF (Z#2398854) 2022-05-04 10:15:14 +02:00
Richard Schreiber
b33ac1910e Fix: match content to placeholder for textfield in editor (ticket, badge) 2022-05-04 09:06:39 +02:00
Raphael Michel
1aadfe3535 Fix AttributeError when clearing a cropped picture field 2022-05-03 16:26:57 +02:00
Raphael Michel
ed0ae0140a Show tax column in invoices if reverse charge is active 2022-05-03 15:13:57 +02:00
Raphael Michel
de6ca763a1 Fix issue in event cloning 2022-04-29 18:10:04 +02:00
Raphael Michel
6c06d72bf1 Fix all offline scans being marked as forced 2022-04-29 15:55:55 +02:00
Raphael Michel
a2413db65d Small tweaks to new customer fields 2022-04-29 14:47:42 +02:00
Raphael Michel
2a8faf1d12 Force organizer page to allowed languages 2022-04-29 14:43:38 +02:00
Richard Schreiber
edff7b8717 Add note field and external identifier to customers (#2605) 2022-04-29 14:43:08 +02:00
Raphael Michel
657cdd07ab Order list export: Include canceled positions (#2612)
* Order list exporter: Include canceled positions

* Review fix
2022-04-29 12:10:41 +02:00
Raphael Michel
e1db207487 Add test coverage and small improvements to event cloning logic (#2611) 2022-04-29 12:09:59 +02:00
Henryk Plötz
86d47fcdd1 Change npm install call in setup.py (#2610) 2022-04-29 12:00:23 +02:00
Edd28
e7a71a1cfd Translated on translate.pretix.eu (Romanian)
Currently translated at 100.0% (200 of 200 strings)

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

powered by weblate
2022-04-29 11:58:53 +02:00
Raphael Michel
ce8c50de53 Extend wordlist for spellcheck 2022-04-29 11:55:11 +02:00
Richard Schreiber
3cc0955523 Fix isort issue 2022-04-29 11:32:23 +02:00
Raphael Michel
3fc8e12d9a Revert "PayPal: Migrate to Order v2 API and ISU authentication (#2493)"
This reverts commit 9af1565db1.
2022-04-28 20:58:39 +02:00
Raphael Michel
6671d01c19 Fix typo in source string 2022-04-28 20:33:20 +02:00
Raphael Michel
31cbcc2528 Translated on translate.pretix.eu (German (informal) (de_Informal))
Currently translated at 100.0% (200 of 200 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Raphael Michel
00000e14e0 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (200 of 200 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Raphael Michel
f97012b412 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4732 of 4732 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Raphael Michel
5fba84ebf5 Translations: Update German
Currently translated at 100.0% (4732 of 4732 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Martin Gross
dedc029c91 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (200 of 200 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Martin Gross
a47271d257 Translations: Update German
Currently translated at 100.0% (4732 of 4732 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Raphael Michel
5d217dc384 Translations: Update German
Currently translated at 100.0% (4732 of 4732 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Martin Gross
0144842be9 Translations: Update German
Currently translated at 99.9% (4729 of 4732 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Raphael Michel
c0c59ddfbf Translations: Update German (informal) (de_Informal)
Currently translated at 99.6% (4716 of 4732 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Raphael Michel
4fb83b7129 Translations: Update German
Currently translated at 99.8% (4727 of 4732 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Martin Gross
6512dd6d40 Translations: Update German
Currently translated at 99.8% (4727 of 4732 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Martin Gross
f3dd6f9949 Translations: Update German
Currently translated at 99.6% (4716 of 4732 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Edd28
f992299129 Translations: Update Romanian
Currently translated at 98.8% (4663 of 4717 strings)

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

powered by weblate
2022-04-28 20:07:25 +02:00
Raphael Michel
b5a8d7e863 Extend wordlist for spellcheck 2022-04-28 20:04:20 +02:00
Raphael Michel
0d785616dd Extend wordlist for spellcheck 2022-04-28 19:51:52 +02:00
Raphael Michel
241eb00113 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-04-28 18:44:46 +02:00
Martin Gross
9af1565db1 PayPal: Migrate to Order v2 API and ISU authentication (#2493)
Co-authored-by: Raphael Michel <michel@rami.io>
2022-04-28 18:42:19 +02:00
Raphael Michel
129d206946 Docs: Add secret placeholder to digital content documentation 2022-04-28 14:08:45 +02:00
Raphael Michel
4b14595531 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4709 of 4709 strings)

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

powered by weblate
2022-04-28 13:18:49 +02:00
Raphael Michel
b3fa551163 Translations: Update German
Currently translated at 100.0% (4709 of 4709 strings)

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

powered by weblate
2022-04-28 13:18:49 +02:00
Edd28
5baa2e2902 Translations: Update Romanian
Currently translated at 98.8% (4663 of 4717 strings)

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

powered by weblate
2022-04-28 13:18:49 +02:00
Edd28
1a916dfba2 Translated on translate.pretix.eu (Romanian)
Currently translated at 100.0% (174 of 174 strings)

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

powered by weblate
2022-04-28 13:18:49 +02:00
Edd28
1eedfd1615 Translations: Update Romanian
Currently translated at 98.8% (4663 of 4716 strings)

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

powered by weblate
2022-04-28 13:18:49 +02:00
Raphael Michel
ed6fbf67f7 Clone discounts when cloning events 2022-04-28 12:52:34 +02:00
Raphael Michel
704988449f Fix crash in API bulk cart creation 2022-04-28 09:06:40 +02:00
Raphael Michel
a6e52dffe7 Fix subtraction in migration 2022-04-27 17:01:42 +02:00
Raphael Michel
c39ce8b610 Fix another typo 2022-04-27 16:22:32 +02:00
Raphael Michel
3f1521a3c5 Fix typos 2022-04-27 16:03:19 +02:00
Raphael Michel
14f8ed8843 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4709 of 4709 strings)

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

powered by weblate
2022-04-27 16:00:18 +02:00
Raphael Michel
e29e967f01 Translations: Update German
Currently translated at 100.0% (4709 of 4709 strings)

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

powered by weblate
2022-04-27 16:00:18 +02:00
Iryna N
ed4c870d0a Translations: Update Ukrainian
Currently translated at 4.9% (229 of 4662 strings)

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

powered by weblate
2022-04-27 16:00:18 +02:00
Edd28
66a684d847 Translations: Update Romanian
Currently translated at 100.0% (4662 of 4662 strings)

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

powered by weblate
2022-04-27 16:00:18 +02:00
Raphael Michel
c72852832b Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-04-27 14:44:56 +02:00
Raphael Michel
6fee0ac0a9 Discounts (#2510) 2022-04-27 14:43:16 +02:00
Raphael Michel
7730cc6170 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 86.4% (4032 of 4662 strings)

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

powered by weblate
2022-04-27 11:16:05 +02:00
Raphael Michel
b88aff0a22 Bump to 4.10.0.dev0 2022-04-27 10:56:19 +02:00
Raphael Michel
5add5656fe Bump version to 4.9.0 2022-04-27 10:55:27 +02:00
Iryna N
621c7e1682 Translated on translate.pretix.eu (Ukrainian)
Currently translated at 89.6% (156 of 174 strings)

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

powered by weblate
2022-04-27 10:23:25 +02:00
Iryna N
beddab5d03 Translations: Update Ukrainian
Currently translated at 4.6% (215 of 4662 strings)

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

powered by weblate
2022-04-27 10:23:25 +02:00
Edd28
52308cb793 Translations: Update Romanian
Currently translated at 100.0% (4662 of 4662 strings)

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

powered by weblate
2022-04-27 10:23:25 +02:00
Edd28
4a45f4f877 Translated on translate.pretix.eu (Romanian)
Currently translated at 100.0% (174 of 174 strings)

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

powered by weblate
2022-04-27 10:23:25 +02:00
Iryna N
a0e8d50356 Translated on translate.pretix.eu (Ukrainian)
Currently translated at 41.9% (73 of 174 strings)

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

powered by weblate
2022-04-27 10:23:25 +02:00
Raphael Michel
8d8c6bcee5 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4662 of 4662 strings)

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

powered by weblate
2022-04-27 10:23:25 +02:00
Raphael Michel
04521287eb Translations: Update German
Currently translated at 100.0% (4662 of 4662 strings)

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

powered by weblate
2022-04-27 10:23:25 +02:00
Iryna N
8963166bcf Translated on translate.pretix.eu (Ukrainian)
Currently translated at 27.5% (48 of 174 strings)

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

powered by weblate
2022-04-27 10:23:25 +02:00
Iryna N
9f71056b56 Translations: Update Ukrainian
Currently translated at 0.1% (2 of 4661 strings)

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

powered by weblate
2022-04-27 10:23:25 +02:00
Raphael Michel
24acc7e159 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-04-26 18:23:13 +02:00
Edd28
3825c384bf Translations: Update Romanian
Currently translated at 66.8% (3117 of 4662 strings)

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

powered by weblate
2022-04-26 18:22:43 +02:00
Raphael Michel
3626d8c642 Bump PyPDF2 version 2022-04-26 15:00:57 +02:00
Raphael Michel
7a5d5d08c0 Allow MultiStringFields to be nullable 2022-04-26 14:39:23 +02:00
fsnaix
276bb12edb Translations: Update Vietnamese
Currently translated at 0.3% (15 of 4660 strings)

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

powered by weblate
2022-04-25 18:22:43 +02:00
Edd28
28411feff6 Translations: Update Romanian
Currently translated at 66.8% (3116 of 4662 strings)

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

powered by weblate
2022-04-25 18:22:43 +02:00
Edd28
1118b01dbd Translated on translate.pretix.eu (Romanian)
Currently translated at 100.0% (174 of 174 strings)

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

powered by weblate
2022-04-25 18:22:43 +02:00
fsnaix
22221fc413 Translations: Update Vietnamese
Currently translated at 0.2% (12 of 4661 strings)

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

powered by weblate
2022-04-25 18:22:43 +02:00
fsnaix
50ece6c1fc Translations: Add Vietnamese 2022-04-25 18:22:43 +02:00
Edd28
5de2d60ff8 Translations: Update Romanian
Currently translated at 52.4% (2446 of 4661 strings)

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

powered by weblate
2022-04-25 18:22:43 +02:00
Edd28
4eed155acb Translated on translate.pretix.eu (Romanian)
Currently translated at 98.2% (171 of 174 strings)

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

powered by weblate
2022-04-25 18:22:43 +02:00
Aya Yabuki
5915abd7cb Translations: Update Japanese
Currently translated at 3.5% (165 of 4661 strings)

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

powered by weblate
2022-04-25 18:22:43 +02:00
Edd28
52d926d698 Translations: Update Romanian
Currently translated at 5.4% (256 of 4661 strings)

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

powered by weblate
2022-04-25 18:22:43 +02:00
Raphael Michel
2b81e983d4 Add Romanian language, reorder languages 2022-04-25 18:10:35 +02:00
Raphael Michel
55dc7fd988 Fix typo 2022-04-25 12:42:39 +02:00
Richard Schreiber
01bcf114b2 Calendar view: do not strike-through labels of non-bookable events (#2602) 2022-04-25 12:37:01 +02:00
Raphael Michel
adbf76a09f Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-04-21 17:18:34 +02:00
Raphael Michel
316081658a Check-in rules: Add now_isoweekday and minutes_since_last_entry (#2577) 2022-04-21 17:17:59 +02:00
hmontheline
0aff74afc6 Translations: Update French
Currently translated at 48.1% (2240 of 4656 strings)

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

powered by weblate
2022-04-21 17:17:47 +02:00
Edd28
f13daafa39 Translations: Update Romanian
Currently translated at 4.1% (193 of 4656 strings)

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

powered by weblate
2022-04-21 17:17:47 +02:00
Edd28
ff449b801f Translations: Update Romanian
Currently translated at 3.9% (185 of 4656 strings)

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

powered by weblate
2022-04-21 17:17:47 +02:00
Basarabeanu Bogdan-Robert
9bdb72aa06 Translations: Update Romanian
Currently translated at 3.9% (184 of 4656 strings)

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

powered by weblate
2022-04-21 17:17:47 +02:00
Edd28
148727917b Translations: Update Romanian
Currently translated at 3.9% (184 of 4656 strings)

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

powered by weblate
2022-04-21 17:17:47 +02:00
Edd28
33b25aa981 Translated on translate.pretix.eu (Romanian)
Currently translated at 100.0% (171 of 171 strings)

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

powered by weblate
2022-04-21 17:17:47 +02:00
Raphael Michel
e01c417c1e Clarify difference between order_modified and order_changed 2022-04-21 17:17:24 +02:00
Raphael Michel
6d050b4d2b Set a maximum value for reservation_time to prevent OverflowError 2022-04-21 14:10:23 +02:00
Raphael Michel
e9c440ceed POS: Allow clients to fetch payment details 2022-04-15 14:09:51 +02:00
Raphael Michel
5cd79ee2b0 Adjust German translation of "Box office" 2022-04-15 13:35:08 +02:00
Raphael Michel
15c6e22414 POS: Show reference for Zettle payments 2022-04-15 13:13:57 +02:00
Raphael Michel
8d04a0183a Docs: Updates to exhibitor API 2022-04-14 15:53:35 +02:00
Richard Schreiber
2f6881934e Calendar view: improve differentiation between events’ states (Z#173939) (#2595) 2022-04-14 15:51:46 +02:00
Richard Schreiber
8f7bc59214 Calendar view: make focus-outline visible (#2594) 2022-04-14 15:51:20 +02:00
Raphael Michel
9bf3b54a83 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4658 of 4658 strings)

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

powered by weblate
2022-04-13 09:52:47 +02:00
Raphael Michel
82cd6e320d Translations: Update German
Currently translated at 100.0% (4658 of 4658 strings)

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

powered by weblate
2022-04-13 09:52:47 +02:00
Raphael Michel
e308b38d6f Fix string formatting issue in log message 2022-04-12 17:14:43 +02:00
Raphael Michel
6b7a2e1981 Fix flake8 issue 2022-04-12 11:27:00 +02:00
Richard Schreiber
d19cb14dc1 Voucher redemption: Raise 404 error if subevent_pk is not an integer (#2590)
Co-authored-by: Raphael Michel <michel@rami.io>
2022-04-12 11:12:06 +02:00
Raphael Michel
3e8e454e92 Fix gettext formatting issues 2022-04-12 10:53:03 +02:00
Raphael Michel
f46de92303 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4658 of 4658 strings)

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

powered by weblate
2022-04-12 10:50:20 +02:00
Raphael Michel
aeba9542be Translations: Update German
Currently translated at 100.0% (4658 of 4658 strings)

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

powered by weblate
2022-04-12 10:50:20 +02:00
Raphael Michel
a380044639 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4646 of 4646 strings)

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

powered by weblate
2022-04-12 10:50:20 +02:00
Raphael Michel
4cbe50f3a2 Translations: Update German
Currently translated at 100.0% (4646 of 4646 strings)

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

powered by weblate
2022-04-12 10:50:20 +02:00
Raphael Michel
278d54e780 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-04-12 10:33:01 +02:00
Raphael Michel
9634598952 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4646 of 4646 strings)

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

powered by weblate
2022-04-12 10:32:02 +02:00
Raphael Michel
b60583168b Translations: Update German
Currently translated at 100.0% (4646 of 4646 strings)

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

powered by weblate
2022-04-12 10:32:02 +02:00
Raphael Michel
ea8630d3d7 Fix order email subject in wrong language if plugin changes order.locale (#2588) 2022-04-12 10:31:49 +02:00
Raphael Michel
3cdf578c14 Allow to add a comment when cancelling an order (#2580) 2022-04-12 09:53:02 +02:00
Richard Schreiber
f7c0921f18 Check-in list: Fix salutation not being localized in CSV-export (Z#184037) (#2586) 2022-04-12 09:51:20 +02:00
Raphael Michel
a7ae556478 Fix order email subject in wrong language if plugin changes order.locale 2022-04-12 09:33:10 +02:00
Raphael Michel
a755bfd22c Allow to bulk-edit devices (#2583)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2022-04-12 08:54:45 +02:00
Raphael Michel
22920a7318 Analyze check-in rules for missing products (#2582) 2022-04-11 18:53:07 +02:00
Raphael Michel
cf6a8c333a Improve visual indicators in multiselect select2 components 2022-04-09 14:26:39 +02:00
Raphael Michel
2623bfd2db Item selection typeahead: Use consistent ordering of items 2022-04-09 14:26:27 +02:00
Raphael Michel
dcc1a93b72 Fix CI builds (#2581) 2022-04-08 17:50:48 +02:00
Raphael Michel
3aeea82d2e Allow to filter log view by action type 2022-04-07 17:58:56 +02:00
Raphael Michel
732621f121 Fix incorrect log display of reverted checkins 2022-04-07 17:58:45 +02:00
Raphael Michel
24e7be4142 Allow plugins to declare fonts "pdf-only" 2022-04-07 17:58:33 +02:00
Raphael Michel
20c6f0b327 Fix logging in shell_scoped with --override 2022-04-07 14:24:27 +02:00
luto
e4817518d8 Add Czech translation 2022-04-07 14:07:16 +02:00
Eva-Maria Obermann
35c443f90f Translated on translate.pretix.eu (French)
Currently translated at 63.7% (109 of 171 strings)

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

powered by weblate
2022-04-07 14:07:00 +02:00
Eva-Maria Obermann
de669156cd Translations: Update French
Currently translated at 48.1% (2237 of 4646 strings)

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

powered by weblate
2022-04-07 14:07:00 +02:00
Liga V
d8e3b49b04 Translated on translate.pretix.eu (Latvian)
Currently translated at 100.0% (171 of 171 strings)

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

powered by weblate
2022-04-07 14:07:00 +02:00
Svyatoslav
567f68965d Translations: Update Latvian
Currently translated at 46.3% (2154 of 4646 strings)

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

powered by weblate
2022-04-07 14:07:00 +02:00
Zane Smite
65f6892896 Translations: Update Latvian
Currently translated at 46.3% (2154 of 4646 strings)

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

powered by weblate
2022-04-07 14:07:00 +02:00
Liga V
ec9800b215 Translations: Update Latvian
Currently translated at 46.3% (2154 of 4646 strings)

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

powered by weblate
2022-04-07 14:07:00 +02:00
Svyatoslav
9010d8f6a1 Translations: Update Latvian
Currently translated at 43.0% (2001 of 4646 strings)

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

powered by weblate
2022-04-07 14:07:00 +02:00
Svyatoslav
f260945fdf Translations: Update Latvian
Currently translated at 43.0% (2000 of 4646 strings)

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

powered by weblate
2022-04-07 14:07:00 +02:00
Liga V
b7241825c3 Translations: Update Latvian
Currently translated at 43.0% (2000 of 4646 strings)

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

powered by weblate
2022-04-07 14:07:00 +02:00
Raphael Michel
c9ed155870 PDF renderer: Fix placeholers using meta: and itemmeta: 2022-04-07 12:40:40 +02:00
Martin Gross
69d0a20674 Checkin Log: Sort devices by device ID 2022-04-07 09:10:03 +02:00
Raphael Michel
b699e8977a Fix incorrect translation of <= from json logic to SQL 2022-04-06 18:56:53 +02:00
Raphael Michel
4f25d8ba89 shell_scoped: Add --print-sql argument 2022-04-06 18:56:18 +02:00
Raphael Michel
71f5303a5e Fix invisible error message when mixing gift card products and admission products 2022-04-06 16:44:20 +02:00
Raphael Michel
69f91e54e6 Make error message about tax rules and gift cards easier to understand 2022-04-06 16:43:52 +02:00
Raphael Michel
bcee2c231a Use internal name of product in filter forms 2022-04-06 13:08:16 +02:00
dependabot[bot]
d1745bb703 Bump rollup from 2.68.0 to 2.70.1 in /src/pretix/static/npm_dir (#2567)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-05 11:05:20 +02:00
dependabot[bot]
f468b393c0 Bump @babel/core from 7.17.5 to 7.17.8 in /src/pretix/static/npm_dir (#2568)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-05 11:05:09 +02:00
Maico Timmerman
4b0c38e4ee Widget: Introduce addCloseListener (#2569) 2022-04-05 11:04:54 +02:00
Maico Timmerman
6768bbb486 Event cancellation: Fix default email subject translations (#2574) 2022-04-05 11:04:12 +02:00
Liga V
f2c9b46d3e Translations: Update Latvian
Currently translated at 39.2% (1825 of 4646 strings)

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

powered by weblate
2022-04-05 11:03:12 +02:00
Liga V
b7a3db2ac0 Translations: Update Latvian
Currently translated at 39.0% (1816 of 4646 strings)

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

powered by weblate
2022-04-05 11:03:12 +02:00
Liga V
307a6654f2 Translations: Update Latvian
Currently translated at 37.1% (1726 of 4646 strings)

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

powered by weblate
2022-04-05 11:03:12 +02:00
Ismael Menéndez Fernández
f2cc8c77e8 Translations: Update Spanish
Currently translated at 64.1% (2982 of 4646 strings)

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

powered by weblate
2022-04-05 11:03:12 +02:00
Raphael Michel
830d48255e Translations: Update Latvian
Currently translated at 37.1% (1726 of 4646 strings)

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

powered by weblate
2022-04-05 11:03:12 +02:00
Liga V
df2b428aa1 Translated on translate.pretix.eu (Latvian)
Currently translated at 100.0% (171 of 171 strings)

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

powered by weblate
2022-04-05 11:03:12 +02:00
Liga V
7d1d05de02 Translations: Update Latvian
Currently translated at 37.1% (1726 of 4646 strings)

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

powered by weblate
2022-04-05 11:03:12 +02:00
Liga V
921d8b6057 Translations: Update Latvian
Currently translated at 30.8% (1432 of 4646 strings)

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

powered by weblate
2022-04-05 11:03:12 +02:00
Ismael Menéndez Fernández
0b13ec49f1 Translations: Update Galician
Currently translated at 11.5% (538 of 4646 strings)

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

powered by weblate
2022-04-05 11:03:12 +02:00
Anna-itk
62ef89f87c Translated on translate.pretix.eu (Danish)
Currently translated at 58.4% (100 of 171 strings)

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

powered by weblate
2022-04-05 11:03:12 +02:00
Anna-itk
d0920caf32 Translations: Update Danish
Currently translated at 37.0% (1720 of 4646 strings)

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

powered by weblate
2022-04-05 11:03:12 +02:00
Raphael Michel
d0474afdfe Fix further Pillow compatibility issues 2022-04-05 09:57:11 +02:00
Raphael Michel
4ab298dd10 Order API search: Disable voucher search to work around an immediate performance problem 2022-04-05 09:55:08 +02:00
Raphael Michel
db39b89ae4 Fix Pillow import to resolve deprecation warning 2022-04-04 19:14:53 +02:00
Raphael Michel
162ae3ead7 Waiting list: Use a more neutral success message 2022-04-04 18:42:17 +02:00
Raphael Michel
65a7e8516e Display self-service cancellation fee during backend cancellation 2022-04-04 13:42:26 +02:00
Raphael Michel
cccd4af6dd Create log entry for question when cloing an item 2022-04-04 12:04:15 +02:00
Raphael Michel
751cfdf203 Fix attendee emails not being sent on free boxoffice orders 2022-04-04 12:02:03 +02:00
Raphael Michel
898776b617 Fix revocation of secrets that did not exist in the first place 2022-04-03 16:33:02 +02:00
Raphael Michel
b4db81d6c3 Update MANIFEST.in 2022-03-31 22:56:13 +02:00
Raphael Michel
e96fdf2a2c Bump to 4.9.0.dev0 2022-03-31 22:42:44 +02:00
Raphael Michel
9052b39f9c Bump to 4.8.0 2022-03-31 22:41:45 +02:00
Raphael Michel
725725223e Docs: Remove duplicate API endpoint 2022-03-31 21:25:06 +02:00
pretix translation bot
71b4c3117f Update translations (#2565)
Co-authored-by: Raphael Michel <michel@rami.io>
2022-03-31 20:57:12 +02:00
Raphael Michel
5e1dc5ac5b Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-03-31 14:36:20 +02:00
Raphael Michel
0b403b7b3a Fix accidental test failures 2022-03-30 18:12:39 +02:00
dependabot[bot]
be46a00d38 Bump minimist from 1.2.5 to 1.2.6 in /src/pretix/static/npm_dir (#2557)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-30 18:03:12 +02:00
Raphael Michel
22f3412ad0 Allow users to see the number of checkins (#2561)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2022-03-30 18:03:05 +02:00
Anna-itk
c23a3fcfcd Translations: Update Danish
Currently translated at 37.0% (1720 of 4648 strings)

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

powered by weblate
2022-03-30 18:01:42 +02:00
Martin Gross
1412b0afdb Translations: Update Danish
Currently translated at 36.9% (1719 of 4648 strings)

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

powered by weblate
2022-03-30 18:01:42 +02:00
Anna-itk
60f14b6a68 Translations: Update Danish
Currently translated at 36.9% (1719 of 4648 strings)

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

powered by weblate
2022-03-30 18:01:42 +02:00
alroiv
dc440d6cc5 Translations: Update Romanian
Currently translated at 1.4% (67 of 4636 strings)

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

powered by weblate
2022-03-30 18:01:42 +02:00
alroiv
7cef7b4d5d Translations: Update Spanish
Currently translated at 64.3% (2982 of 4636 strings)

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

powered by weblate
2022-03-30 18:01:42 +02:00
Raphael Michel
659a587cdf PDF render: Fall back to seat of parent position (#2559) 2022-03-30 17:44:14 +02:00
Raphael Michel
ee72009e73 Improvements to PDF text placeholders (#2562) 2022-03-30 17:43:26 +02:00
Raphael Michel
69375f4092 API: Allow to change orders (#2552) 2022-03-30 17:36:10 +02:00
Raphael Michel
76475039b5 Allow to print expiry date of gift card in PDFs (#2560) 2022-03-30 17:25:29 +02:00
Martin Gross
8a8524a346 ItemList: Order items by category the same way as in presale and CategoryList 2022-03-29 10:14:34 +02:00
Raphael Michel
e7996c693a Fix phone number not being saved in customer registration form 2022-03-27 15:09:53 +02:00
Richard Schreiber
551bd3e284 Fix #2556 -- Remove attestation from 2FA-device registration
As we currently do not verify attestation in 2FA-device registration, we can safely remove it. This circumvents a bug in webkit when registering Touch-ID as 2FA-device on M1 Macs. See https://bugs.webkit.org/show_bug.cgi?id=224042

For more info on why we do not use attestation, see https://fidoalliance.org/fido-technotes-the-truth-about-attestation/
2022-03-25 12:25:15 +01:00
301 changed files with 178270 additions and 103004 deletions

View File

@@ -55,7 +55,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-
- name: Install system dependencies
run: sudo apt update && sudo apt install gettext mariadb-client
run: sudo apt update && sudo apt install gettext mariadb-client-10.3
- name: Install Python dependencies
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
working-directory: ./src

View File

@@ -31,7 +31,7 @@ RUN apt-get update && \
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
mkdir /static && \
mkdir /etc/supervisord && \
curl -fsSL https://deb.nodesource.com/setup_15.x | sudo -E bash - && \
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash - && \
apt-get install -y nodejs && \
curl -qL https://www.npmjs.com/install.sh | sh

View File

@@ -157,7 +157,7 @@
<div class="rst-content">
{% include "breadcrumbs.html" %}
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
<div itemprop="articleBody">
<div itemprop="articleBody" class="section">
{% block body %}{% endblock %}
</div>
<div class="articleComments">

View File

@@ -60,6 +60,7 @@ that your clients can deal with them properly:
* Support of new HTTP methods for a given API endpoint
* Support of new query parameters for a given API endpoint
* New fields contained in API responses
* Response body structure or message texts on failed requests (``4xx``, ``5xx`` response codes)
We treat the following types of changes as *backwards-incompatible*:

View File

@@ -172,8 +172,6 @@ Cart position endpoints
* does not check or calculate prices but believes any prices you send
* does not support the redemption of vouchers
* does not prevent you from buying items that can only be bought with a voucher
* does not support file upload questions
@@ -189,8 +187,9 @@ Cart position endpoints
* ``attendee_email`` (optional)
* ``subevent`` (optional)
* ``expires`` (optional)
* ``includes_tax`` (optional)
* ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
* ``sales_channel`` (optional)
* ``voucher`` (optional, expect a voucher code)
* ``answers``
* ``question``

View File

@@ -14,6 +14,7 @@ The customer resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
identifier string Internal ID of the customer
external_identifier string External ID of the customer (or ``null``)
email string Customer email address
name string Name of this customer (or ``null``)
name_parts object of strings Decomposition of name (i.e. given name, family name)
@@ -24,6 +25,7 @@ last_login datetime Date and time o
date_joined datetime Date and time of registration
locale string Preferred language of the customer
last_modified datetime Date and time of modification of the record
notes string Internal notes and comments (or ``null``)
===================================== ========================== =======================================================
.. versionadded:: 4.0
@@ -58,6 +60,7 @@ Endpoints
"results": [
{
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "customer@example.org",
"name": "John Doe",
"name_parts": {
@@ -69,7 +72,8 @@ Endpoints
"last_login": null,
"date_joined": "2021-04-06T13:44:22.809216Z",
"locale": "de",
"last_modified": "2021-04-06T13:44:22.809377Z"
"last_modified": "2021-04-06T13:44:22.809377Z",
"notes": null
}
]
}
@@ -103,6 +107,7 @@ Endpoints
{
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "customer@example.org",
"name": "John Doe",
"name_parts": {
@@ -114,7 +119,8 @@ Endpoints
"last_login": null,
"date_joined": "2021-04-06T13:44:22.809216Z",
"locale": "de",
"last_modified": "2021-04-06T13:44:22.809377Z"
"last_modified": "2021-04-06T13:44:22.809377Z",
"notes": null
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -150,6 +156,7 @@ Endpoints
{
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "test@example.org",
...
}
@@ -193,6 +200,7 @@ Endpoints
{
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": "test@example.org",
}
@@ -226,6 +234,7 @@ Endpoints
{
"identifier": "8WSAJCJ",
"external_identifier": null,
"email": null,
}

View File

@@ -0,0 +1,306 @@
.. _`rest-discounts`:
Discounts
=========
Resource description
--------------------
Discounts provide a way to automatically reduce the price of a cart if it matches a given set of conditions.
Discounts are available to everyone. If you want to give a discount just to specific persons, look at
:ref:`vouchers <rest-vouchers>` instead. If you are interested in the behind-the-scenes details of how
discounts are calculated for a specific order, have a look at :ref:`our algorithm documentation <algorithms-pricing>`.
.. rst-class:: rest-resource-table
======================================== ========================== =======================================================
Field Type Description
======================================== ========================== =======================================================
id integer Internal ID of the discount rule
active boolean The discount will be ignored if this is ``false``
internal_name string A name for the rule used in the backend
position integer An integer, used for sorting the rules which are applied in order
sales_channels list of strings Sales channels this discount is available on, such as
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
available_from datetime The first date time at which this discount can be applied
(or ``null``).
available_until datetime The last date time at which this discount can be applied
(or ``null``).
subevent_mode strings Determines how the discount is handled when used in an
event series. Can be ``"mixed"`` (no special effect),
``"same"`` (discount is only applied for groups within
the same date), or ``"distinct"`` (discount is only applied
for groups with no two same dates).
condition_all_products boolean If ``true``, the discount applies to all items.
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.
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
applies to bundled products.
condition_ignore_voucher_discounted boolean If ``true``, the discount does not apply to products which have
been discounted by a voucher.
condition_min_count integer The minimum number of matching products for the discount
to be activated.
condition_min_value money (string) The minimum value of matching products for the discount
to be activated. Cannot be combined with ``condition_min_count``,
or with ``subevent_mode`` set to ``distinct``.
benefit_discount_matching_percent decimal (string) The percentage of price reduction for matching products.
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.
Cannot be combined with ``condition_min_value``.
======================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/discounts/
Returns a list of all discounts within a given event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/discounts/ 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": [
{
"id": 1,
"active": true,
"internal_name": "3 for 2",
"position": 1,
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:query boolean active: If set to ``true`` or ``false``, only discounts with this value for the field ``active`` will be
returned.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``.
Default: ``position``
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/discounts/(id)/
Returns information on one discount, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/discounts/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"active": true,
"internal_name": "3 for 2",
"position": 1,
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the discount to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/discounts/
Creates a new discount
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/discounts/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"active": true,
"internal_name": "3 for 2",
"position": 1,
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"active": true,
"internal_name": "3 for 2",
"position": 1,
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
:param organizer: The ``slug`` field of the organizer of the event to create a discount for
:param event: The ``slug`` field of the event to create a discount for
:statuscode 201: no error
:statuscode 400: The discount could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/discounts/(id)/
Update a discount. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``id`` field.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/discounts/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"active": false
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"active": false,
"internal_name": "3 for 2",
"position": 1,
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"subevent_mode": "mixed",
"condition_all_products": true,
"condition_limit_products": [],
"condition_apply_to_addons": true,
"condition_ignore_voucher_discounted": false,
"condition_min_count": 3,
"condition_min_value": "0.00",
"benefit_discount_matching_percent": "100.00",
"benefit_only_apply_to_cheapest_n_matches": 1
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the discount to modify
:statuscode 200: no error
:statuscode 400: The discount could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/discount/(id)/
Delete a discount.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/discount/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the discount to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.

View File

@@ -24,6 +24,7 @@ at :ref:`plugin-docs`.
orders
invoices
vouchers
discounts
checkinlists
waitinglist
customers

View File

@@ -68,6 +68,7 @@ positions list of objects List of order p
non-canceled positions are included.
fees list of objects List of fees included in the order total. By default, only
non-canceled fees are included.
├ id integer Internal ID of the fee record
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
``other``)
├ value money (string) Fee amount
@@ -136,6 +137,10 @@ last_modified datetime Last modificati
The ``subevent`` query parameters has been added.
.. versionchanged:: 4.8
The ``order.fees.id`` attribute has been added.
.. _order-position-resource:
@@ -604,13 +609,17 @@ Fetching individual orders
Order ticket download
---------------------
.. versionchanged:: 4.10
The API now supports ticket downloads for pending orders if allowed by the event settings.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/download/(output)/
Download tickets for an order, identified by its order code. Depending on the chosen output, the response might
be a ZIP file, PDF file or something else. The order details response contains a list of output options for this
particular order.
Tickets can be only downloaded if the order is paid and if ticket downloads are active. Note that in some cases the
Tickets can only be downloaded if ticket downloads are active and depending on event settings the order is either paid or pending. Note that in some cases the
ticket file might not yet have been created. In that case, you will receive a status code :http:statuscode:`409` and
you are expected to retry the request after a short period of waiting.
@@ -735,6 +744,37 @@ Generating new secrets
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/regenerate_secrets/
Triggers generation of a new ``secret`` attribute for a single order position.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23/regenerate_secrets/ 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
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``id`` field of the order position to update
:statuscode 200: no error
:statuscode 400: The order position could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position.
Deleting orders
---------------
@@ -1046,6 +1086,9 @@ Order state operations
will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation
fee as the only component of the order.
You can control whether the customer is notified through ``send_email`` (defaults to ``true``).
You can pass a ``comment`` that can be visible to the user if it is used in the email template.
**Example request**:
.. sourcecode:: http
@@ -1057,6 +1100,7 @@ Order state operations
{
"send_email": true,
"comment": "Event was canceled.",
"cancellation_fee": null
}
@@ -1595,6 +1639,10 @@ Fetching individual positions
Order position ticket download
------------------------------
.. versionchanged:: 4.10
The API now supports ticket downloads for pending orders if allowed by the event settings.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/download/(output)/
Download tickets for one order position, identified by its internal ID.
@@ -1606,7 +1654,7 @@ Order position ticket download
The referenced URL can provide a download or a regular, human-viewable website - so it is advised to open this URL
in a webbrowser and leave it up to the user to handle the result.
Tickets can be only downloaded if the order is paid and if ticket downloads are active. Also, depending on event
Tickets can only be downloaded if ticket downloads are active and depending on event settings the order is either paid or pending. Also, depending on event
configuration downloads might be only unavailable for add-on products or non-admission products.
Note that in some cases the ticket file might not yet have been created. In that case, you will receive a status
code :http:statuscode:`409` and you are expected to retry the request after a short period of waiting.
@@ -1642,6 +1690,8 @@ Order position ticket download
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
.. _rest-orderpositions-manipulate:
Manipulating individual positions
---------------------------------
@@ -1649,6 +1699,11 @@ Manipulating individual positions
The ``PATCH`` method has been added for individual positions.
.. versionchanged:: 4.8
The ``PATCH`` method now supports changing items, variations, subevents, seats, prices, and tax rules.
The ``POST`` endpoint to add individual positions has been added.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Updates specific fields on an order position. Currently, only the following fields are supported:
@@ -1675,6 +1730,21 @@ Manipulating individual positions
and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value
``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it.
* ``item``
* ``variation``
* ``subevent``
* ``seat`` (specified as a string mapping to a ``string_guid``)
* ``price``
* ``tax_rule``
Changing parameters such as ``item`` or ``price`` will **not** automatically trigger creation of a new invoice,
you need to take care of that yourself.
**Example request**:
.. sourcecode:: http
@@ -1696,7 +1766,7 @@ Manipulating individual positions
Vary: Accept
Content-Type: application/json
(Full order resource, see above.)
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
@@ -1707,9 +1777,83 @@ Manipulating individual positions
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
Adds a new position to an order. Currently, only the following fields are supported:
* ``order`` (mandatory, specified as a string mapping to a ``code``)
* ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position)
* ``item`` (mandatory)
* ``variation`` (mandatory depending on item)
* ``subevent`` (mandatory depending on event)
* ``seat`` (specified as a string mapping to a ``string_guid``, mandatory depending on event and item)
* ``price`` (default price will be used if unset)
* ``attendee_email``
* ``attendee_name_parts`` or ``attendee_name``
* ``company``
* ``street``
* ``zipcode``
* ``city``
* ``country``
* ``state``
* ``answers``: Validation is handled the same way as when creating orders through the API. You are therefore
expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier``
and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value
``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it.
This will **not** automatically trigger creation of a new invoice, you need to take care of that yourself.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"order": "ABC12",
"item": 5,
"addon_to": 1
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:statuscode 200: no error
:statuscode 400: The position could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this position.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Deletes an order position, identified by its internal ID.
Cancels an order position, identified by its internal ID.
**Example request**:
@@ -1735,6 +1879,128 @@ Manipulating individual positions
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order position does not exist.
Changing order contents
-----------------------
While you can :ref:`change positions individually <rest-orderpositions-manipulate>` sometimes it is necessary to make
multiple changes to an order at once within one transaction. This makes it possible to e.g. swap the seats of two
attendees in an order without running into conflicts. This interface also offers some possibilities not available
otherwise, such as splitting an order or changing fees.
.. versionchanged:: 4.8
This endpoint has been added to the system.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/change/
Performs a change operation on an order. You can supply the following fields:
* ``patch_positions``: A list of objects with the two keys ``position`` specifying an order position ID and
``body`` specifying the desired changed values of the position (``item``, ``variation``, ``subevent``, ``seat``,
``price``, ``tax_rule``).
* ``cancel_positions``: A list of objects with the single key ``position`` specifying an order position ID.
* ``split_positions``: A list of objects with the single key ``position`` specifying an order position ID.
* ``create_positions``: A list of objects describing new order positions with the same fields supported as when
creating them individually through the ``POST …/orderpositions/`` endpoint.
* ``patch_fees``: A list of objects with the two keys ``fee`` specifying an order fee ID and
``body`` specifying the desired changed values of the position (``value``).
* ``cancel_fees``: A list of objects with the single key ``fee`` specifying an order fee ID.
* ``recalculate_taxes``: If set to ``"keep_net"``, all taxes will be recalculated based on the tax rule and invoice
address, the net price will be kept. If set to ``"keep_gross"``, the gross price will be kept. If set to ``null``
(the default) the taxes are not recalculated.
* ``send_email``: If set to ``true``, the customer will be notified about the change. Defaults to ``false``.
* ``reissue_invoice``: If set to ``true`` and an invoice exists for the order, it will be canceled and a new invoice
will be issued. Defaults to ``true``.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"cancel_positions": [
{
"position": 12373
}
],
"patch_positions": [
{
"position": 12374,
"body": {
"item": 12,
"variation": None,
"subevent": 562,
"seat": "seat-guid-2",
"price": "99.99",
"tax_rule": 15
}
}
],
"split_positions": [
{
"position": 12375
}
],
"create_positions": [
{
"item": 12,
"variation": None,
"subevent": 562,
"seat": "seat-guid-2",
"price": "99.99",
"addon_to": 12374,
"attendee_name": "Peter",
}
],
"cancel_fees": [
{
"fee": 49
}
],
"change_fees": [
{
"fee": 51,
"body": {
"value": "12.00"
}
}
],
"reissue_invoice": true,
"send_email": true,
"recalculate_taxes": "keep_gross"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``code`` field of the order to update
:statuscode 200: no error
:statuscode 400: The order could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
Order payment endpoints
-----------------------

View File

@@ -474,6 +474,7 @@ Endpoints
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
: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 event: The ``slug`` field of the event to fetch
:statuscode 200: no error

View File

@@ -1,3 +1,5 @@
.. _`rest-vouchers`:
Vouchers
========

View File

@@ -9,5 +9,6 @@ ticket scanning apps and we want to ensure the implementations are as similar as
.. toctree::
:maxdepth: 2
pricing
checkin
layouts

View File

@@ -0,0 +1,180 @@
.. _`algorithms-pricing`:
Pricing algorithms
==================
With pretix being an e-commerce application, one of its core tasks is to determine the price of a purchase. With the
complexity allowed by our range of features, this is not a trivial task and there are many edge cases that need to be
clearly defined. The most challenging part about this is that there are many situations in which a price might change
while the user is going through the checkout process and we're learning more information about them or their purchase.
For example, prices change when
* The cart expires and the listed prices changed in the meantime
* The user adds an invoice address that triggers a change in taxation
* The user chooses a custom price for an add-on product and adjusts the price later on
* The user adds a voucher to their cart
* An automatic discount is applied
For the purposes of this page, we're making a distinction between "naive prices" (which are just a plain number like 23.00), and
"taxed prices" (which are a combination of a net price, a tax rate, and a gross price, like 19.33 + 19% = 23.00).
Computation of listed prices
----------------------------
When showing a list of products, e.g. on the event front page, we always need to show a price. This price is what we
call the "listed price" later on.
To compute the listed price, we first use the ``default_price`` attribute of the ``Item`` that is being shown.
If we are showing an ``ItemVariation`` and that variation has a ``default_price`` set on itself, the variation's price
takes precedence and replaces the item's price.
If we're in an event series and there exists a ``SubEventItem`` or ``SubEventItemVariation`` with a price set, the
subevent's price configuration takes precedence over both the item as well as the variation and replaces the listed price.
Listed prices are naive prices. Before we actually show them to the user, we need to check if ``TaxRule.price_includes_tax``
is set to determine if we need to add tax or subtract tax to get to the taxed price. We then consider the event's
``display_net_prices`` setting to figure out which way to present the taxed price in the interface.
Guarantees on listed prices
---------------------------
One goal of all further logic is that if a user sees a listed price, they are guaranteed to get the product at that
price as long as they complete their purchase within the cart expiration time frame. For example, if the cart expiration
time is set to 30 minutes and someone puts a item listed at €23 in their cart at 4pm, they can still complete checkout
at €23 until 4.30pm, even if the organizer decides to raise the price to €25 at 4.10pm. If they complete checkout after
4.30pm, their cart will be adjusted to the new price and the user will see a warning that the price has changed.
Computation of cart prices
--------------------------
Input
"""""
To ensure the guarantee mentioned above, even in the light of all possible dynamic changes, the ``listed_price``
is explicitly stored in the ``CartPosition`` model after the item has been added to the cart.
If ``Item.free_price`` is set, the user is allowed to voluntarily increase the price. In this case, the user's input
is stored as ``custom_price_input`` without much further validation for use further down below in the process.
If ``display_net_prices`` is set, the user's input is also considered to be a net price and ``custom_price_input_is_net``
is stored for the cart position. In any other case, the user's input is considered to be a gross price based on the tax
rules' default tax rate.
The computation of prices in the cart always starts from the ``listed_price``. The ``list_price`` is only computed
when adding the product to the cart or when extending the cart's lifetime after it expired. All other steps such as
creating an order based on the cart trust ``list_price`` without further checks.
Vouchers
""""""""
As a first step, the cart is checked for any voucher that should be applied to the position. If such a voucher exists,
it's discount (percentage or fixed) is applied to the listed price. The result of this is stored to ``price_after_voucher``.
Since ``listed_price`` naive, ``price_after_voucher`` is naive as well. As a consequence, if you have a voucher configured
to "set the price to €10", it depends on ``TaxRule.price_includes_tax`` again whether this is €10 including or excluding
taxes.
The ``price_after_voucher`` is only computed when adding the product to the cart or when extending the cart's
lifetime after it expired. It is also checked again when the order is created, since the available discount might have
changed due to the voucher's budget being (almost) exhausted.
Line price
""""""""""
The next step computes the final price of this position if it is the only position in the cart. This happens in "reverse
order", i.e. before the computation can be performed for a cart position, the step needs to be performed on all of its
bundled positions. The sum of ``price_after_voucher`` of all bundled positions is now called ``bundled_sum``.
First, the value from ``price_after_voucher`` will be processed by the applicable ``TaxRule.tax()`` (which is complex
in itself but is not documented here in detail at the moment).
If ``custom_price_input`` is not set, ``bundled_sum`` will be subtracted from the gross price and the net price is
adjusted accordingly. The result is stored as ``tax_rate`` and ``line_price_gross`` in the cart position.
If ``custom_price_input`` is set, the value will be compared to either the gross or the net value of the ``tax()``
result, depending on ``custom_price_input_is_net``. If the comparison yields that the custom price is higher, ``tax()``
will be called again . Then, ``bundled_sum`` will be subtracted from the gross price and the result is stored like
above.
The computation of ``line_price_gross`` from ``price_after_voucher``, ``custom_price_input``, and tax settings
is repeated after every change of anything in the cart or after every change of the invoice address.
Discounts
---------
After ``line_price_gross`` has been computed for all positions, the discount engine will run to apply any automatic
discounts. Organizers can add rules for automatic discounts in the pretix backend. These rules are ordered and
will be applied in order. Every cart position can only be "used" by one discount rule. "Used" can either mean that
the price of the position was actually discounted, but it can also mean that the position was required to enable
a discount for a different position, e.g. in case of a "buy 3 for the price of 2" offer.
The algorithm for applying an individual discount rule first starts with eliminating all products that do not match
the rule based on its product scope. Then, the algorithm is handled differently for different configurations.
Case 1: Discount based on minimum value without respect to subevents
""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
* Check whether the gross sum of all positions is at least ``condition_min_value``, otherwise abort.
* Reduce the price of all positions by ``benefit_discount_matching_percent``.
* Mark all positions as "used" to hide them from further rules
Case 2: Discount based on minimum number of tickets without respect to subevents
""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""
* Check whether the number of all positions is at least ``condition_min_count``, otherwise abort.
* If ``benefit_only_apply_to_cheapest_n_maches`` is set,
* Sort all positions by price.
* Reduce the price of the first ``n_positions // condition_min_count * benefit_only_apply_to_cheapest_n_matches`` positions by ``benefit_discount_matching_percent``.
* Mark the first ``n_positions // condition_min_count * condition_min_count`` as "used" to hide them from further rules.
* Mark all positions as "used" to hide them from further rules.
* Else,
* Reduce the price of all positions by ``benefit_discount_matching_percent``.
* Mark all positions as "used" to hide them from further rules.
Case 3: Discount only for products of the same subevent
"""""""""""""""""""""""""""""""""""""""""""""""""""""""
* Split the cart into groups based on the subevent.
* Proceed with case 1 or 2 for every group.
Case 4: Discount only for products of distinct subevents
""""""""""""""""""""""""""""""""""""""""""""""""""""""""
* Let ``subevents`` be a list of distinct subevents in the cart.
* Let ``positions[subevent]`` be a list of positions for every subevent.
* Let ``current_group`` be the current group and ``groups`` the list of all groups.
* Repeat
* Order ``subevents`` by the length of their ``positions[subevent]`` list, starting with the longest list.
Do not count positions that are part of ``current_group`` already.
* Let ``candidates`` be the concatenation of all ``positions[subevent]`` lists with the same length as the
longest list.
* If ``candidates`` is empty, abort the repetition.
* Order ``candidates`` by their price, starting with the lowest price.
* Pick one entry from ``candidates`` and put it into ``current_group``. If ``current_group`` is shorter than
``benefit_only_apply_to_cheapest_n_matches``, we pick from the start (lowest price), otherwise we pick from
the end (highest price)
* If ``current_group`` is now ``condition_min_count``, remove all entries from ``current_group`` from
``positions[…]``, add ``current_group`` to ``groups``, and reset ``current_group`` to an empty group.
* For every position still left in a ``positions[…]`` list, try if there is any ``group`` in groups that it can
still be added to without violating the rule of distinct subevents
* For every group in ``groups``, proceed with case 1 or 2.
Flowchart
---------
.. image:: /images/cart_pricing.png

BIN
doc/images/cart_pricing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1,28 @@
@startuml
partition "For every cart position" {
(*) --> "Get default price from product"
--> if "Product has variations?" then
-->[yes] "Override with price from variation"
--> if "Event series?" then
-->[yes] "Override with price from subevent"
-down-> "Store as listed_price"
else
-down->[no] "Store as listed_price"
endif
else
-down->[no] "Store as listed_price"
endif
--> if "Voucher applied?" then
-->[yes] "Apply voucher pricing"
--> "Store as price_after_voucher"
else
-->[no] "Store as price_after_voucher"
endif
--> "Apply custom price if product allows\nApply tax rule\nSubtract bundled products"
--> "Store as line_price (gross), tax_rate"
}
--> "Apply discount engine"
--> "Store as price (gross)"
@enduml

View File

@@ -52,6 +52,7 @@ Variable Description
``order_email`` E-mail address of the ticket purchaser
``product_id`` Internal ID of the purchased product
``product_variation`` Internal ID of the purchased product variation (or empty)
``secret`` The secret ticket code, would be used as the QR code for physical tickets
``attendee_name`` Full name of the ticket holder (or empty)
``attendee_name_*`` Name parts of the ticket holder, depending on configuration, e.g. ``attendee_name_given_name`` or ``attendee_name_family_name``
``attendee_email`` E-mail address of the ticket holder (or empty)

View File

@@ -57,7 +57,10 @@ notes string A note taken by
tags list of strings Additional tags selected by the exhibitor
first_upload datetime Date and time of the first upload of this lead
data list of objects Attendee data set that may be shown to the exhibitor based o
the event's configuration. Each entry contains the fields ``id``, ``label``, and ``value``.
the event's configuration. Each entry contains the fields ``id``,
``label``, ``value``, and ``details``. ``details`` is usually empty
except in a few cases where it contains an additional list of objects
with ``value`` and ``label`` keys (e.g. splitting of names).
device_name string User-defined name for the device used for scanning (or ``null``).
===================================== ========================== =======================================================
@@ -205,7 +208,11 @@ Endpoints
{
"id": "attendee_name",
"label": "Attendee name",
"value": "Peter",
"value": "Peter Miller",
"details": [
{"label": "Given name", "value": "Peter"},
{"label": "Family name", "value": "Miller"},
]
}
]
}
@@ -220,6 +227,108 @@ Endpoints
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event or exhibitor does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/vouchers/
Returns a list of all vouchers connected to an exhibitor. The response contains the same data as described in
:ref:`rest-vouchers`.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/vouchers/ 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": [
{
"id": 1,
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
"valid_until": null,
"block_quota": false,
"allow_ignore_quota": false,
"price_mode": "set",
"value": "12.00",
"item": 1,
"variation": null,
"quota": null,
"tag": "testvoucher",
"comment": "",
"seat": null,
"subevent": null
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the exhibitor to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event or exhibitor does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/vouchers/attach/
Attaches an **existing** voucher to an exhibitor. You need to send either the ``id`` **or** the ``code`` field of
the voucher.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/vouchers/attach/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
{
"id": 15
}
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/vouchers/attach/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
{
"code": "43K6LKM37FBVR2YG"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{}
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event to use
:param id: The ``id`` field of the exhibitor to use
:statuscode 200: no error
:statuscode 400: Invalid data sent, e.g. voucher does not exist
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event or exhibitor does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/
Create a new exhibitor.
@@ -542,12 +651,17 @@ The request for this looks like this:
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe"
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com"
"value": "test@example.com",
"details": []
}
]
},
@@ -560,3 +674,59 @@ The request for this looks like this:
:statuscode 201: No error, leads was scanned for the first time
:statuscode 400: Invalid data submitted
:statuscode 401: Invalid authentication code
You can also fetch existing leads (if you are authorized to do so):
.. http:get:: /exhibitors/api/v1/leads/
**Example request:**
.. sourcecode:: http
GET /exhibitors/api/v1/leads/ HTTP/1.1
Authorization: Exhibitor ABCDE123
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": [
{
"attendee": {
"fields": [
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com",
"details": []
}
]
},
"rating": 4,
"tags": ["foo"],
"notes": "Great customer, wants our newsletter"
}
]
}
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data

View File

@@ -19,6 +19,7 @@ If you want to **create** a plugin, please go to the
certificates
digital
exhibitors
shipping
imported_secrets
webinar
presale-saml

235
doc/plugins/shipping.rst Normal file
View File

@@ -0,0 +1,235 @@
Shipping
========
The shipping plugin provides a HTTP API that exposes the various layouts used to generate PDF badges.
Shipping address resource
-------------------------
The shipping address resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
company string Customer company name
name string Customer name
street string Customer street
zipcode string Customer ZIP code
city string Customer city
country string Customer country code
state string Customer state (ISO 3166-2 code). Only supported in
AU, BR, CA, CN, MY, MX, and US.
gift boolean Request by customer to not disclose prices in the shipping
===================================== ========================== =======================================================
Shipping status resource
------------------------
The shipping status resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
method integer Internal ID of shipping method
status string Status, one of ``"new"`` or ``"shipped"``
method_type string Method type, one of ``"ship"``, ``"online"``, or ``"collect"``
===================================== ========================== =======================================================
Print job resource
------------------
The print job resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
code string Order code of the ticket order
event string Event slug
status string Status, one of ``"new"`` or ``"shipped"``
method string Method type, one of ``"ship"``, ``"online"``, or ``"collect"``
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/shippingaddress/
Returns the shipping address of an order
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/democon/orders/ABC12/shippingaddress/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"company": "ACME Corp",
"name": "John Doe",
"street": "Sesame Street 12\nAp. 5",
"zipcode": "12345",
"city": "Berlin",
"country": "DE",
"state": "",
"gift": false
}
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of a valid event
:param order: The ``code`` field of a valid order
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
:statuscode 404: The order does not exist or no shipping address is attached.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/shippingaddress/
Returns the shipping status of an order
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/democon/orders/ABC12/shippingstatus/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"method": 23,
"method_type": "ship",
"status": "new"
}
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of a valid event
:param order: The ``code`` field of a valid order
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
:statuscode 404: The order does not exist or no shipping address is attached.
.. http:get:: /api/v1/organizers/(organizer)/printjobs/
Returns a list of ticket orders, only useful with some query filters
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/printjobs/?method=ship&status=new HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"event": "democon",
"order": "ABC12",
"method": "ship",
"status": "new"
}
]
}
:query string method: Filter by response field ``method`` (can be passed multiple times)
:query string status: Filter by response field ``status``
:query string event: Filter by response field ``event``
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of a valid event
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/printjobs/poll/
Returns the PDF file for the next job to print.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/printjobs/poll/?method=ship&status=new 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/pdf
X-Pretix-Order-Code: ABC12
...
:query string method: Filter by response field ``method`` (can be passed multiple times)
:query string status: Filter by response field ``status``
:query string event: Filter by response field ``event``
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of a valid event
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/printjobs/(order)/ack/
Change an order's status to "shipped".
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/printjobs/ABC12/ack/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of a valid event
:param order: The ``code`` field of a valid order
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
:statuscode 404: The order does not exist.

View File

@@ -1,20 +1,44 @@
Use case: Group discounts
-------------------------
Often times, you want to give discounts for whole groups attending your event. pretix can't automatically discount based on volume, but there's still some ways you can set up group tickets.
Often times, you want to give discounts for whole groups attending your event.
Flexible group sizes
Automatic discounts
"""""""""""""""""""
pretix can automatically grant discounts if a certain condition is met, such as a specific group size. To set this up,
head to **Products**, **Discounts** in the event navigation and **Create a new discount**. You can choose a name so you
can later find this again. You can also optionally restrict the discount to a specific time frame or a specific sales
channel.
Next, either select **Apply to all products** or create a selection of products that are eligible for the discount.
For a **percentual group discount** similar to "if you buy at least 5 tickets, you get 20 percent off", set
**Minimum number of matching products** to "5" and **Percentual discount on matching products** to "20.00".
For a **buy-X-get-Y discount**, e.g. "if you buy 5 tickets, you get one free", set
**Minimum number of matching products** to "5", **Percentual discount on matching products** to "100.00", and
**Apply discount only to this number of matching products** to "1".
Fixed group packages
""""""""""""""""""""
If you want to give out discounted tickets to groups starting at a given size, but still billed per person, you can do so by creating a special **Group ticket** at the per-person price and set the **Minimum amount per order** option of the ticket to the minimal group size.
If you want to sell group tickets in fixed sizes, e.g. a table of eight at your gala dinner, you can use product bundles.
Assuming you already set up a ticket for admission of single persons, you then set up a second product **Table (8 persons)**
with a discounted full price. Then, head to the **Bundled products** tab of that product and add one bundle configuration
to include the single admission product **eight times**. Next, create an unlimited quota mapped to the new product.
This way, the purchase of a table will automatically create eight tickets, leading to a correct calculation of your total
quota and, as expected, eight persons on your check-in list. You can even ask for the individual names of the persons
during checkout.
Minimum order amount
""""""""""""""""""""
If you want to promote discounted group tickets in your price list, you can also do so by creating a special
**Group ticket** at the reduced per-person price and set the **Minimum amount per order** option of the ticket to the minimal
group size.
For more complex use cases, you can also use add-on products that can be chosen multiple times.
This way, your ticket can be bought an arbitrary number of times but no less than the given minimal amount per order.
Fixed group sizes
"""""""""""""""""
If you want to sell group tickets in fixed sizes, e.g. a table of eight at your gala dinner, you can use product bundles. Assuming you already set up a ticket for admission of single persons, you then set up a second product **Table (8 persons)** with a discounted full price. Then, head to the **Bundled products** tab of that product and add one bundle configuration to include the single admission product **eight times**. Next, create an unlimited quota mapped to the new product.
This way, the purchase of a table will automatically create eight tickets, leading to a correct calculation of your total quota and, as expected, eight persons on your check-in list. You can even ask for the individual names of the persons during checkout.

View File

@@ -253,18 +253,21 @@ If you want, you can suppress us loading the widget and/or modify the user data
If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
Waiting for the widget to load
------------------------------
Waiting for the widget to load or close
---------------------------------------
If you want to run custom JavaScript once the widget is fully loaded, you can register a callback function. Note that
this function might be run multiple times, for example if you have multiple widgets on a page or if the user switches
e.g. from an event list to an event detail view::
If you want to run custom JavaScript once the widget is fully loaded or when it is closed, you can register callback
functions. Note that these function might be run multiple times, for example if you have multiple widgets on a page
or if the user switches e.g. from an event list to an event detail view::
<script type="text/javascript">
window.pretixWidgetCallback = function () {
window.PretixWidget.addLoadListener(function () {
console.log("Widget has loaded!");
});
window.PretixWidget.addCloseListener(function () {
console.log("Widget has been closed!");
});
}
</script>

View File

@@ -84,7 +84,9 @@ going to develop around pretix, for example connect to pretix through our API, y
- A voucher is a code that can be used for multiple purposes: To grant a discount to specific customers, to only
show certain products to certain customers, or to keep a seat open for someone specific even though you are
sold out. If a voucher is used to apply a discount, the price of the purchased product is reduced by the
discounted amount. Vouchers are connected to a specific event.
* - | |:gb:| **(Automatic) Discount**
| |:de:| (Automatischer) Rabatt
- Discounts can be used to automatically provide discounts to customers if their cart satisfies a certain condition.
* - | |:gb:| **Gift card**
| |:de:| Wertgutschein
- A :ref:`gift card <giftcards>` is a coupon representing an exact amount of money that can be used for purchases

View File

@@ -13,6 +13,7 @@ recursive-include pretix/plugins/banktransfer/static *
recursive-include pretix/plugins/manualpayment/templates *
recursive-include pretix/plugins/manualpayment/static *
recursive-include pretix/plugins/paypal/templates *
recursive-include pretix/plugins/paypal/static *
recursive-include pretix/plugins/pretixdroid/templates *
recursive-include pretix/plugins/pretixdroid/static *
recursive-include pretix/plugins/sendmail/templates *

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
# <https://www.gnu.org/licenses/>.
#
__version__ = "4.8.0.dev0"
__version__ = "4.11.0.dev0"

View File

@@ -45,6 +45,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
allowlist = (
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -76,6 +77,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
allowlist = (
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -105,6 +107,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
allowlist = (
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -135,6 +138,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
allowlist = (
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('GET', 'api-v1:idempotency.query'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
@@ -151,6 +155,8 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:ticketlayoutitem-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:voucher-list'),
('GET', 'api-v1:voucher-detail'),
('GET', 'api-v1:order-list'),
('POST', 'api-v1:order-list'),
('GET', 'api-v1:order-detail'),

View File

@@ -23,6 +23,7 @@ import os
from datetime import timedelta
from django.core.files import File
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import gettext_lazy
@@ -33,13 +34,19 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
)
from pretix.base.models import Quota, Seat
from pretix.base.models import Quota, Seat, Voucher
from pretix.base.models.orders import CartPosition
class TaxIncludedField(serializers.Field):
def to_representation(self, instance: CartPosition):
return not instance.custom_price_input_is_net
class CartPositionSerializer(I18nAwareModelSerializer):
answers = AnswerSerializer(many=True)
seat = InlineSeatSerializer()
includes_tax = TaxIncludedField(source='*')
class Meta:
model = CartPosition
@@ -54,11 +61,13 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
sales_channel = serializers.CharField(required=False, default='sales_channel')
includes_tax = serializers.BooleanField(required=False, allow_null=True)
voucher = serializers.CharField(required=False, allow_null=True)
class Meta:
model = CartPosition
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel')
'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel', 'voucher')
def create(self, validated_data):
answers_data = validated_data.pop('answers')
@@ -118,15 +127,53 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError('The specified seat ID is not unique.')
else:
validated_data['seat'] = seat
if not seat.is_available(
sales_channel=validated_data.get('sales_channel', 'web'),
distance_ignore_cart_id=validated_data['cart_id'],
):
raise ValidationError(gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
elif seated:
raise ValidationError('The specified product requires to choose a seat.')
if validated_data.get('voucher'):
try:
voucher = self.context['event'].vouchers.get(code__iexact=validated_data.get('voucher'))
except Voucher.DoesNotExist:
raise ValidationError('The specified voucher does not exist.')
if voucher and not voucher.applies_to(validated_data.get('item'), validated_data.get('variation')):
raise ValidationError('The specified voucher is not valid for the given item and variation.')
if voucher and voucher.seat and voucher.seat != validated_data.get('seat'):
raise ValidationError('The specified voucher is not valid for this seat.')
if voucher and voucher.subevent_id and (not validated_data.get('subevent') or voucher.subevent_id != validated_data['subevent'].pk):
raise ValidationError('The specified voucher is not valid for this subevent.')
if voucher.valid_until is not None and voucher.valid_until < now():
raise ValidationError('The specified voucher is expired.')
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=voucher) & Q(event=self.context['event']) & Q(expires__gte=now())
)
cart_count = redeemed_in_carts.count()
v_avail = voucher.max_usages - voucher.redeemed - cart_count
if v_avail < 1:
raise ValidationError('The specified voucher has already been used the maximum number of times.')
validated_data['voucher'] = voucher
if validated_data.get('seat'):
if not validated_data['seat'].is_available(
sales_channel=validated_data.get('sales_channel', 'web'),
distance_ignore_cart_id=validated_data['cart_id'],
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
):
raise ValidationError(
gettext_lazy('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name))
validated_data.pop('sales_channel')
# todo: does this make sense?
validated_data['custom_price_input'] = validated_data['price']
# todo: listed price, etc?
# currently does not matter because there is no way to transform an API cart position into an order that keeps
# prices, cart positions are just quota/voucher placeholders
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:

View File

@@ -0,0 +1,49 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Discount
class DiscountSerializer(I18nAwareModelSerializer):
class Meta:
model = Discount
fields = ('id', 'active', 'internal_name', 'position', 'sales_channels', 'available_from',
'available_until', 'subevent_mode', 'condition_all_products', 'condition_limit_products',
'condition_apply_to_addons', 'condition_min_count', 'condition_min_value',
'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches',
'condition_ignore_voucher_discounted')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
Discount.validate_config(full_data)
return data

View File

@@ -56,7 +56,9 @@ from pretix.base.models.orders import (
from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages
from pretix.base.services.locking import NoLockManager
from pretix.base.services.pricing import get_price
from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, is_included_for_free,
)
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.signals import register_ticket_outputs
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -424,88 +426,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
self.fields.pop('pdf_data', None)
def validate(self, data):
if data.get('attendee_name') and data.get('attendee_name_parts'):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country').code):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data
def update(self, instance, validated_data):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = [
'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
'state', 'attendee_email',
]
answers_data = validated_data.pop('answers', None)
name = validated_data.pop('attendee_name', '')
if name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': name
}
for attr, value in validated_data.items():
if attr in update_fields:
setattr(instance, attr, value)
instance.save(update_fields=update_fields)
if answers_data is not None:
qs_seen = set()
answercache = {
a.question_id: a for a in instance.answers.all()
}
for answ_data in answers_data:
options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk]
if isinstance(answ_data['answer'], File):
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
pass # keep current file
else:
for attr, value in answ_data.items():
setattr(a, attr, value)
a.save()
else:
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='')
a.file.save(os.path.basename(an.name), an, save=False)
a.answer = 'file://' + a.file.name
a.save()
else:
a = instance.answers.create(**answ_data)
a.options.set(options)
qs_seen.add(a.question_id)
for qid, a in answercache.items():
if qid not in qs_seen:
a.delete()
return instance
raise TypeError("this serializer is readonly")
class RequireAttentionField(serializers.Field):
@@ -593,7 +514,7 @@ class OrderPaymentDateField(serializers.DateField):
class OrderFeeSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderFee
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
class PaymentURLField(serializers.URLField):
@@ -1056,8 +977,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else:
ia = None
lock_required = False
for pos_data in positions_data:
pos_data['_quotas'] = list(
pos_data.get('variation').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
lockfn = self.context['event'].lock
if simulate:
if simulate or not lock_required:
lockfn = NoLockManager
with lockfn() as now_dt:
free_seats = set()
@@ -1121,29 +1052,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if v.budget is not None:
price = pos_data.get('price')
listed_price = get_listed_price(pos_data.get('item'), pos_data.get('variation'), pos_data.get('subevent'))
if pos_data.get('voucher'):
price_after_voucher = pos_data.get('voucher').calculate_price(listed_price)
else:
price_after_voucher = listed_price
if price is None:
price = get_price(
item=pos_data.get('item'),
variation=pos_data.get('variation'),
voucher=v,
custom_price=None,
subevent=pos_data.get('subevent'),
addon_to=pos_data.get('addon_to'),
invoice_address=ia,
).gross
pbv = get_price(
item=pos_data['item'],
variation=pos_data.get('variation'),
voucher=None,
custom_price=None,
subevent=pos_data.get('subevent'),
addon_to=pos_data.get('addon_to'),
invoice_address=ia,
)
price = price_after_voucher
if v not in v_budget:
v_budget[v] = v.budget - v.budget_used()
disc = pbv.gross - price
disc = max(listed_price - price, 0)
if disc > v_budget[v]:
new_disc = v_budget[v]
v_budget[v] -= new_disc
@@ -1192,9 +1112,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
str(pos_data.get('item'))
)]
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
if pos_data.get('variation')
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
new_quotas = pos_data['_quotas']
if len(new_quotas) == 0:
errs[i]['item'] = [gettext_lazy('The product "{}" is not assigned to a quota.').format(
str(pos_data.get('item'))
@@ -1239,52 +1157,85 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.invoice_address = ia
ia.last_modified = now()
# Generate position objects
pos_map = {}
for pos_data in positions_data:
answers_data = pos_data.pop('answers', [])
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(**pos_data)
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas'})
if simulate:
pos.order = order._wrapped
else:
pos.order = order
if addon_to:
if simulate:
pos.addon_to = pos_map[addon_to]._wrapped
pos.addon_to = pos_map[addon_to]
else:
pos.addon_to = pos_map[addon_to]
if pos.price is None:
price = get_price(
item=pos.item,
variation=pos.variation,
voucher=pos.voucher,
custom_price=None,
subevent=pos.subevent,
addon_to=pos.addon_to,
invoice_address=ia,
)
pos.price = price.gross
pos.tax_rate = price.rate
pos.tax_value = price.tax
pos.tax_rule = pos.item.tax_rule
else:
pos._calculate_tax()
pos_map[pos.positionid] = pos
pos_data['__instance'] = pos
pos.price_before_voucher = get_price(
item=pos.item,
variation=pos.variation,
voucher=None,
custom_price=None,
subevent=pos.subevent,
addon_to=pos.addon_to,
invoice_address=ia,
).gross
# 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', [])
pos = pos_data['__instance']
pos._calculate_tax()
if simulate:
pos = WrappedModel(pos)
@@ -1297,6 +1248,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answers.append(answ)
pos.answers = answers
pos.pseudonymization_id = "PREVIEW"
pos_map[pos.positionid] = pos
else:
if pos.voucher:
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
@@ -1319,7 +1271,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else:
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
pos_map[pos.positionid] = pos
if not simulate:
for cp in delete_cps:
@@ -1361,14 +1312,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
f.order = order._wrapped if simulate else order
f._calculate_tax()
fees.append(f)
if not simulate:
if simulate:
f.id = 0
else:
f.save()
else:
f = OrderFee(**fee_data)
f.order = order._wrapped if simulate else order
f._calculate_tax()
fees.append(f)
if not simulate:
if simulate:
f.id = 0
else:
f.save()
order.total += sum([f.value for f in fees])

View File

@@ -0,0 +1,424 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
import logging
import os
import pycountry
from django.core.files import File
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField,
OrderPositionCreateSerializer,
)
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
from pretix.base.services.orders import OrderError
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
logger = logging.getLogger(__name__)
class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerializer):
order = serializers.SlugRelatedField(slug_field='code', queryset=Order.objects.none(), required=True, allow_null=False)
answers = AnswerCreateSerializer(many=True, required=False)
addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2,
max_digits=10)
country = CompatibleCountryField(source='*')
class Meta:
model = OrderPosition
fields = ('order', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'secret', 'addon_to', 'subevent', 'answers', 'seat')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['order'].queryset = self.context['event'].orders.all()
self.fields['item'].queryset = self.context['event'].items.all()
self.fields['subevent'].queryset = self.context['event'].subevents.all()
self.fields['seat'].queryset = self.context['event'].seats.all()
self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=self.context['event'])
if 'order' in self.context:
del self.fields['order']
def validate(self, data):
data = super().validate(data)
if data.get('addon_to'):
try:
data['addon_to'] = data['order'].positions.get(positionid=data['addon_to'])
except OrderPosition.DoesNotExist:
raise ValidationError({
'addon_to': ['addon_to refers to an unknown position ID for this order.']
})
return data
def create(self, validated_data):
ocm = self.context['ocm']
try:
ocm.add_position(
item=validated_data['item'],
variation=validated_data.get('variation'),
price=validated_data.get('price'),
addon_to=validated_data.get('addon_to'),
subevent=validated_data.get('subevent'),
seat=validated_data.get('seat'),
)
if self.context.get('commit', True):
ocm.commit()
return validated_data['order'].positions.order_by('-positionid').first()
else:
return OrderPosition() # fake to appease DRF
except OrderError as e:
raise ValidationError(str(e))
class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
answers = AnswerSerializer(many=True)
country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False)
class Meta:
model = OrderPosition
fields = (
'attendee_name', 'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
'state', 'attendee_email', 'answers',
)
def validate(self, data):
if data.get('attendee_name') and data.get('attendee_name_parts'):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country').code):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data
def update(self, instance, validated_data):
answers_data = validated_data.pop('answers', None)
name = validated_data.pop('attendee_name', '')
if name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': name
}
for attr, value in validated_data.items():
if attr in self.fields:
setattr(instance, attr, value)
instance.save(update_fields=list(validated_data.keys()))
if answers_data is not None:
qs_seen = set()
answercache = {
a.question_id: a for a in instance.answers.all()
}
for answ_data in answers_data:
options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk]
if isinstance(answ_data['answer'], File):
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
pass # keep current file
else:
for attr, value in answ_data.items():
setattr(a, attr, value)
a.save()
else:
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='')
a.file.save(os.path.basename(an.name), an, save=False)
a.answer = 'file://' + a.file.name
a.save()
else:
a = instance.answers.create(**answ_data)
a.options.set(options)
qs_seen.add(a.question_id)
for qid, a in answercache.items():
if qid not in qs_seen:
a.delete()
return instance
class OrderPositionChangeSerializer(serializers.ModelSerializer):
seat = serializers.CharField(source='seat.seat_guid', allow_null=True, required=False)
class Meta:
model = OrderPosition
fields = (
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['item'].queryset = self.context['event'].items.all()
self.fields['subevent'].queryset = self.context['event'].subevents.all()
self.fields['tax_rule'].queryset = self.context['event'].tax_rules.all()
if kwargs.get('partial'):
for k, v in self.fields.items():
self.fields[k].required = False
def validate_item(self, item):
if item.event != self.context['event']:
raise ValidationError(
'The specified item does not belong to this event.'
)
return item
def validate_subevent(self, subevent):
if self.context['event'].has_subevents:
if not subevent:
raise ValidationError(
'You need to set a subevent.'
)
if subevent.event != self.context['event']:
raise ValidationError(
'The specified subevent does not belong to this event.'
)
elif subevent:
raise ValidationError(
'You cannot set a subevent for this event.'
)
return subevent
def validate(self, data, instance=None):
instance = instance or self.instance
if instance is None:
return data # needs to be done later
if data.get('item', instance.item):
if data.get('item', instance.item).has_variations:
if not data.get('variation', instance.variation):
raise ValidationError({'variation': ['You should specify a variation for this item.']})
else:
if data.get('variation', instance.variation).item != data.get('item', instance.item):
raise ValidationError(
{'variation': ['The specified variation does not belong to the specified item.']}
)
elif data.get('variation', instance.variation):
raise ValidationError(
{'variation': ['You cannot specify a variation for this item.']}
)
return data
def update(self, instance, validated_data):
ocm = self.context['ocm']
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
item = validated_data.get('item', instance.item)
variation = validated_data.get('variation', instance.variation)
subevent = validated_data.get('subevent', instance.subevent)
price = validated_data.get('price', instance.price)
seat = validated_data.get('seat', current_seat)
tax_rule = validated_data.get('tax_rule', instance.tax_rule)
change_item = None
if item != instance.item or variation != instance.variation:
change_item = (item, variation)
change_subevent = None
if self.context['event'].has_subevents and subevent != instance.subevent:
change_subevent = (subevent,)
try:
if change_item is not None and change_subevent is not None:
ocm.change_item_and_subevent(instance, *change_item, *change_subevent)
elif change_item is not None:
ocm.change_item(instance, *change_item)
elif change_subevent is not None:
ocm.change_subevent(instance, *change_subevent)
if seat != current_seat or change_subevent:
ocm.change_seat(instance, seat['seat_guid'] if seat else None)
if price != instance.price:
ocm.change_price(instance, price)
if tax_rule != instance.tax_rule:
ocm.change_tax_rule(instance, tax_rule)
if self.context.get('commit', True):
ocm.commit()
instance.refresh_from_db()
except OrderError as e:
raise ValidationError(str(e))
return instance
class PatchPositionSerializer(serializers.Serializer):
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none())
def validate_position(self, value):
self.fields['body'].instance = value # hack around DRFs validation order
return value
def validate(self, data):
OrderPositionChangeSerializer(context=self.context, partial=True).validate(data['body'], data['position'])
return data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['position'].queryset = self.context['order'].positions.all()
self.fields['body'] = OrderPositionChangeSerializer(context=self.context, partial=True)
class SelectPositionSerializer(serializers.Serializer):
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['position'].queryset = self.context['order'].positions.all()
class OrderFeeChangeSerializer(serializers.ModelSerializer):
class Meta:
model = OrderFee
fields = (
'value',
)
def update(self, instance, validated_data):
ocm = self.context['ocm']
value = validated_data.get('value', instance.value)
try:
if value != instance.value:
ocm.change_fee(instance, value)
if self.context.get('commit', True):
ocm.commit()
instance.refresh_from_db()
except OrderError as e:
raise ValidationError(str(e))
return instance
class PatchFeeSerializer(serializers.Serializer):
fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['fee'].queryset = self.context['order'].fees.all()
self.fields['body'] = OrderFeeChangeSerializer(context=self.context)
class SelectFeeSerializer(serializers.Serializer):
fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['fee'].queryset = self.context['order'].fees.all()
class OrderChangeOperationSerializer(serializers.Serializer):
send_email = serializers.BooleanField(default=False, required=False)
reissue_invoice = serializers.BooleanField(default=True, required=False)
recalculate_taxes = serializers.ChoiceField(default=None, allow_null=True, required=False, choices=[
('keep_net', 'keep_net'),
('keep_gross', 'keep_gross'),
])
def __init__(self, *args, **kwargs):
super().__init__(self, *args, **kwargs)
self.fields['patch_positions'] = PatchPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['cancel_positions'] = SelectPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['create_positions'] = OrderPositionCreateForExistingOrderSerializer(
many=True, required=False, context=self.context
)
self.fields['split_positions'] = SelectPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['patch_fees'] = PatchFeeSerializer(
many=True, required=False, context=self.context
)
self.fields['cancel_fees'] = SelectFeeSerializer(
many=True, required=False, context=self.context
)
def validate(self, data):
seen_positions = set()
for d in data.get('patch_positions', []):
print(d, seen_positions)
if d['position'] in seen_positions:
raise ValidationError({'patch_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_positions = set()
for d in data.get('cancel_positions', []):
if d['position'] in seen_positions:
raise ValidationError({'cancel_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_positions = set()
for d in data.get('split_positions', []):
if d['position'] in seen_positions:
raise ValidationError({'split_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_fees = set()
for d in data.get('patch_fees', []):
if d['fee'] in seen_fees:
raise ValidationError({'patch_fees': ['You have specified the same object twice.']})
seen_positions.add(d['fee'])
seen_fees = set()
for d in data.get('cancel_fees', []):
if d['fee'] in seen_fees:
raise ValidationError({'cancel_fees': ['You have specified the same object twice.']})
seen_positions.add(d['fee'])
return data

View File

@@ -71,8 +71,8 @@ class CustomerSerializer(I18nAwareModelSerializer):
class Meta:
model = Customer
fields = ('identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
'locale', 'last_modified')
fields = ('identifier', 'external_identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
'locale', 'last_modified', 'notes')
class MembershipTypeSerializer(I18nAwareModelSerializer):

View File

@@ -41,8 +41,8 @@ from rest_framework import routers
from pretix.api.views import cart
from .views import (
checkin, device, event, exporters, item, oauth, order, organizer, upload,
user, version, voucher, waitinglist, webhooks,
checkin, device, discount, event, exporters, idempotency, item, oauth,
order, organizer, upload, user, version, voucher, waitinglist, webhooks,
)
router = routers.DefaultRouter()
@@ -72,6 +72,7 @@ event_router.register(r'clone', event.CloneEventViewSet)
event_router.register(r'items', item.ItemViewSet)
event_router.register(r'categories', item.ItemCategoryViewSet)
event_router.register(r'questions', item.QuestionViewSet)
event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.OrderViewSet)
@@ -132,6 +133,7 @@ urlpatterns = [
re_path(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
re_path(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
re_path(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
re_path(r"^idempotency_query$", idempotency.IdempotencyQueryView.as_view(), name="idempotency.query"),
re_path(r"^upload$", upload.UploadView.as_view(), name="upload"),
re_path(r"^me$", user.MeView.as_view(), name="user.me"),
re_path(r"^version$", version.VersionView.as_view(), name="version"),

View File

@@ -157,6 +157,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
list=self.get_object(),
successful=False,
forced=True,
force_sent=True,
device=self.request.auth if isinstance(self.request.auth, Device) else None,
gate=self.request.auth.gate if isinstance(self.request.auth, Device) else None,
**kwargs,
@@ -424,6 +425,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
forced=force,
)
raw_barcode_for_checkin = None
from_revoked_secret = False
try:
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
@@ -499,6 +501,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
elif revoked_matches and force:
op = revoked_matches[0].position
raw_barcode_for_checkin = self.kwargs['pk']
from_revoked_secret = True
else:
op = revoked_matches[0].position
op.order.log_action('pretix.event.checkin.revoked', data={
@@ -550,7 +553,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
auth=self.request.auth,
type=type,
raw_barcode=raw_barcode_for_checkin,
from_revoked_secret=True,
from_revoked_secret=from_revoked_secret,
)
except RequiredQuestionsError as e:
return Response({

View File

@@ -0,0 +1,99 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
# 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: Ture Gjørup
#
# 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.
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import OrderingFilter
from pretix.api.serializers.discount import DiscountSerializer
from pretix.api.views import ConditionalListView
from pretix.base.models import CartPosition, Discount
with scopes_disabled():
class DiscountFilter(FilterSet):
class Meta:
model = Discount
fields = ['active']
class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = DiscountSerializer
queryset = Discount.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
filterset_class = DiscountFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
return self.request.event.discounts.all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.discount.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.discount.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('You cannot delete this discount because it already has '
'been used as part of an order.')
instance.log_action(
'pretix.event.discount.deleted',
user=self.request.user,
auth=self.request.auth,
)
CartPosition.objects.filter(discount=instance).update(discount=None)
super().perform_destroy(instance)

View File

@@ -321,6 +321,7 @@ with scopes_disabled():
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs')
class Meta:
model = SubEvent
@@ -353,6 +354,9 @@ with scopes_disabled():
else:
return queryset.exclude(expr)
def sales_channel_qs(self, queryset, name, value):
return queryset.filter(event__sales_channels__contains=value)
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = SubEventSerializer

View File

@@ -0,0 +1,80 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
import json
import logging
from hashlib import sha1
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from rest_framework import status
from rest_framework.views import APIView
from pretix.api.models import ApiCall
logger = logging.getLogger(__name__)
class IdempotencyQueryView(APIView):
# Experimental feature, therefore undocumented for now
authentication_classes = ()
permission_classes = ()
def get(self, request, format=None):
idempotency_key = request.GET.get("key")
auth_hash_parts = '{}:{}'.format(
request.headers.get('Authorization', ''),
request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')
)
auth_hash = sha1(auth_hash_parts.encode()).hexdigest()
if not idempotency_key:
return JsonResponse({
'detail': 'No idempotency key given.'
}, status=status.HTTP_404_NOT_FOUND)
try:
call = ApiCall.objects.get(
auth_hash=auth_hash,
idempotency_key=idempotency_key,
)
except ApiCall.DoesNotExist:
return JsonResponse({
'detail': 'Idempotency key not seen before.'
}, status=status.HTTP_404_NOT_FOUND)
if call.locked:
r = JsonResponse(
{'detail': 'Concurrent request with idempotency key.'},
status=status.HTTP_409_CONFLICT,
)
r['Retry-After'] = 5
return r
content = call.response_body
if isinstance(content, memoryview):
content = content.tobytes()
r = HttpResponse(
content=content,
status=call.response_code,
)
for k, v in json.loads(call.response_headers).values():
r[k] = v
return r

View File

@@ -27,7 +27,9 @@ from decimal import Decimal
import django_filters
import pytz
from django.db import transaction
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
from django.db.models import (
Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects,
)
from django.db.models.functions import Coalesce, Concat
from django.http import FileResponse, HttpResponse
from django.shortcuts import get_object_or_404
@@ -36,7 +38,7 @@ from django.utils.translation import gettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from PIL import Image
from rest_framework import mixins, serializers, status, viewsets
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError,
@@ -53,6 +55,12 @@ from pretix.api.serializers.order import (
PriceCalcSerializer, RevokedTicketSecretSerializer,
SimulatedOrderSerializer,
)
from pretix.api.serializers.orderchange import (
OrderChangeOperationSerializer, OrderFeeChangeSerializer,
OrderPositionChangeSerializer,
OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer,
)
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Checkin, Device, Event, Invoice,
@@ -144,7 +152,8 @@ with scopes_disabled():
matching_positions = OrderPosition.objects.filter(
Q(order=OuterRef('pk')) & Q(
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
| Q(secret__istartswith=u) | Q(voucher__code__icontains=u)
| Q(secret__istartswith=u)
# | Q(voucher__code__icontains=u) # temporarily removed since it caused bad query performance on postgres
)
).values('id')
@@ -195,37 +204,35 @@ class OrderViewSet(viewsets.ModelViewSet):
if 'invoice_address' not in self.request.GET.getlist('exclude'):
qs = qs.select_related('invoice_address')
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
qs = qs.prefetch_related(self._positions_prefetch(self.request))
return qs
def _positions_prefetch(self, request):
if request.query_params.get('include_canceled_positions', 'false') == 'true':
opq = OrderPosition.all
else:
opq = OrderPosition.objects
if self.request.query_params.get('pdf_data', 'false') == 'true':
qs = qs.prefetch_related(
Prefetch(
'positions',
opq.all().prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.all()),
'item', 'variation', 'answers', 'answers__options', 'answers__question',
'item__category', 'addon_to', 'seat',
Prefetch('addons', opq.select_related('item', 'variation', 'seat'))
)
if request.query_params.get('pdf_data', 'false') == 'true':
return Prefetch(
'positions',
opq.all().prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.all()),
'item', 'variation', 'answers', 'answers__options', 'answers__question',
'item__category', 'addon_to', 'seat',
Prefetch('addons', opq.select_related('item', 'variation', 'seat'))
)
)
else:
qs = qs.prefetch_related(
Prefetch(
'positions',
opq.all().prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.all()),
'item', 'variation',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
'seat',
)
return Prefetch(
'positions',
opq.all().prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.all()),
'item', 'variation',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
'seat',
)
)
return qs
def _get_output_provider(self, identifier):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
@@ -254,8 +261,11 @@ class OrderViewSet(viewsets.ModelViewSet):
provider = self._get_output_provider(output)
order = self.get_object()
if order.status != Order.STATUS_PAID:
raise PermissionDenied("Downloads are not available for unpaid orders.")
if order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
raise PermissionDenied("Downloads are not available for canceled or expired orders.")
if order.status == Order.STATUS_PENDING and not request.event.settings.ticket_download_pending:
raise PermissionDenied("Downloads are not available for pending orders.")
ct = CachedCombinedTicket.objects.filter(
order=order, provider=provider.identifier, file__isnull=False
@@ -338,6 +348,7 @@ class OrderViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['POST'])
def mark_canceled(self, request, **kwargs):
send_mail = request.data.get('send_email', True)
comment = request.data.get('comment', None)
cancellation_fee = request.data.get('cancellation_fee', None)
if cancellation_fee:
try:
@@ -360,6 +371,7 @@ class OrderViewSet(viewsets.ModelViewSet):
device=request.auth if isinstance(request.auth, Device) else None,
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
send_mail=send_mail,
email_comment=comment,
cancellation_fee=cancellation_fee
)
except OrderError as e:
@@ -607,6 +619,7 @@ class OrderViewSet(viewsets.ModelViewSet):
serializer = SimulatedOrderSerializer(order, context=serializer.context)
return Response(serializer.data, status=status.HTTP_201_CREATED)
else:
prefetch_related_objects([order], self._positions_prefetch(request))
serializer = OrderSerializer(order, context=serializer.context)
order.log_action(
@@ -644,7 +657,7 @@ class OrderViewSet(viewsets.ModelViewSet):
if send_mail:
free_flow = (
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
not order.require_approval and payment.provider == "free"
not order.require_approval and payment.provider in ("free", "boxoffice")
)
if order.require_approval:
email_template = request.event.settings.mail_text_order_placed_require_approval
@@ -782,6 +795,79 @@ class OrderViewSet(viewsets.ModelViewSet):
with transaction.atomic():
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
@action(detail=True, methods=['POST'])
def change(self, request, **kwargs):
order = self.get_object()
serializer = OrderChangeOperationSerializer(
context={'order': order, **self.get_serializer_context()},
data=request.data,
)
serializer.is_valid(raise_exception=True)
try:
ocm = OrderChangeManager(
order=order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=serializer.validated_data.get('send_email', False),
reissue_invoice=serializer.validated_data.get('reissue_invoice', True),
)
canceled_positions = set()
for r in serializer.validated_data.get('cancel_positions', []):
ocm.cancel(r['position'])
canceled_positions.add(r['position'])
for r in serializer.validated_data.get('patch_positions', []):
if r['position'] in canceled_positions:
continue
pos_serializer = OrderPositionChangeSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
partial=True,
)
pos_serializer.update(r['position'], r['body'])
for r in serializer.validated_data.get('split_positions', []):
if r['position'] in canceled_positions:
continue
ocm.split(r['position'])
for r in serializer.validated_data.get('create_positions', []):
pos_serializer = OrderPositionCreateForExistingOrderSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
)
pos_serializer.create(r)
canceled_fees = set()
for r in serializer.validated_data.get('cancel_fees', []):
ocm.cancel_fee(r['fee'])
canceled_fees.add(r['fee'])
for r in serializer.validated_data.get('patch_fees', []):
if r['fee'] in canceled_fees:
continue
pos_serializer = OrderFeeChangeSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
)
pos_serializer.update(r['fee'], r['body'])
if serializer.validated_data.get('recalculate_taxes') == 'keep_net':
ocm.recalculate_taxes(keep='net')
elif serializer.validated_data.get('recalculate_taxes') == 'keep_gross':
ocm.recalculate_taxes(keep='gross')
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
order.refresh_from_db()
serializer = OrderSerializer(
instance=order,
context=self.get_serializer_context(),
)
return Response(serializer.data)
with scopes_disabled():
class OrderPositionFilter(FilterSet):
@@ -823,7 +909,7 @@ with scopes_disabled():
}
class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
class OrderPositionViewSet(viewsets.ModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
@@ -1037,8 +1123,11 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
provider = self._get_output_provider(output)
pos = self.get_object()
if pos.order.status != Order.STATUS_PAID:
raise PermissionDenied("Downloads are not available for unpaid orders.")
if pos.order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
raise PermissionDenied("Downloads are not available for canceled or expired orders.")
if pos.order.status == Order.STATUS_PENDING and not request.event.settings.ticket_download_pending:
raise PermissionDenied("Downloads are not available for pending orders.")
if not pos.generate_ticket:
raise PermissionDenied("Downloads are not enabled for this product.")
@@ -1060,6 +1149,25 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
)
return resp
@action(detail=True, methods=['POST'])
def regenerate_secrets(self, request, **kwargs):
instance = self.get_object()
try:
ocm = OrderChangeManager(
instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
notify=False,
reissue_invoice=False,
)
ocm.regenerate_secret(instance)
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
return self.retrieve(request, [], **kwargs)
def perform_destroy(self, instance):
try:
ocm = OrderChangeManager(
@@ -1075,18 +1183,33 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
def update(self, request, *args, **kwargs):
partial = kwargs.get('partial', False)
if not partial:
return Response(
{"detail": "Method \"PUT\" not allowed."},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
return super().update(request, *args, **kwargs)
def perform_update(self, serializer):
def create(self, request, *args, **kwargs):
with transaction.atomic():
old_data = self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data
serializer = OrderPositionCreateForExistingOrderSerializer(
data=request.data,
context=self.get_serializer_context(),
)
serializer.is_valid(raise_exception=True)
order = serializer.validated_data['order']
ocm = OrderChangeManager(
order=order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=False,
reissue_invoice=False,
)
serializer.context['ocm'] = ocm
serializer.save()
# Fields that can be easily patched after the position was added
old_data = OrderPositionInfoPatchSerializer(instance=serializer.instance, context=self.get_serializer_context()).data
serializer = OrderPositionInfoPatchSerializer(
instance=serializer.instance,
context=self.get_serializer_context(),
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
new_data = serializer.data
@@ -1109,9 +1232,77 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi
]
}
)
tickets.invalidate_cache.apply_async(
kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
return Response(
OrderPositionSerializer(serializer.instance, context=self.get_serializer_context()).data,
status=status.HTTP_201_CREATED,
)
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
def update(self, request, *args, **kwargs):
partial = kwargs.get('partial', False)
if not partial:
return Response(
{"detail": "Method \"PUT\" not allowed."},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
with transaction.atomic():
instance = self.get_object()
ocm = OrderChangeManager(
order=instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=False,
reissue_invoice=False,
)
# Field that need to go through OrderChangeManager
serializer = OrderPositionChangeSerializer(
instance=instance,
context={'ocm': ocm, **self.get_serializer_context()},
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
# Fields that can be easily patched
old_data = OrderPositionInfoPatchSerializer(instance=instance, context=self.get_serializer_context()).data
serializer = OrderPositionInfoPatchSerializer(
instance=instance,
context=self.get_serializer_context(),
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
new_data = serializer.data
if old_data != new_data:
log_data = self.request.data
if 'answers' in log_data:
for a in new_data['answers']:
log_data[f'question_{a["question"]}'] = a["answer"]
log_data.pop('answers', None)
serializer.instance.order.log_action(
'pretix.event.order.modified',
user=self.request.user,
auth=self.request.auth,
data={
'data': [
dict(
position=serializer.instance.pk,
**log_data
)
]
}
)
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
return Response(self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data)
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):

View File

@@ -25,7 +25,7 @@ from django.db import transaction
from django.db.models import F, Q
from django.utils.timezone import now
from django_filters.rest_framework import (
BooleanFilter, DjangoFilterBackend, FilterSet,
BooleanFilter, CharFilter, DjangoFilterBackend, FilterSet,
)
from django_scopes import scopes_disabled
from rest_framework import status, viewsets
@@ -40,6 +40,7 @@ from pretix.base.models import Voucher
with scopes_disabled():
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
code = CharFilter(lookup_expr='iexact')
class Meta:
model = Voucher

View File

@@ -89,6 +89,13 @@ class SalesChannel:
"""
return True
@property
def discounts_supported(self) -> bool:
"""
If this property is ``True``, this sales channel can be selected for automatic discounts.
"""
return True
def get_all_sales_channels():
global _ALL_CHANNELS

View File

@@ -259,7 +259,7 @@ class OrderListExporter(MultiSheetListExporter):
payment_providers=Subquery(p_providers, output_field=CharField()),
invoice_numbers=Subquery(i_numbers, output_field=CharField()),
pcnt=Subquery(s, output_field=IntegerField())
).select_related('invoice_address')
).select_related('invoice_address', 'customer')
qs = self._date_filter(qs, form_data, rel='')
@@ -268,8 +268,8 @@ class OrderListExporter(MultiSheetListExporter):
tax_rates = self._get_all_tax_rates(qs)
headers = [
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Phone number'), _('Order date'),
_('Order time'), _('Company'), _('Name'),
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Phone number'),
_('Order date'), _('Order time'), _('Company'), _('Name'),
]
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
if name_scheme and len(name_scheme['fields']) > 1:
@@ -294,6 +294,7 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Follow-up date'))
headers.append(_('Positions'))
headers.append(_('E-mail address verified'))
headers.append(_('External customer ID'))
headers.append(_('Payment providers'))
if form_data.get('include_payment_amounts'):
payment_methods = self._get_all_payment_methods(qs)
@@ -400,6 +401,7 @@ class OrderListExporter(MultiSheetListExporter):
row.append(order.custom_followup_at.strftime("%Y-%m-%d") if order.custom_followup_at else "")
row.append(order.pcnt)
row.append(_('Yes') if order.email_known_to_work else _('No'))
row.append(str(order.customer.external_identifier) if order.customer and order.customer.external_identifier else '')
row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((order.payment_providers or '').split(',')))
if p and p != 'free'
@@ -424,13 +426,13 @@ class OrderListExporter(MultiSheetListExporter):
).values(
'm'
).order_by()
qs = OrderFee.objects.filter(
qs = OrderFee.all.filter(
order__event__in=self.events,
).annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
).select_related('order', 'order__invoice_address', 'tax_rule')
).select_related('order', 'order__invoice_address', 'order__customer', 'tax_rule')
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID)
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
qs = self._date_filter(qs, form_data, rel='order__')
@@ -459,6 +461,7 @@ class OrderListExporter(MultiSheetListExporter):
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
]
headers.append(_('External customer ID'))
headers.append(_('Payment providers'))
yield headers
@@ -469,7 +472,7 @@ class OrderListExporter(MultiSheetListExporter):
row = [
self.event_object_cache[order.event_id].slug,
order.code,
order.get_status_display(),
_("canceled") if op.canceled else order.get_status_display(),
order.email,
str(order.phone) if order.phone else '',
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
@@ -502,6 +505,7 @@ class OrderListExporter(MultiSheetListExporter):
]
except InvoiceAddress.DoesNotExist:
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
row.append(str(order.customer.external_identifier) if order.customer and order.customer.external_identifier else '')
row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
if p and p != 'free'
@@ -518,19 +522,19 @@ class OrderListExporter(MultiSheetListExporter):
).values(
'm'
).order_by()
base_qs = OrderPosition.objects.filter(
base_qs = OrderPosition.all.filter(
order__event__in=self.events,
)
qs = base_qs.annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
).select_related(
'order', 'order__invoice_address', 'item', 'variation',
'order', 'order__invoice_address', 'order__customer', 'item', 'variation',
'voucher', 'tax_rule'
).prefetch_related(
'answers', 'answers__question', 'answers__options'
)
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID)
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
qs = self._date_filter(qs, form_data, rel='order__')
@@ -611,6 +615,7 @@ class OrderListExporter(MultiSheetListExporter):
headers += [
_('Sales channel'), _('Order locale'),
_('E-mail address verified'),
_('External customer ID'),
_('Payment providers'),
]
@@ -628,7 +633,7 @@ class OrderListExporter(MultiSheetListExporter):
self.event_object_cache[order.event_id].slug,
order.code,
op.positionid,
order.get_status_display(),
_("canceled") if op.canceled else order.get_status_display(),
order.email,
str(order.phone) if order.phone else '',
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
@@ -730,7 +735,8 @@ class OrderListExporter(MultiSheetListExporter):
row += [
order.sales_channel,
order.locale,
_('Yes') if order.email_known_to_work else _('No')
_('Yes') if order.email_known_to_work else _('No'),
str(order.customer.external_identifier) if order.customer and order.customer.external_identifier else '',
]
row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))

View File

@@ -196,10 +196,16 @@ class SecretKeySettingsWidget(forms.TextInput):
attrs.update({
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
})
self.__reflect_value = False
super().__init__(attrs)
def value_from_datadict(self, data, files, name):
value = super().value_from_datadict(data, files, name)
self.__reflect_value = value and value != SECRET_REDACTED
return value
def get_context(self, name, value, attrs):
if value:
if value and not self.__reflect_value:
value = SECRET_REDACTED
return super().get_context(name, value, attrs)

View File

@@ -429,7 +429,7 @@ class PortraitImageWidget(UploadedFileWidget):
def value_from_datadict(self, data, files, name):
d = super().value_from_datadict(data, files, name)
if d is not None:
if d is not None and d is not False:
d._cropdata = json.loads(data.get(name + '_cropdata', '{}') or '{}')
return d

View File

@@ -510,7 +510,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
return story
def _get_story(self, doc):
has_taxes = any(il.tax_value for il in self.invoice.lines.all())
has_taxes = any(il.tax_value for il in self.invoice.lines.all()) or self.invoice.reverse_charge
story = [
NextPageTemplate('FirstPage'),

View File

@@ -19,11 +19,13 @@
# 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/>.
#
import logging
import sys
from django.apps import apps
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import connection
from django_scopes import scope, scopes_disabled
@@ -33,6 +35,13 @@ class Command(BaseCommand):
parser.parse_args = lambda x: parser.parse_known_args(x)[0]
return parser
def add_arguments(self, parser):
parser.add_argument(
'--print-sql',
action='store_true',
help='Print all SQL queries.',
)
def handle(self, *args, **options):
try:
from django_extensions.management.commands import shell_plus # noqa
@@ -41,6 +50,11 @@ class Command(BaseCommand):
cmd = 'shell'
del options['skip_checks']
if options['print_sql']:
connection.force_debug_cursor = True
logger = logging.getLogger("django.db.backends")
logger.setLevel(logging.DEBUG)
parser = self.create_parser(sys.argv[0], sys.argv[1])
flags = parser.parse_known_args(sys.argv[2:])[1]
if "--override" in flags:

View File

@@ -76,6 +76,10 @@ class LocaleMiddleware(MiddlewareMixin):
if lang.startswith(firstpart + '-'):
language = lang
break
if language not in settings_holder.settings.locales:
# This seems redundant, but can happen in the rare edge case that settings.locale is (wrongfully)
# not part of settings.locales
language = settings_holder.settings.locales[0]
if '-' not in language and settings_holder.settings.region:
language += '-' + settings_holder.settings.region
else:

View File

@@ -4,7 +4,6 @@
from django.core.exceptions import ImproperlyConfigured
from django.db import migrations, models
from django_mysql.checks import mysql_connections
from django_mysql.utils import connection_is_mariadb
def set_attendee_name_parts(apps, schema_editor):
@@ -31,7 +30,7 @@ def check_mysqlversion(apps, schema_editor):
conns = list(mysql_connections())
found = 'Unknown version'
for alias, conn in conns:
if connection_is_mariadb(conn) and hasattr(conn, 'mysql_version'):
if hasattr(conn, 'mysql_is_mariadb') and conn.mysql_is_mariadb and hasattr(conn, 'mysql_version'):
if conn.mysql_version >= (10, 2, 7):
any_conn_works = True
else:

View File

@@ -0,0 +1,89 @@
# Generated by Django 3.2.2 on 2022-03-03 20:17
from decimal import Decimal
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0209_device_info'),
]
operations = [
migrations.AddField(
model_name='cartposition',
name='custom_price_input',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='custom_price_input_is_net',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='cartposition',
name='line_price_gross',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='listed_price',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='price_after_voucher',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
migrations.AddField(
model_name='cartposition',
name='tax_rate',
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=7),
),
migrations.CreateModel(
name='Discount',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('active', models.BooleanField(default=True)),
('internal_name', models.CharField(max_length=255)),
('position', models.PositiveIntegerField(default=0)),
('sales_channels', pretix.base.models.fields.MultiStringField(default=['web'])),
('available_from', models.DateTimeField(blank=True, null=True)),
('available_until', models.DateTimeField(blank=True, null=True)),
('subevent_mode', models.CharField(max_length=50, default='mixed')),
('condition_all_products', models.BooleanField(default=True)),
('condition_min_count', models.PositiveIntegerField(default=0)),
('condition_min_value', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('benefit_discount_matching_percent', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('benefit_only_apply_to_cheapest_n_matches', models.PositiveIntegerField(null=True)),
('condition_limit_products', models.ManyToManyField(to='pretixbase.Item')),
('condition_apply_to_addons', models.BooleanField(default=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='discounts', to='pretixbase.event')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.AddField(
model_name='cartposition',
name='discount',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, to='pretixbase.discount'),
),
migrations.AddField(
model_name='orderposition',
name='discount',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, to='pretixbase.discount'),
),
migrations.AddField(
model_name='orderposition',
name='voucher_budget_use',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.2.2 on 2022-03-14 20:01
from decimal import Decimal
from django.db import migrations
from django.db.models import F
from django.db.models.functions import Greatest
def migrate_voucher_budget_use(apps, schema_editor):
OrderPosition = apps.get_model('pretixbase', 'OrderPosition') # noqa
OrderPosition.all.filter(
price_before_voucher__isnull=False
).exclude(price=F('price_before_voucher')).update(
voucher_budget_use=Greatest(F('price_before_voucher') - F('price'), Decimal('0.00'))
)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0210_auto_20220303_2017'),
]
operations = [
migrations.RunPython(
migrate_voucher_budget_use,
migrations.RunPython.noop,
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 3.2.12 on 2022-03-18 14:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0211_auto_20220314_2001'),
]
operations = [
migrations.RemoveField(
model_name='cartposition',
name='includes_tax',
),
migrations.RemoveField(
model_name='cartposition',
name='override_tax_rate',
),
migrations.RemoveField(
model_name='cartposition',
name='price_before_voucher',
),
migrations.RemoveField(
model_name='orderposition',
name='price_before_voucher',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.2 on 2022-04-13 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0212_auto_20220318_1408'),
]
operations = [
migrations.AddField(
model_name='discount',
name='condition_ignore_voucher_discounted',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.2.12 on 2022-04-28 08:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0213_discount_condition_ignore_voucher_discounted'),
]
operations = [
migrations.AddField(
model_name='customer',
name='external_identifier',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='customer',
name='notes',
field=models.TextField(null=True),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 3.2.12 on 2022-05-12 15:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0214_customer_notes_ext_id'),
]
operations = [
migrations.AlterField(
model_name='customer',
name='identifier',
field=models.CharField(db_index=True, max_length=190),
),
migrations.AlterUniqueTogether(
name='customer',
unique_together={('organizer', 'email'), ('organizer', 'identifier')},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2022-04-29 13:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0215_customer_organizer_identifier_unique'),
]
operations = [
migrations.AddField(
model_name='checkin',
name='force_sent',
field=models.BooleanField(default=False, null=True),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 3.2.12 on 2022-06-15 08:10
import django.db.models.deletion
import i18nfield.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0216_checkin_forced_sent'),
]
operations = [
migrations.CreateModel(
name='OrganizerFooterLink',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('label', i18nfield.fields.I18nCharField(max_length=200)),
('url', models.URLField()),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='footer_links', to='pretixbase.organizer')),
],
),
migrations.CreateModel(
name='EventFooterLink',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('label', i18nfield.fields.I18nCharField(max_length=200)),
('url', models.URLField()),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='footer_links', to='pretixbase.event')),
],
),
]

View File

@@ -25,6 +25,7 @@ from .base import CachedFile, LoggedModel, cachedfile_name
from .checkin import Checkin, CheckinList
from .customers import Customer
from .devices import Device, Gate
from .discount import Discount
from .event import (
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
SubEvent, SubEventMetaValue, generate_invite_token,

View File

@@ -188,6 +188,7 @@ class CheckinList(LoggedModel):
# * in pretix.helpers.jsonlogic_boolalg
# * in checkinrules.js
# * in libpretixsync
# * in pretixscan-ios (in the future)
top_level_operators = {
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
}
@@ -195,7 +196,8 @@ class CheckinList(LoggedModel):
'buildTime', 'objectList', 'lookup', 'var',
}
allowed_vars = {
'product', 'variation', 'now', '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',
}
if not rules or not isinstance(rules, dict):
return rules
@@ -324,7 +326,13 @@ class Checkin(models.Model):
type = models.CharField(max_length=100, choices=CHECKIN_TYPES, default=TYPE_ENTRY)
nonce = models.CharField(max_length=190, null=True, blank=True)
# Whether or not the scan was made offline
force_sent = models.BooleanField(default=False, null=True, blank=True)
# Whether the scan was made offline AND would have not been possible online
forced = models.BooleanField(default=False)
device = models.ForeignKey(
'pretixbase.Device', related_name='checkins', on_delete=models.PROTECT, null=True, blank=True
)

View File

@@ -24,6 +24,7 @@ from django.conf import settings
from django.contrib.auth.hashers import (
check_password, is_password_usable, make_password,
)
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import F, Q
from django.utils.crypto import get_random_string, salted_hmac
@@ -44,7 +45,18 @@ class Customer(LoggedModel):
"""
id = models.BigAutoField(primary_key=True)
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
identifier = models.CharField(max_length=190, db_index=True, unique=True)
identifier = models.CharField(
max_length=190,
db_index=True,
help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do '
'not input one, we will generate one automatically.'),
validators=[
RegexValidator(
regex=r"^[a-zA-Z0-9]([a-zA-Z0-9.\-_]*[a-zA-Z0-9])?$",
message=_("The identifier may only contain letters, numbers, dots, dashes, and underscores. It must start and end with a letter or number."),
),
],
)
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
password = models.CharField(verbose_name=_('Password'), max_length=128)
@@ -59,11 +71,13 @@ class Customer(LoggedModel):
default=settings.LANGUAGE_CODE,
verbose_name=_('Language'))
last_modified = models.DateTimeField(auto_now=True)
external_identifier = models.CharField(max_length=255, verbose_name=_('External identifier'), null=True, blank=True)
notes = models.TextField(verbose_name=_('Notes'), null=True, blank=True)
objects = ScopedManager(organizer='organizer')
class Meta:
unique_together = [['organizer', 'email']]
unique_together = [['organizer', 'email'], ['organizer', 'identifier']]
ordering = ('email',)
def get_email_field_name(self):
@@ -90,6 +104,8 @@ class Customer(LoggedModel):
self.name_cached = ''
self.email = None
self.phone = None
self.external_identifier = None
self.notes = None
self.save()
self.all_logentries().update(data={}, shredded=True)
self.orders.all().update(customer=None)

View File

@@ -179,6 +179,7 @@ class Device(LoggedModel):
return {
'can_view_orders',
'can_change_orders',
'can_view_vouchers',
'can_manage_gift_cards'
}

View File

@@ -0,0 +1,368 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
from collections import defaultdict
from decimal import Decimal
from itertools import groupby
from typing import Dict, Optional, Tuple
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models import fields
from pretix.base.models.base import LoggedModel
class Discount(LoggedModel):
SUBEVENT_MODE_MIXED = 'mixed'
SUBEVENT_MODE_SAME = 'same'
SUBEVENT_MODE_DISTINCT = 'distinct'
SUBEVENT_MODE_CHOICES = (
(SUBEVENT_MODE_MIXED, pgettext_lazy('subevent', 'Dates can be mixed without limitation')),
(SUBEVENT_MODE_SAME, pgettext_lazy('subevent', 'All matching products must be for the same date')),
(SUBEVENT_MODE_DISTINCT, pgettext_lazy('subevent', 'Each matching product must be for a different date')),
)
event = models.ForeignKey(
'Event',
on_delete=models.CASCADE,
related_name='discounts',
)
active = models.BooleanField(
verbose_name=_("Active"),
default=True,
)
internal_name = models.CharField(
verbose_name=_("Internal name"),
max_length=255
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
sales_channels = fields.MultiStringField(
verbose_name=_('Sales channels'),
default=['web'],
blank=False,
)
available_from = models.DateTimeField(
verbose_name=_("Available from"),
null=True,
blank=True,
)
available_until = models.DateTimeField(
verbose_name=_("Available until"),
null=True,
blank=True,
)
subevent_mode = models.CharField(
verbose_name=_('Event series handling'),
max_length=50,
default=SUBEVENT_MODE_MIXED,
choices=SUBEVENT_MODE_CHOICES,
)
condition_all_products = models.BooleanField(
default=True,
verbose_name=_("Apply to all products (including newly created ones)")
)
condition_limit_products = models.ManyToManyField(
'Item',
verbose_name=_("Apply to specific products"),
blank=True
)
condition_apply_to_addons = models.BooleanField(
default=True,
verbose_name=_("Apply to add-on products"),
help_text=_("Discounts never apply to bundled products"),
)
condition_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 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."),
)
condition_min_count = models.PositiveIntegerField(
verbose_name=_('Minimum number of matching products'),
default=0,
)
condition_min_value = models.DecimalField(
verbose_name=_('Minimum gross value of matching products'),
decimal_places=2,
max_digits=10,
default=Decimal('0.00'),
)
benefit_discount_matching_percent = models.DecimalField(
verbose_name=_('Percentual discount on matching products'),
decimal_places=2,
max_digits=10,
default=Decimal('0.00'),
validators=[MinValueValidator(Decimal('0.00'))],
)
benefit_only_apply_to_cheapest_n_matches = models.PositiveIntegerField(
verbose_name=_('Apply discount only to this number of matching products'),
help_text=_(
'This option allows you to create discounts of the type "buy X get Y reduced/for free". For example, if '
'you set "Minimum number of matching products" to four and this value to two, the customer\'s cart will be '
'split into groups of four tickets and the cheapest two tickets within every group will be discounted. If '
'you want to grant the discount on all matching products, keep this field empty.'
),
null=True,
blank=True,
validators=[MinValueValidator(1)],
)
# more feature ideas:
# - max_usages_per_order
# - promote_to_user_if_almost_satisfied
# - require_customer_account
objects = ScopedManager(organizer='event__organizer')
class Meta:
ordering = ('position', 'id')
def __str__(self):
return self.internal_name
@property
def sortkey(self):
return self.position, self.id
def __lt__(self, other) -> bool:
return self.sortkey < other.sortkey
@classmethod
def validate_config(cls, data):
# We forbid a few combinations of settings, because we don't think they are neccessary and at the same
# time they introduce edge cases, in which it becomes almost impossible to compute the discount optimally
# and also very hard to understand for the user what is going on.
if data.get('condition_min_count') and data.get('condition_min_value'):
raise ValidationError(
_('You can either set a minimum number of matching products or a minimum value, not both.')
)
if not data.get('condition_min_count') and not data.get('condition_min_value'):
raise ValidationError(
_('You need to either set a minimum number of matching products or a minimum value.')
)
if data.get('condition_min_value') and data.get('benefit_only_apply_to_cheapest_n_matches'):
raise ValidationError(
_('You cannot apply the discount only to some of the matched products if you are matching '
'on a minimum value.')
)
if data.get('subevent_mode') == cls.SUBEVENT_MODE_DISTINCT and data.get('condition_min_value'):
raise ValidationError(
_('You cannot apply the discount only to bookings of different dates if you are matching '
'on a minimum value.')
)
def allow_delete(self):
return not self.orderposition_set.exists()
def clean(self):
super().clean()
Discount.validate_config({
'condition_min_count': self.condition_min_count,
'condition_min_value': self.condition_min_value,
'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches,
'subevent_mode': self.subevent_mode,
})
def _apply_min_value(self, positions, idx_group, result):
if self.condition_min_value and sum(positions[idx][2] for idx in idx_group) < self.condition_min_value:
return
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
raise ValueError('Validation invariant violated.')
for idx in idx_group:
previous_price = positions[idx][2]
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
)
result[idx] = new_price
def _apply_min_count(self, positions, idx_group, result):
if len(idx_group) < self.condition_min_count:
return
if not self.condition_min_count or self.condition_min_value:
raise ValueError('Validation invariant violated.')
if self.benefit_only_apply_to_cheapest_n_matches:
if not self.condition_min_count:
raise ValueError('Validation invariant violated.')
idx_group = sorted(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
# want to match multiples of 3
consume_idx = idx_group[:len(idx_group) // self.condition_min_count * self.condition_min_count]
benefit_idx = idx_group[:len(idx_group) // self.condition_min_count * self.benefit_only_apply_to_cheapest_n_matches]
else:
consume_idx = idx_group
benefit_idx = idx_group
for idx in benefit_idx:
previous_price = positions[idx][2]
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
)
result[idx] = new_price
for idx in consume_idx:
result.setdefault(idx, positions[idx][2])
def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]]) -> Dict[int, Decimal]:
"""
Tries to apply this discount to a cart
:param positions: Dictionary mapping IDs to tuples of the form
``(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)``.
Bundled positions may not be included.
:return: A dictionary mapping keys from the input dictionary to new prices. All positions
contained in this dictionary are considered "consumed" and should not be considered
by other discounts.
"""
result = {}
if not self.active:
return result
limit_products = set()
if not self.condition_all_products:
limit_products = {p.pk for p in self.condition_limit_products.all()}
# First, filter out everything not even covered by our product scope
initial_candidates = [
idx
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if (
(self.condition_all_products or item_id in limit_products) and
(self.condition_apply_to_addons or not is_addon_to) and
(not self.condition_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.condition_min_count:
self._apply_min_count(positions, initial_candidates, result)
else:
self._apply_min_value(positions, initial_candidates, result)
elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx):
return positions[idx][1] # subevent_id
# Build groups of candidates with the same subevent, then apply our regular algorithm
# to each group
_groups = groupby(sorted(initial_candidates, key=key), key=key)
candidate_groups = [list(g) for k, g in _groups]
for g in candidate_groups:
if self.condition_min_count:
self._apply_min_count(positions, g, result)
else:
self._apply_min_value(positions, g, result)
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
if self.condition_min_value:
raise ValueError('Validation invariant violated.')
# Build optimal groups of candidates with distinct subevents, then apply our regular algorithm
# to each group. Optimal, in this case, means:
# - First try to build as many groups of size condition_min_count as possible while trying to
# balance out the cheapest products so that they are not all in the same group
# - Then add remaining positions to existing groups if possible
candidate_groups = []
# Build a list of subevent IDs in descending order of frequency
subevent_to_idx = defaultdict(list)
for idx, p in positions.items():
subevent_to_idx[p[1]].append(idx)
for v in subevent_to_idx.values():
v.sort(key=lambda idx: positions[idx][2])
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
# Build groups of exactly condition_min_count distinct subevents
current_group = []
while True:
# Build a list of candidates, which is a list of all positions belonging to a subevent of the
# maximum cardinality, where the cardinality of a subevent is defined as the number of tickets
# for that subevent that are not yet part of any group
candidates = []
cardinality = None
for se, l in subevent_to_idx.items():
l = [ll for ll in l if ll not in current_group]
if cardinality and len(l) != cardinality:
continue
if se not in {positions[idx][1] for idx in current_group}:
candidates += l
cardinality = len(l)
if not candidates:
break
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
# and 2 from the end" scheme to optimize price distribution among groups
candidates = sorted(candidates, key=lambda idx: positions[idx][2])
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0]
else:
candidate = candidates[-1]
current_group.append(candidate)
# Only add full groups to the list of groups
if len(current_group) >= max(self.condition_min_count, 1):
candidate_groups.append(current_group)
for c in current_group:
subevent_to_idx[positions[c][1]].remove(c)
current_group = []
# Distribute "leftovers"
for se in subevent_order:
if subevent_to_idx[se]:
for group in candidate_groups:
if se not in {positions[idx][1] for idx in group}:
group.append(subevent_to_idx[se].pop())
if not subevent_to_idx[se]:
break
for g in candidate_groups:
self._apply_min_count(positions, g, result)
return result

View File

@@ -700,8 +700,8 @@ class Event(EventMixin, LoggedModel):
from ..signals import event_copy_data
from . import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, Question,
Quota,
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
Question, Quota,
)
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
@@ -718,12 +718,17 @@ class Event(EventMixin, LoggedModel):
self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
for fl in EventFooterLink.objects.filter(event=other):
fl.pk = None
fl.event = self
fl.save(force_insert=True)
tax_map = {}
for t in other.tax_rules.all():
tax_map[t.pk] = t
t.pk = None
t.event = self
t.save()
t.save(force_insert=True)
t.log_action('pretix.object.cloned')
category_map = {}
@@ -731,7 +736,7 @@ class Event(EventMixin, LoggedModel):
category_map[c.pk] = c
c.pk = None
c.event = self
c.save()
c.save(force_insert=True)
c.log_action('pretix.object.cloned')
item_meta_properties_map = {}
@@ -739,7 +744,7 @@ class Event(EventMixin, LoggedModel):
item_meta_properties_map[imp.pk] = imp
imp.pk = None
imp.event = self
imp.save()
imp.save(force_insert=True)
imp.log_action('pretix.object.cloned')
item_map = {}
@@ -760,7 +765,7 @@ class Event(EventMixin, LoggedModel):
if i.grant_membership_type and other.organizer_id != self.organizer_id:
i.grant_membership_type = None
i.save()
i.save() # no force_insert since i.picture.save could have already inserted
i.log_action('pretix.object.cloned')
if require_membership_types and other.organizer_id == self.organizer_id:
@@ -770,19 +775,19 @@ class Event(EventMixin, LoggedModel):
variation_map[v.pk] = v
v.pk = None
v.item = i
v.save()
v.save(force_insert=True)
for imv in ItemMetaValue.objects.filter(item__event=other).prefetch_related('item', 'property'):
imv.pk = None
imv.property = item_meta_properties_map[imv.property.pk]
imv.item = item_map[imv.item.pk]
imv.save()
imv.save(force_insert=True)
for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'):
ia.pk = None
ia.base_item = item_map[ia.base_item.pk]
ia.addon_category = category_map[ia.addon_category.pk]
ia.save()
ia.save(force_insert=True)
for ia in ItemBundle.objects.filter(base_item__event=other).prefetch_related('base_item', 'bundled_item', 'bundled_variation'):
ia.pk = None
@@ -790,7 +795,7 @@ class Event(EventMixin, LoggedModel):
ia.bundled_item = item_map[ia.bundled_item.pk]
if ia.bundled_variation:
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
ia.save()
ia.save(force_insert=True)
quota_map = {}
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
@@ -801,7 +806,7 @@ class Event(EventMixin, LoggedModel):
q.pk = None
q.event = self
q.closed = False
q.save()
q.save(force_insert=True)
q.log_action('pretix.object.cloned')
for i in items:
if i.pk in item_map:
@@ -810,6 +815,16 @@ class Event(EventMixin, LoggedModel):
q.variations.add(variation_map[v.pk])
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'):
items = list(d.condition_limit_products.all())
d.pk = None
d.event = self
d.save(force_insert=True)
d.log_action('pretix.object.cloned')
for i in items:
if i.pk in item_map:
d.condition_limit_products.add(item_map[i.pk])
question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
items = list(q.items.all())
@@ -817,7 +832,7 @@ class Event(EventMixin, LoggedModel):
question_map[q.pk] = q
q.pk = None
q.event = self
q.save()
q.save(force_insert=True)
q.log_action('pretix.object.cloned')
for i in items:
@@ -825,7 +840,7 @@ class Event(EventMixin, LoggedModel):
for o in opts:
o.pk = None
o.question = q
o.save()
o.save(force_insert=True)
for q in self.questions.filter(dependency_question__isnull=False):
q.dependency_question = question_map[q.dependency_question_id]
@@ -835,10 +850,10 @@ class Event(EventMixin, LoggedModel):
if isinstance(rules, dict):
for k, v in rules.items():
if k == 'lookup':
if v[0] == 'product':
v[1] = str(item_map.get(int(v[1]), 0).pk) if int(v[1]) in item_map else "0"
elif v[0] == 'variation':
v[1] = str(variation_map.get(int(v[1]), 0).pk) if int(v[1]) in variation_map else "0"
if rules[k][0] == 'product':
rules[k][1] = str(item_map.get(int(v[1]), 0).pk) if int(v[1]) in item_map else "0"
elif rules[k][0] == 'variation':
rules[k][1] = str(variation_map.get(int(v[1]), 0).pk) if int(v[1]) in variation_map else "0"
else:
_walk_rules(v)
elif isinstance(rules, list):
@@ -854,7 +869,7 @@ class Event(EventMixin, LoggedModel):
rules = cl.rules
_walk_rules(rules)
cl.rules = rules
cl.save()
cl.save(force_insert=True)
cl.log_action('pretix.object.cloned')
for i in items:
cl.limit_products.add(item_map[i.pk])
@@ -863,21 +878,25 @@ class Event(EventMixin, LoggedModel):
if other.seating_plan.organizer_id == self.organizer_id:
self.seating_plan = other.seating_plan
else:
self.organizer.seating_plans.create(name=other.seating_plan.name, layout=other.seating_plan.layout)
sp = other.seating_plan
sp.pk = None
sp.organizer = self.organizer
sp.save(force_insert=True)
self.seating_plan = sp
self.save()
for m in other.seat_category_mappings.filter(subevent__isnull=True):
m.pk = None
m.event = self
m.product = item_map[m.product_id]
m.save()
m.save(force_insert=True)
for s in other.seats.filter(subevent__isnull=True):
s.pk = None
s.event = self
if s.product_id:
s.product = item_map[s.product_id]
s.save()
s.save(force_insert=True)
has_custom_style = other.settings.presale_css_file or other.settings.presale_widget_css_file
skip_settings = (
@@ -1212,7 +1231,7 @@ class Event(EventMixin, LoggedModel):
self.set_active_plugins(plugins_active)
plugins_available = self.get_available_plugins()
if hasattr(plugins_available[module].app, 'uninstalled'):
if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'):
getattr(plugins_available[module].app, 'uninstalled')(self)
regenerate_css.apply_async(args=(self.pk,))
@@ -1598,3 +1617,25 @@ class SubEventMetaValue(LoggedModel):
super().save(*args, **kwargs)
if self.subevent:
self.subevent.event.cache.clear()
class EventFooterLink(models.Model):
"""
A footer link assigned to an event.
"""
event = models.ForeignKey('Event', on_delete=models.CASCADE, related_name='footer_links')
label = I18nCharField(
max_length=200,
verbose_name=_("Link text"),
)
url = models.URLField(
verbose_name=_("Link URL"),
)
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
self.event.cache.clear()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.event.cache.clear()

View File

@@ -52,7 +52,10 @@ class MultiStringField(TextField):
if isinstance(value, (list, tuple)):
return DELIMITER + DELIMITER.join(value) + DELIMITER
elif value is None:
return ""
if self.null:
return None
else:
return ""
raise TypeError("Invalid data type passed.")
def get_prep_lookup(self, lookup_type, value): # NOQA
@@ -78,6 +81,8 @@ class MultiStringField(TextField):
return MultiStringContains
elif lookup_name == 'icontains':
return MultiStringIContains
elif lookup_name == 'isnull':
return builtin_lookups.IsNull
raise NotImplementedError(
"Lookup '{}' doesn't work with MultiStringField".format(lookup_name),
)

View File

@@ -1243,7 +1243,13 @@ class Question(LoggedModel):
max_length=190,
verbose_name=_("Internal identifier"),
help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do '
'not input one, we will generate one automatically.')
'not input one, we will generate one automatically.'),
validators=[
RegexValidator(
regex=r"^[a-zA-Z0-9.\-_]+$",
message=_("The identifier may only contain letters, numbers, dots, dashes, and underscores."),
),
],
)
help_text = I18nTextField(
verbose_name=_("Help text"),
@@ -1461,7 +1467,17 @@ class Question(LoggedModel):
class QuestionOption(models.Model):
question = models.ForeignKey('Question', related_name='options', on_delete=models.CASCADE)
identifier = models.CharField(max_length=190)
identifier = models.CharField(
max_length=190,
help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do '
'not input one, we will generate one automatically.'),
validators=[
RegexValidator(
regex=r"^[a-zA-Z0-9.\-_]+$",
message=_("The identifier may only contain letters, numbers, dots, dashes, and underscores."),
),
],
)
answer = I18nCharField(verbose_name=_('Answer'))
position = models.IntegerField(default=0)

View File

@@ -138,8 +138,8 @@ class LogEntry(models.Model):
@cached_property
def display_object(self):
from . import (
Event, Item, ItemCategory, Order, Question, Quota, SubEvent,
TaxRule, Voucher,
Discount, Event, Item, ItemCategory, Order, Question, Quota,
SubEvent, TaxRule, Voucher,
)
try:
@@ -202,6 +202,16 @@ class LogEntry(models.Model):
}),
'val': escape(co.name),
}
elif isinstance(co, Discount):
a_text = _('Discount {val}')
a_map = {
'href': reverse('control:event.items.discounts.edit', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'discount': co.id
}),
'val': escape(co.internal_name),
}
elif isinstance(co, ItemCategory):
a_text = _('Category {val}')
a_map = {

View File

@@ -906,10 +906,10 @@ class Order(LockModel, LoggedModel):
if force:
continue
if op.voucher and op.voucher.budget is not None and op.price_before_voucher is not None:
if op.voucher and op.voucher.budget is not None and op.voucher_budget_use:
if op.voucher not in v_budget:
v_budget[op.voucher] = op.voucher.budget - op.voucher.budget_used()
disc = op.price_before_voucher - op.price
disc = op.voucher_budget_use
if disc > v_budget[op.voucher]:
raise Quota.QuotaExceededException(error_messages['voucher_budget'].format(
voucher=op.voucher.code
@@ -1275,9 +1275,6 @@ class AbstractPosition(models.Model):
verbose_name=_("Variation"),
on_delete=models.PROTECT
)
price_before_voucher = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
price = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Price")
@@ -1314,6 +1311,10 @@ class AbstractPosition(models.Model):
)
is_bundled = models.BooleanField(default=False)
discount = models.ForeignKey(
'Discount', null=True, blank=True, on_delete=models.RESTRICT
)
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'), null=True)
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
@@ -2160,6 +2161,9 @@ class OrderPosition(AbstractPosition):
related_name='all_positions',
on_delete=models.PROTECT
)
voucher_budget_use = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True,
)
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2,
verbose_name=_('Tax rate')
@@ -2232,6 +2236,8 @@ class OrderPosition(AbstractPosition):
else:
setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax()
if cartpos.voucher:
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher
op.positionid = i + 1
op.save()
ops.append(op)
@@ -2580,12 +2586,25 @@ class CartPosition(AbstractPosition):
verbose_name=_("Expiration date"),
db_index=True
)
includes_tax = models.BooleanField(
default=True
tax_rate = models.DecimalField(
max_digits=7, decimal_places=2, default=Decimal('0.00'),
verbose_name=_('Tax rate')
)
override_tax_rate = models.DecimalField(
max_digits=10, decimal_places=2,
null=True, blank=True
listed_price = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
price_after_voucher = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
custom_price_input = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
custom_price_input_is_net = models.BooleanField(
default=False,
)
line_price_gross = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
objects = ScopedManager(organizer='event__organizer')
@@ -2599,21 +2618,66 @@ class CartPosition(AbstractPosition):
self.item.id, self.variation.id if self.variation else 0, self.cart_id
)
@property
def tax_rate(self):
if self.includes_tax:
if self.override_tax_rate is not None:
return self.override_tax_rate
return self.item.tax(self.price, base_price_is='gross').rate
else:
return Decimal('0.00')
@property
def tax_value(self):
if self.includes_tax:
return self.item.tax(self.price, override_tax_rate=self.override_tax_rate, base_price_is='gross').tax
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
self.event.currency)
return self.price - net
def update_listed_price_and_voucher(self, voucher_only=False, max_discount=None):
from pretix.base.services.pricing import (
get_listed_price, is_included_for_free,
)
if voucher_only:
listed_price = self.listed_price
else:
return Decimal('0.00')
if self.addon_to_id and is_included_for_free(self.item, self.addon_to):
listed_price = Decimal('0.00')
else:
listed_price = get_listed_price(self.item, self.variation, self.subevent)
if self.voucher:
price_after_voucher = self.voucher.calculate_price(listed_price, max_discount)
else:
price_after_voucher = listed_price
if self.is_bundled:
bundle = self.addon_to.item.bundles.filter(bundled_item=self.item, bundled_variation=self.variation).first()
if bundle:
listed_price = bundle.designated_price
price_after_voucher = bundle.designated_price
if listed_price != self.listed_price or price_after_voucher != self.price_after_voucher:
self.listed_price = listed_price
self.price_after_voucher = price_after_voucher
self.save(update_fields=['listed_price', 'price_after_voucher'])
def migrate_free_price_if_necessary(self):
# Migrate from pre-discounts position
if self.item.free_price and self.custom_price_input is None:
custom_price = self.price
if custom_price > 100000000:
raise ValueError('price_too_high')
self.custom_price_input = custom_price
self.custom_price_input_is_net = not False
self.save(update_fields=['custom_price_input', 'custom_price_input_is_net'])
def update_line_price(self, invoice_address, bundled_positions):
from pretix.base.services.pricing import get_line_price
line_price = get_line_price(
price_after_voucher=self.price_after_voucher,
custom_price_input=self.custom_price_input,
custom_price_input_is_net=self.custom_price_input_is_net,
tax_rule=self.item.tax_rule,
invoice_address=invoice_address,
bundled_sum=sum([b.price_after_voucher for b in bundled_positions]),
)
if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate:
self.line_price_gross = line_price.gross
self.tax_rate = line_price.rate
self.save(update_fields=['line_price_gross', 'tax_rate'])
class InvoiceAddress(models.Model):

View File

@@ -46,6 +46,7 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import gettext_lazy as _
from i18nfield.fields import I18nCharField
from pretix.base.models.base import LoggedModel
from pretix.base.validators import OrganizerSlugBanlistValidator
@@ -464,3 +465,25 @@ class TeamAPIToken(models.Model):
return self.get_events_with_any_permission()
else:
return self.team.organizer.events.none()
class OrganizerFooterLink(models.Model):
"""
A footer link assigned to an organizer.
"""
organizer = models.ForeignKey('Organizer', on_delete=models.CASCADE, related_name='footer_links')
label = I18nCharField(
max_length=200,
verbose_name=_("Link text"),
)
url = models.URLField(
verbose_name=_("Link URL"),
)
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
self.organizer.cache.clear()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.organizer.cache.clear()

View File

@@ -39,7 +39,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator
from django.db import connection, models
from django.db.models import F, OuterRef, Q, Subquery, Sum
from django.db.models import OuterRef, Q, Subquery, Sum
from django.db.models.functions import Coalesce
from django.utils.crypto import get_random_string
from django.utils.timezone import now
@@ -530,6 +530,8 @@ class Voucher(LoggedModel):
original price will be returned.
"""
if self.value is not None:
if not isinstance(self.value, Decimal):
self.value = Decimal(self.value)
if self.price_mode == 'set':
p = self.value
elif self.price_mode == 'subtract':
@@ -569,21 +571,21 @@ class Voucher(LoggedModel):
def annotate_budget_used_orders(cls, qs):
opq = OrderPosition.objects.filter(
voucher_id=OuterRef('pk'),
price_before_voucher__isnull=False,
voucher_budget_use__isnull=False,
order__status__in=[
Order.STATUS_PAID,
Order.STATUS_PENDING
]
).order_by().values('voucher_id').annotate(s=Sum(F('price_before_voucher') - F('price'))).values('s')
).order_by().values('voucher_id').annotate(s=Sum('voucher_budget_use')).values('s')
return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=10, decimal_places=2)), Decimal('0.00')))
def budget_used(self):
ops = OrderPosition.objects.filter(
voucher=self,
price_before_voucher__isnull=False,
voucher_budget_use__isnull=False,
order__status__in=[
Order.STATUS_PAID,
Order.STATUS_PENDING
]
).aggregate(s=Sum(F('price_before_voucher') - F('price')))['s'] or Decimal('0.00')
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
return ops

View File

@@ -955,6 +955,8 @@ class BoxOfficeProvider(BasePaymentProvider):
return {
"pos_id": payment.info_data.get('pos_id', None),
"receipt_id": payment.info_data.get('receipt_id', None),
"payment_type": payment.info_data.get('payment_type', None),
"payment_data": payment.info_data.get('payment_data', {}),
}
def payment_control_render(self, request, payment) -> str:

View File

@@ -49,12 +49,13 @@ from arabic_reshaper import ArabicReshaper
from bidi.algorithm import get_display
from django.conf import settings
from django.contrib.staticfiles import finders
from django.db.models import Max, Min
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import SimpleLazyObject
from django.utils.html import conditional_escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext
from i18nfield.strings import LazyI18nString
from PyPDF2 import PdfFileReader
from pytz import timezone
@@ -394,30 +395,41 @@ DEFAULT_VARIABLES = OrderedDict((
("seat", {
"label": _("Seat: Full name"),
"editor_sample": _("Ground floor, Row 3, Seat 4"),
"evaluate": lambda op, order, ev: str(op.seat if op.seat else
"evaluate": lambda op, order, ev: str(get_seat(op) if get_seat(op) else
_('General admission') if ev.seating_plan_id is not None else "")
}),
("seat_zone", {
"label": _("Seat: zone"),
"editor_sample": _("Ground floor"),
"evaluate": lambda op, order, ev: str(op.seat.zone_name if op.seat else
"evaluate": lambda op, order, ev: str(get_seat(op).zone_name if get_seat(op) else
_('General admission') if ev.seating_plan_id is not None else "")
}),
("seat_row", {
"label": _("Seat: row"),
"editor_sample": "3",
"evaluate": lambda op, order, ev: str(op.seat.row_name if op.seat else "")
"evaluate": lambda op, order, ev: str(get_seat(op).row_name if get_seat(op) else "")
}),
("seat_number", {
"label": _("Seat: seat number"),
"editor_sample": 4,
"evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "")
"evaluate": lambda op, order, ev: str(get_seat(op).seat_number if get_seat(op) else "")
}),
("first_scan", {
"label": _("Date and time of first scan"),
"editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: get_first_scan(op)
}),
("giftcard_issuance_date", {
"label": _("Gift card: Issuance date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: get_giftcard_issuance(op, ev)
}),
("giftcard_expiry_date", {
"label": _("Gift card: Expiration date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: get_giftcard_expiry(op, ev)
}),
))
DEFAULT_IMAGES = OrderedDict([])
@@ -492,22 +504,36 @@ def variables_from_questions(sender, *args, **kwargs):
for q in sender.questions.all():
if q.type == Question.TYPE_FILE:
continue
d['question_{}'.format(q.identifier)] = {
'label': _('Question: {question}').format(question=q.question),
'editor_sample': _('<Answer: {question}>').format(question=q.question),
'evaluate': partial(get_answer, question_id=q.pk),
'migrate_from': 'question_{}'.format(q.pk)
}
d['question_{}'.format(q.pk)] = {
'label': _('Question: {question}').format(question=q.question),
'editor_sample': _('<Answer: {question}>').format(question=q.question),
'evaluate': partial(get_answer, question_id=q.pk)
'evaluate': partial(get_answer, question_id=q.pk),
'hidden': True,
}
return d
def _get_attendee_name_part(key, op, order, ev):
if isinstance(key, tuple):
return ' '.join(p for p in [_get_attendee_name_part(c[0], op, order, ev) for c in key] if p)
return op.attendee_name_parts.get(key, '')
parts = [_get_attendee_name_part(c[0], op, order, ev) for c in key if not (c[0] == 'salutation' and op.attendee_name_parts.get(c[0], '') == "Mx")]
return ' '.join(p for p in parts if p)
value = op.attendee_name_parts.get(key, '')
if key == 'salutation':
return pgettext('person_name_salutation', value)
return value
def _get_ia_name_part(key, op, order, ev):
return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
value = order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
if key == 'salutation' and value:
return pgettext('person_name_salutation', value)
return value
def get_images(event):
@@ -523,6 +549,14 @@ def get_variables(event):
v = copy.copy(DEFAULT_VARIABLES)
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
concatenation_for_salutation = scheme.get("concatenation_for_salutation", scheme["concatenation"])
v['attendee_name_for_salutation'] = {
'label': _("Attendee name for salutation"),
'editor_sample': _("Mr Doe"),
'evaluate': lambda op, order, ev: concatenation_for_salutation(op.attendee_name_parts or {})
}
for key, label, weight in scheme['fields']:
v['attendee_name_%s' % key] = {
'label': _("Attendee name: {part}").format(part=label),
@@ -540,6 +574,12 @@ def get_variables(event):
v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
v['attendee_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
v['invoice_name_for_salutation'] = {
'label': _("Invoice address name for salutation"),
'editor_sample': _("Mr Doe"),
'evaluate': lambda op, order, ev: concatenation_for_salutation(order.invoice_address.name_parts if getattr(order, 'invoice_address', None) else {})
}
for key, label, weight in scheme['fields']:
v['invoice_name_%s' % key] = {
'label': _("Invoice address name: {part}").format(part=label),
@@ -553,6 +593,24 @@ def get_variables(event):
return v
def get_giftcard_expiry(op: OrderPosition, ev):
if not op.item.issue_giftcard:
return "" # performance optimization
m = op.issued_gift_cards.aggregate(m=Min('expires'))['m']
if not m:
return ""
return date_format(m.astimezone(ev.timezone), "SHORT_DATE_FORMAT")
def get_giftcard_issuance(op: OrderPosition, ev):
if not op.item.issue_giftcard:
return "" # performance optimization
m = op.issued_gift_cards.aggregate(m=Max('issuance'))['m']
if not m:
return ""
return date_format(m.astimezone(ev.timezone), "SHORT_DATE_FORMAT")
def get_first_scan(op: OrderPosition):
scans = list(op.checkins.all())
@@ -564,6 +622,14 @@ def get_first_scan(op: OrderPosition):
return ""
def get_seat(op: OrderPosition):
if op.seat_id:
return op.seat
if op.addon_to_id:
return op.addon_to.seat
return None
reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={
'delete_harakat': True,
'support_ligatures': False,
@@ -668,11 +734,16 @@ class Renderer:
text = o['text']
def replace(x):
if x.group(1) not in self.variables:
if x.group(1).startswith('itemmeta:'):
return op.item.meta_data.get(x.group(1)[9:]) or ''
elif x.group(1).startswith('meta:'):
return ev.meta_data.get(x.group(1)[5:]) or ''
elif x.group(1) not in self.variables:
return x.group(0)
if x.group(1) == 'secret':
# Do not use shortened version
return op.secret
try:
return self.variables[x.group(1)]['evaluate'](op, order, ev)
except:
@@ -681,7 +752,7 @@ class Renderer:
# We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this
# 1:1 on other platforms that render PDFs through our API (libpretixprint)
return re.sub(r'\{([a-zA-Z0-9_]+)\}', replace, text)
return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text)
elif o['content'].startswith('itemmeta:'):
return op.item.meta_data.get(o['content'][9:]) or ''

View File

@@ -247,7 +247,7 @@ def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_us
**kwargs
)
changed = position.secret != secret
if position.secret and changed and gen.use_revocation_list:
if position.secret and changed and gen.use_revocation_list and position.pk:
position.revoked_secrets.create(event=event, secret=position.secret)
position.secret = secret
if save and changed:

View File

@@ -54,11 +54,14 @@ from pretix.base.models import (
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.models.tax import TaxRule
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.pricing import get_price
from pretix.base.services.pricing import (
apply_discounts, get_line_price, get_listed_price, get_price,
is_included_for_free,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.settings import PERSON_NAME_SCHEMES, LazyI18nStringList
@@ -145,13 +148,15 @@ error_messages = {
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat',
'price_before_voucher'))
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
'price_after_voucher', 'custom_price_input',
'custom_price_input_is_net'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price'))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent', 'seat', 'price_before_voucher'))
VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price_after_voucher'))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'voucher',
'quotas', 'subevent', 'seat', 'listed_price',
'price_after_voucher'))
order = {
RemoveOperation: 10,
VoucherOperation: 15,
@@ -178,8 +183,8 @@ class CartManager:
@property
def positions(self):
return CartPosition.objects.filter(
Q(cart_id=self.cart_id) & Q(event=self.event)
return self.event.cartposition_set.filter(
Q(cart_id=self.cart_id)
).select_related('item', 'subevent')
def _is_seated(self, item, subevent):
@@ -390,7 +395,6 @@ class CartManager:
'addons'
).order_by('-is_bundled')
err = None
changed_prices = {}
for cp in expired:
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
@@ -401,40 +405,16 @@ class CartManager:
if cp.is_bundled:
bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first()
if bundle:
price = bundle.designated_price or 0
listed_price = bundle.designated_price or 0
else:
price = cp.price
changed_prices[cp.pk] = price
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
force_custom_price=True, cp_is_net=False)
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
force_custom_price=True)
pbv = TAXED_ZERO
listed_price = cp.price
price_after_voucher = listed_price
else:
bundled_sum = Decimal('0.00')
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
bundledprice = changed_prices.get(bundledp.pk, bundledp.price)
bundled_sum += bundledprice
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
price = TaxedPrice(net=price.net, gross=price.net, rate=Decimal('0'), tax=Decimal('0'), name='')
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=Decimal('0'), tax=Decimal('0'), name='')
listed_price = get_listed_price(cp.item, cp.variation, cp.subevent)
if cp.voucher:
price_after_voucher = cp.voucher.calculate_price(listed_price)
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
bundled_sum=bundled_sum)
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
bundled_sum=bundled_sum)
price_after_voucher = listed_price
quotas = list(cp.quotas)
if not quotas:
@@ -450,7 +430,8 @@ class CartManager:
op = self.ExtendOperation(
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat, price_before_voucher=pbv
quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price,
price_after_voucher=price_after_voucher,
)
self._check_item_constraints(op)
@@ -466,7 +447,10 @@ class CartManager:
try:
voucher = self.event.vouchers.get(code__iexact=voucher_code.strip())
except Voucher.DoesNotExist:
raise CartError(error_messages['voucher_invalid'])
if self.event.organizer.accepted_gift_cards.filter(secret__iexact=voucher_code).exists():
raise CartError(error_messages['gift_card'])
else:
raise CartError(error_messages['voucher_invalid'])
voucher_use_diff = Counter()
ops = []
@@ -489,26 +473,22 @@ class CartManager:
if p.is_bundled:
continue
bundled_sum = Decimal('0.00')
if not p.addon_to_id:
for bundledp in p.addons.all():
if bundledp.is_bundled:
bundledprice = bundledp.price
bundled_sum += bundledprice
price = self._get_price(p.item, p.variation, voucher, None, p.subevent, bundled_sum=bundled_sum)
"""
if price.gross > p.price:
continue
"""
if p.listed_price is None:
if p.addon_to_id and is_included_for_free(p.item, p.addon_to):
listed_price = Decimal('0.00')
else:
listed_price = get_listed_price(p.item, p.variation, p.subevent)
else:
listed_price = p.listed_price
price_after_voucher = voucher.calculate_price(listed_price)
voucher_use_diff[voucher] += 1
ops.append((p.price - price.gross, self.VoucherOperation(p, voucher, price)))
ops.append((listed_price - price_after_voucher, self.VoucherOperation(p, voucher, price_after_voucher)))
# If there are not enough voucher usages left for the full cart, let's apply them in the order that benefits
# the user the most.
ops.sort(key=lambda k: k[0], reverse=True)
self._operations += [k[1] for k in ops]\
self._operations += [k[1] for k in ops]
if not voucher_use_diff:
raise CartError(error_messages['voucher_no_match'])
@@ -575,7 +555,6 @@ class CartManager:
# Fetch bundled items
bundled = []
bundled_sum = Decimal('0.00')
db_bundles = list(item.bundles.all())
self._update_items_cache([b.bundled_item_id for b in db_bundles], [b.bundled_variation_id for b in db_bundles])
for bundle in db_bundles:
@@ -595,28 +574,49 @@ class CartManager:
else:
bundle_quotas = []
if bundle.designated_price:
bprice = self._get_price(bitem, bvar, None, bundle.designated_price, subevent, force_custom_price=True,
cp_is_net=False)
else:
bprice = TAXED_ZERO
bundled_sum += bundle.designated_price * bundle.count
bop = self.AddOperation(
count=bundle.count, item=bitem, variation=bvar, price=bprice,
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
includes_tax=bool(bprice.rate), bundled=[], seat=None, price_before_voucher=bprice,
count=bundle.count,
item=bitem,
variation=bvar,
voucher=None,
quotas=bundle_quotas,
addon_to='FAKE',
subevent=subevent,
bundled=[],
seat=None,
listed_price=bundle.designated_price,
price_after_voucher=bundle.designated_price,
custom_price_input=None,
custom_price_input_is_net=False,
)
self._check_item_constraints(bop, operations)
bundled.append(bop)
price = self._get_price(item, variation, voucher, i.get('price'), subevent, bundled_sum=bundled_sum)
pbv = self._get_price(item, variation, None, i.get('price'), subevent, bundled_sum=bundled_sum)
listed_price = get_listed_price(item, variation, subevent)
if voucher:
price_after_voucher = voucher.calculate_price(listed_price)
else:
price_after_voucher = listed_price
custom_price = None
if item.free_price and i.get('price'):
custom_price = Decimal(str(i.get('price')).replace(",", "."))
if custom_price > 100000000:
raise ValueError('price_too_high')
op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat,
price_before_voucher=pbv
count=i['count'],
item=item,
variation=variation,
voucher=voucher,
quotas=quotas,
addon_to=False,
subevent=subevent,
bundled=bundled,
seat=seat,
listed_price=listed_price,
price_after_voucher=price_after_voucher,
custom_price_input=custom_price,
custom_price_input_is_net=self.event.settings.display_net_prices,
)
self._check_item_constraints(op, operations)
operations.append(op)
@@ -707,16 +707,27 @@ class CartManager:
input_addons[cp.id][a['item'], a['variation']] = a.get('count', 1)
selected_addons[cp.id, item.category_id][a['item'], a['variation']] = a.get('count', 1)
if price_included[cp.pk].get(item.category_id):
price = TAXED_ZERO
if is_included_for_free(item, cp):
listed_price = Decimal('0.00')
else:
price = self._get_price(item, variation, None, a.get('price'), cp.subevent)
listed_price = get_listed_price(item, variation, cp.subevent)
custom_price = None
if item.free_price and a.get('price'):
custom_price = Decimal(str(a.get('price')).replace(",", "."))
if custom_price > 100000000:
raise ValueError('price_too_high')
# Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky)
for ca in current_addons[cp][a['item'], a['variation']]:
if ca.price != price.gross:
ca.price = price.gross
ca.save(update_fields=['price'])
if ca.listed_price != listed_price:
ca.listed_price = ca.listed_price
ca.price_after_voucher = ca.price_after_voucher
ca.save(update_fields=['listed_price', 'price_after_voucher'])
if ca.custom_price_input != custom_price:
ca.custom_price_input = custom_price
ca.custom_price_input_is_net = self.event.settings.display_net_prices
ca.price_after_voucher = ca.price_after_voucher
ca.save(update_fields=['custom_price_input', 'custom_price_input'])
if a.get('count', 1) > len(current_addons[cp][a['item'], a['variation']]):
# This add-on is new, add it to the cart
@@ -725,9 +736,18 @@ class CartManager:
op = self.AddOperation(
count=a.get('count', 1) - len(current_addons[cp][a['item'], a['variation']]),
item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None,
price_before_voucher=None
item=item,
variation=variation,
voucher=None,
quotas=quotas,
addon_to=cp,
subevent=cp.subevent,
bundled=[],
seat=None,
listed_price=listed_price,
price_after_voucher=listed_price,
custom_price_input=custom_price,
custom_price_input_is_net=self.event.settings.display_net_prices,
)
self._check_item_constraints(op, operations)
operations.append(op)
@@ -972,13 +992,31 @@ class CartManager:
err = err or error_messages['seat_unavailable']
for k in range(available_count):
line_price = get_line_price(
price_after_voucher=op.price_after_voucher,
custom_price_input=op.custom_price_input,
custom_price_input_is_net=op.custom_price_input_is_net,
tax_rule=op.item.tax_rule,
invoice_address=self.invoice_address,
bundled_sum=sum([pp.count * pp.price_after_voucher for pp in op.bundled]),
)
cp = CartPosition(
event=self.event, item=op.item, variation=op.variation,
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat,
override_tax_rate=op.price.rate,
price_before_voucher=op.price_before_voucher.gross if op.price_before_voucher is not None else None
event=self.event,
item=op.item,
variation=op.variation,
expires=self._expiry,
cart_id=self.cart_id,
voucher=op.voucher,
addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent,
seat=op.seat,
listed_price=op.listed_price,
price_after_voucher=op.price_after_voucher,
custom_price_input=op.custom_price_input,
custom_price_input_is_net=op.custom_price_input_is_net,
line_price_gross=line_price.gross,
tax_rate=line_price.tax,
price=line_price.gross,
)
if self.event.settings.attendee_names_asked:
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
@@ -1007,12 +1045,26 @@ class CartManager:
if op.bundled:
cp.save() # Needs to be in the database already so we have a PK that we can reference
for b in op.bundled:
bline_price = (
b.item.tax_rule or TaxRule(rate=Decimal('0.00'))
).tax(b.listed_price, base_price_is='gross', invoice_address=self.invoice_address) # todo compare with previous behaviour
for j in range(b.count):
new_cart_positions.append(CartPosition(
event=self.event, item=b.item, variation=b.variation,
price=b.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=None, addon_to=cp, override_tax_rate=b.price.rate,
subevent=b.subevent, includes_tax=b.includes_tax, is_bundled=True
event=self.event,
item=b.item,
variation=b.variation,
expires=self._expiry, cart_id=self.cart_id,
voucher=None,
addon_to=cp,
subevent=b.subevent,
listed_price=b.listed_price,
price_after_voucher=b.price_after_voucher,
custom_price_input=b.custom_price_input,
custom_price_input_is_net=b.custom_price_input_is_net,
line_price_gross=bline_price.gross,
tax_rate=bline_price.tax,
price=bline_price.gross,
is_bundled=True
))
new_cart_positions.append(cp)
@@ -1024,11 +1076,11 @@ class CartManager:
op.position.delete()
elif available_count == 1:
op.position.expires = self._expiry
op.position.price = op.price.gross
if op.price_before_voucher is not None:
op.position.price_before_voucher = op.price_before_voucher.gross
op.position.listed_price = op.listed_price
op.position.price_after_voucher = op.price_after_voucher
# op.position.price will be updated by recompute_final_prices_and_taxes()
try:
op.position.save(force_update=True)
op.position.save(force_update=True, update_fields=['expires', 'listed_price', 'price_after_voucher'])
except DatabaseError:
# Best effort... The position might have been deleted in the meantime!
pass
@@ -1046,10 +1098,10 @@ class CartManager:
# be expected
continue
op.position.price_before_voucher = op.position.price
op.position.price = op.price.gross
op.position.price_after_voucher = op.price_after_voucher
op.position.voucher = op.voucher
op.position.save()
# op.posiiton.price will be set in recompute_final_prices_and_taxes
op.position.save(update_fields=['price_after_voucher', 'voucher'])
vouchers_ok[op.voucher] -= 1
for p in new_cart_positions:
@@ -1074,6 +1126,35 @@ class CartManager:
return False
def recompute_final_prices_and_taxes(self):
positions = sorted(list(self.positions), key=lambda op: -(op.addon_to_id or 0))
diff = Decimal('0.00')
for cp in positions:
if cp.listed_price is None:
# migration from old system? also used in unit tests
cp.update_listed_price_and_voucher()
cp.migrate_free_price_if_necessary()
cp.update_line_price(self.invoice_address, [b for b in positions if b.addon_to_id == cp.pk and b.is_bundled])
discount_results = apply_discounts(
self.event,
self._sales_channel,
[
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in positions
]
)
for cp, (new_price, discount) in zip(positions, discount_results):
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
diff += new_price - cp.price
cp.price = new_price
cp.discount = discount
cp.save(update_fields=['price', 'discount'])
return diff
def commit(self):
self._check_presale_dates()
self._check_max_cart_size()
@@ -1091,33 +1172,11 @@ class CartManager:
self.now_dt = now_dt
self._extend_expiry_of_valid_existing_positions()
err = self._perform_operations() or err
self.recompute_final_prices_and_taxes()
if err:
raise CartError(err)
def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress):
positions = CartPosition.objects.filter(
cart_id=cart_id, event=event
).select_related('item', 'item__tax_rule')
totaldiff = Decimal('0.00')
for pos in positions:
if not pos.item.tax_rule:
continue
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
if pos.tax_rate != rate:
if not pos.item.tax_rule.keep_gross_if_rate_changes:
current_net = pos.price - pos.tax_value
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
totaldiff += new_gross - pos.price
pos.price = new_gross
pos.includes_tax = rate != Decimal('0.00')
pos.override_tax_rate = rate
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
return totaldiff
def get_fees(event, request, total, invoice_address, provider, positions):
from pretix.presale.views.cart import cart_session

View File

@@ -41,8 +41,8 @@ import pytz
from django.core.files import File
from django.db import IntegrityError, transaction
from django.db.models import (
BooleanField, Count, ExpressionWrapper, F, IntegerField, OuterRef, Q,
Subquery, Value,
BooleanField, Count, ExpressionWrapper, F, IntegerField, Max, Min,
OuterRef, Q, Subquery, Value,
)
from django.db.models.functions import Coalesce, TruncDate
from django.dispatch import receiver
@@ -60,7 +60,7 @@ from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
from pretix.helpers.jsonlogic_query import (
Equal, GreaterEqualThan, GreaterThan, InList, LowerEqualThan, LowerThan,
tolerance,
MinutesSince, tolerance,
)
@@ -210,19 +210,60 @@ def _logic_explain(rules, ev, rule_data):
elif var == 'product' or var == 'variation':
var_weights[vname] = (1000, 0)
var_texts[vname] = _('Ticket type not allowed')
elif var in ('entries_number', 'entries_today', 'entries_days'):
elif var in ('entries_number', 'entries_today', 'entries_days', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday'):
w = {
'minutes_since_first_entry': 80,
'minutes_since_last_entry': 90,
'entries_days': 100,
'entries_number': 120,
'entries_today': 140,
'now_isoweekday': 210,
}
operator_weights = {
'==': 2,
'<': 1,
'<=': 1,
'>': 1,
'>=': 1,
'!=': 3,
}
l = {
'minutes_since_last_entry': _('time since last entry'),
'minutes_since_first_entry': _('time since first entry'),
'entries_days': _('number of days with an entry'),
'entries_number': _('number of entries'),
'entries_today': _('number of entries today'),
'now_isoweekday': _('week day'),
}
compare_to = rhs[0]
var_weights[vname] = (w[var], abs(compare_to - rule_data[var]))
penalty = 0
if var in ('minutes_since_last_entry', 'minutes_since_first_entry'):
is_comparison_to_minus_one = (
(operator == '<' and compare_to <= 0) or
(operator == '<=' and compare_to < 0) or
(operator == '>=' and compare_to < 0) or
(operator == '>' and compare_to <= 0) or
(operator == '==' and compare_to == -1) or
(operator == '!=' and compare_to == -1)
)
if is_comparison_to_minus_one:
# These are "technical" comparisons without real meaning, we don't want to show them.
penalty = 1000
var_weights[vname] = (w[var] + operator_weights.get(operator, 0) + penalty, abs(compare_to - rule_data[var]))
if var == 'now_isoweekday':
compare_to = {
1: _('Monday'),
2: _('Tuesday'),
3: _('Wednesday'),
4: _('Thursday'),
5: _('Friday'),
6: _('Saturday'),
7: _('Sunday'),
}.get(compare_to, compare_to)
if operator == '==':
var_texts[vname] = _('{variable} is not {value}').format(variable=l[var], value=compare_to)
elif operator in ('<', '<='):
@@ -231,6 +272,7 @@ def _logic_explain(rules, ev, rule_data):
var_texts[vname] = _('Minimum {variable} exceeded').format(variable=l[var])
elif operator == '!=':
var_texts[vname] = _('{variable} is {value}').format(variable=l[var], value=compare_to)
else:
raise ValueError(f'Unknown variable {var}')
@@ -289,6 +331,11 @@ class LazyRuleVars:
def now(self):
return self._dt
@property
def now_isoweekday(self):
tz = self._clist.event.timezone
return self._dt.astimezone(tz).isoweekday()
@property
def product(self):
return self._position.item_id
@@ -315,6 +362,30 @@ class LazyRuleVars:
day=TruncDate('datetime', tzinfo=tz)
).values('day').distinct().count()
@cached_property
def minutes_since_last_entry(self):
tz = self._clist.event.timezone
with override(tz):
last_entry = self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).order_by('datetime').last()
if last_entry is None:
# Returning "None" would be "correct", but the handling of "None" in JSON logic is inconsistent
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
# consistent.
return -1
return (now() - last_entry.datetime).total_seconds() // 60
@cached_property
def minutes_since_first_entry(self):
tz = self._clist.event.timezone
with override(tz):
last_entry = self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).order_by('datetime').first()
if last_entry is None:
# Returning "None" would be "correct", but the handling of "None" in JSON logic is inconsistent
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
# consistent.
return -1
return (now() - last_entry.datetime).total_seconds() // 60
class SQLLogic:
"""
@@ -399,6 +470,8 @@ class SQLLogic:
elif operator == 'var':
if values[0] == 'now':
return Value(now().astimezone(pytz.UTC))
elif values[0] == 'now_isoweekday':
return Value(now().astimezone(self.list.event.timezone).isoweekday())
elif values[0] == 'product':
return F('item_id')
elif values[0] == 'variation':
@@ -450,6 +523,38 @@ class SQLLogic:
Value(0),
output_field=IntegerField()
)
elif values[0] == 'minutes_since_last_entry':
sq_last_entry = Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
).values('position_id').order_by().annotate(
m=Max('datetime')
).values('m')
)
return Coalesce(
MinutesSince(sq_last_entry),
Value(-1),
output_field=IntegerField()
)
elif values[0] == 'minutes_since_first_entry':
sq_last_entry = Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
).values('position_id').order_by().annotate(
m=Min('datetime')
).values('m')
)
return Coalesce(
MinutesSince(sq_last_entry),
Value(-1),
output_field=IntegerField()
)
else:
raise ValueError(f'Unknown operator {operator}')
@@ -691,6 +796,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
gate=device.gate if device else None,
nonce=nonce,
forced=force and (not entry_allowed or from_revoked_secret),
force_sent=force,
raw_barcode=raw_barcode,
)
op.order.log_action('pretix.event.checkin', data={

View File

@@ -412,8 +412,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
logger.exception('Could not attach invoice to email')
pass
if attach_size < settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT:
# Do not attach more than 4MB, it will bounce way to often.
if attach_size < settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT - 1:
# Do not attach more than (limit - 1 MB) in tickets (1MB space for invoice, email itself, …),
# it will bounce way to often.
for a in args:
try:
email.attach(*a)

View File

@@ -35,6 +35,7 @@
import json
import logging
import sys
from collections import Counter, defaultdict, namedtuple
from datetime import datetime, time, timedelta
from decimal import Decimal
@@ -53,7 +54,7 @@ from django.db.transaction import get_connection
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext as _
from django.utils.translation import gettext as _, gettext_lazy
from django_scopes import scopes_disabled
from pretix.api.models import OAuthApplication
@@ -68,7 +69,6 @@ from pretix.base.models import (
Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle
from pretix.base.models.orders import (
InvoiceAddress, OrderFee, OrderRefund, generate_secret,
)
@@ -86,7 +86,9 @@ from pretix.base.services.mail import SendMailException
from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order,
)
from pretix.base.services.pricing import get_price
from pretix.base.services.pricing import (
apply_discounts, get_listed_price, get_price,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
from pretix.base.signals import (
@@ -384,7 +386,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
cancellation_fee=None, keep_fees=None, cancel_invoice=True):
cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None):
"""
Mark this order as canceled
:param order: The order to change
@@ -481,7 +483,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
data={'cancellation_fee': cancellation_fee})
data={'cancellation_fee': cancellation_fee, 'comment': comment})
order.cancellation_requests.all().delete()
order.create_transactions()
@@ -489,7 +491,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
if send_mail:
email_template = order.event.settings.mail_text_order_canceled
with language(order.locale, order.event.settings.region):
email_context = get_email_context(event=order.event, order=order)
email_context = get_email_context(event=order.event, order=order, comment=comment or "")
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
try:
order.send_mail(
@@ -565,7 +567,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
_check_date(event, now_dt)
products_seen = Counter()
changed_prices = {}
q_avail = Counter()
v_avail = Counter()
v_budget = {}
deleted_positions = set()
seats_seen = set()
@@ -582,6 +585,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
cp.delete()
sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled))
# Check availability
for i, cp in enumerate(sorted_positions):
if cp.pk in deleted_positions:
continue
@@ -601,29 +606,17 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
break
if cp.voucher:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
).exclude(pk=cp.pk)
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1:
if cp.voucher not in v_avail:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
).exclude(cart_id=cp.cart_id)
v_avail[cp.voucher] = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
v_avail[cp.voucher] -= 1
if v_avail[cp.voucher] < 0:
err = err or error_messages['voucher_redeemed']
delete(cp)
continue
if cp.voucher.budget is not None:
if cp.voucher not in v_budget:
v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used()
disc = cp.price_before_voucher - cp.price
if disc > v_budget[cp.voucher]:
new_disc = max(0, v_budget[cp.voucher])
cp.price = cp.price + (disc - new_disc)
cp.save()
err = err or error_messages['voucher_budget_used']
v_budget[cp.voucher] -= new_disc
continue
else:
v_budget[cp.voucher] -= disc
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
err = err or error_messages['some_subevent_not_started']
delete(cp)
@@ -662,7 +655,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)
) and not cp.is_bundled:
delete(cp)
cp.delete()
err = error_messages['voucher_required']
break
@@ -671,56 +663,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
# time, since we absolutely can not overbook a seat.
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel):
err = err or error_messages['seat_unavailable']
cp.delete()
delete(cp)
continue
if cp.expires >= now_dt and not cp.voucher:
# Other checks are not necessary
continue
max_discount = None
if cp.price_before_voucher is not None and cp.voucher in v_budget:
current_discount = cp.price_before_voucher - cp.price
max_discount = max(v_budget[cp.voucher] + current_discount, 0)
try:
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
bprice = bundle.designated_price or 0
except ItemBundle.DoesNotExist:
bprice = cp.price
except ItemBundle.MultipleObjectsReturned:
raise OrderError("Invalid product configuration (duplicate bundle)")
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
custom_price_is_tax_rate=cp.override_tax_rate,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
custom_price_is_tax_rate=cp.override_tax_rate,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
changed_prices[cp.pk] = bprice
else:
bundled_sum = Decimal('0.00')
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price)
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
except TaxRule.SaleNotAllowed:
err = err or error_messages['country_blocked']
cp.delete()
continue
if max_discount is not None:
v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross)
if price is False or len(quotas) == 0:
if len(quotas) == 0:
err = err or error_messages['unavailable']
delete(cp)
continue
@@ -742,42 +692,88 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
continue
if pbv is not None and pbv.gross != price.gross:
cp.price_before_voucher = pbv.gross
else:
cp.price_before_voucher = None
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
cp.price = price.gross
cp.includes_tax = bool(price.rate)
cp.save()
err = err or error_messages['price_changed']
continue
quota_ok = True
ignore_all_quotas = cp.expires >= now_dt or (
cp.voucher and (cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)))
cp.voucher and (
cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)
)
)
if not ignore_all_quotas:
for quota in quotas:
if cp.voucher and cp.voucher.block_quota and cp.voucher.quota_id == quota.pk:
continue
avail = quota.availability(now_dt)
if avail[0] != Quota.AVAILABILITY_OK:
# This quota is sold out/currently unavailable, so do not sell this at all
if quota not in q_avail:
avail = quota.availability(now_dt)
q_avail[quota] = avail[1] if avail[1] is not None else sys.maxsize
q_avail[quota] -= 1
if q_avail[quota] < 0:
err = err or error_messages['unavailable']
quota_ok = False
break
if quota_ok:
cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
else:
if not quota_ok:
# Sorry, can't let you keep that!
delete(cp)
# Check prices
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
old_total = sum(cp.price for cp in sorted_positions)
for i, cp in enumerate(sorted_positions):
if cp.listed_price is None:
# migration from pre-discount cart positions
cp.update_listed_price_and_voucher(max_discount=None)
cp.migrate_free_price_if_necessary()
# deal with max discount
max_discount = None
if cp.voucher and cp.voucher.budget is not None:
if cp.voucher not in v_budget:
v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used()
max_discount = max(v_budget[cp.voucher], 0)
if cp.expires < now_dt or cp.listed_price is None:
# Guarantee on listed price is expired
cp.update_listed_price_and_voucher(max_discount=max_discount)
elif cp.voucher:
cp.update_listed_price_and_voucher(max_discount=max_discount, voucher_only=True)
if max_discount is not None:
v_budget[cp.voucher] = v_budget[cp.voucher] - (cp.listed_price - cp.price_after_voucher)
try:
cp.update_line_price(address, [b for b in sorted_positions if b.addon_to_id == cp.pk and b.is_bundled and b.pk and b.pk not in deleted_positions])
except TaxRule.SaleNotAllowed:
err = err or error_messages['country_blocked']
delete(cp)
continue
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
discount_results = apply_discounts(
event,
sales_channel,
[
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in sorted_positions
]
)
for cp, (new_price, discount) in zip(sorted_positions, discount_results):
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
cp.price = new_price
cp.discount = discount
cp.save(update_fields=['price', 'discount'])
new_total = sum(cp.price for cp in sorted_positions)
if old_total != new_total:
err = err or error_messages['price_changed']
# Store updated positions
for cp in sorted_positions:
cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
if err:
raise OrderError(err, errargs)
@@ -934,7 +930,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
invoice, payment: OrderPayment, is_free=False):
email_context = get_email_context(event=event, order=order, payment=payment if pprov else None)
email_subject = _('Your order: %(code)s') % {'code': order.code}
email_subject = gettext_lazy('Your order: {code}')
try:
order.send_mail(
email_subject, email_template, email_context,
@@ -952,7 +948,7 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str, is_free=False):
email_context = get_email_context(event=event, order=order, position=position)
email_subject = _('Your event registration: %(code)s') % {'code': order.code}
email_subject = gettext_lazy('Your event registration: {code}')
try:
position.send_mail(
@@ -1467,7 +1463,7 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
self._invoice_dirty = True
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None):
if isinstance(seat, str):
if not seat:
@@ -1492,6 +1488,8 @@ class OrderChangeManager:
if price is None:
raise OrderError(self.error_messages['product_invalid'])
if item.variations.exists() and not variation:
raise OrderError(self.error_messages['product_without_variation'])
if not addon_to and item.category and item.category.is_addon:
raise OrderError(self.error_messages['addon_to_required'])
if addon_to:
@@ -1856,16 +1854,14 @@ class OrderChangeManager:
op.position.item = op.item
op.position.variation = op.variation
op.position._calculate_tax()
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
op.position.price_before_voucher = max(
op.position.price,
get_price(
op.position.item, op.position.variation,
subevent=op.position.subevent,
custom_price=op.position.price,
invoice_address=self._invoice_address
).gross
)
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price))
else:
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
@@ -1906,16 +1902,13 @@ class OrderChangeManager:
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
op.position.price_before_voucher = max(
op.position.price,
get_price(
op.position.item, op.position.variation,
subevent=op.position.subevent,
custom_price=op.position.price,
invoice_address=self._invoice_address
).gross
)
if op.position.voucher_budget_use is not None and op.position.voucher and not op.position.addon_to_id:
listed_price = get_listed_price(op.position.item, op.position.variation, op.position.subevent)
if not op.position.item.tax_rule or op.position.item.tax_rule.price_includes_tax:
price_after_voucher = max(op.position.price, op.position.voucher.calculate_price(listed_price))
else:
price_after_voucher = max(op.position.price - op.position.tax_value, op.position.voucher.calculate_price(listed_price))
op.position.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00'))
op.position.save()
elif isinstance(op, self.AddFeeOperation):
self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={
@@ -2324,6 +2317,7 @@ class OrderChangeManager:
self._check_and_lock_memberships()
try:
self._perform_operations()
self.order.refresh_from_db()
except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked'])
self._recalculate_total_and_payment_fee()
@@ -2504,15 +2498,15 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
@scopes_disabled()
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False, comment=None,
cancel_invoice=True):
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False,
email_comment=None, refund_comment=None, cancel_invoice=True):
try:
try:
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
cancellation_fee, cancel_invoice=cancel_invoice)
cancellation_fee, cancel_invoice=cancel_invoice, comment=email_comment)
if try_auto_refund:
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
comment=comment)
comment=refund_comment)
return ret
except LockTimeoutException:
self.retry()

View File

@@ -20,39 +20,30 @@
# <https://www.gnu.org/licenses/>.
#
from decimal import Decimal
from typing import List, Optional, Tuple
from django.db.models import Q
from django.utils.timezone import now
from pretix.base.decimal import round_decimal
from pretix.base.models import (
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.event import Event, SubEvent
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
def get_price(item: Item, variation: ItemVariation = None,
voucher: Voucher = None, custom_price: Decimal = None,
subevent: SubEvent = None, custom_price_is_net: bool = False,
custom_price_is_tax_rate: Decimal=None,
custom_price_is_tax_rate: Decimal = None,
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None,
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00'),
max_discount: Decimal = None, tax_rule=None) -> TaxedPrice:
if addon_to:
try:
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
if iao.price_included:
return TAXED_ZERO
except ItemAddOn.DoesNotExist:
pass
if is_included_for_free(item, addon_to):
return TAXED_ZERO
price = item.default_price
if subevent and item.pk in subevent.item_price_overrides:
price = subevent.item_price_overrides[item.pk]
if variation is not None:
if variation.default_price is not None:
price = variation.default_price
if subevent and variation.pk in subevent.var_price_overrides:
price = subevent.var_price_overrides[variation.pk]
price = get_listed_price(item, variation, subevent)
if voucher:
price = voucher.calculate_price(price, max_discount=max_discount)
@@ -85,10 +76,10 @@ def get_price(item: Item, variation: ItemVariation = None,
price = tax_rule.tax(price, invoice_address=invoice_address)
if custom_price_is_net:
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net',
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net', override_tax_rate=price.rate,
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', gross_price_is_tax_rate=custom_price_is_tax_rate,
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', override_tax_rate=price.rate,
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
@@ -98,3 +89,83 @@ def get_price(item: Item, variation: ItemVariation = None,
price.tax = price.gross - price.net
return price
def is_included_for_free(item: Item, addon_to: AbstractPosition):
if addon_to:
try:
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
if iao.price_included:
return True
except ItemAddOn.DoesNotExist:
pass
return False
def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubEvent = None) -> Decimal:
price = item.default_price
if subevent and item.pk in subevent.item_price_overrides:
price = subevent.item_price_overrides[item.pk]
if variation is not None:
if variation.default_price is not None:
price = variation.default_price
if subevent and variation.pk in subevent.var_price_overrides:
price = subevent.var_price_overrides[variation.pk]
return price
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal) -> TaxedPrice:
if not tax_rule:
tax_rule = TaxRule(
name='',
rate=Decimal('0.00'),
price_includes_tax=True,
eu_reverse_charge=False,
)
if custom_price_input:
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address)
if custom_price_input_is_net:
price = tax_rule.tax(max(custom_price_input, price.net), base_price_is='net', override_tax_rate=price.rate,
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
return price
def apply_discounts(event: Event, sales_channel: str,
positions: List[Tuple[int, Optional[int], Decimal, bool, bool]]) -> List[Decimal]:
"""
Applies any dynamic discounts to a cart
:param event: Event the cart belongs to
:param sales_channel: Sales channel the cart was created with
:param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
:return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input
"""
new_prices = {}
discount_qs = event.discounts.filter(
Q(available_from__isnull=True) | Q(available_from__lte=now()),
Q(available_until__isnull=True) | Q(available_until__gte=now()),
sales_channels__contains=sales_channel,
active=True,
).prefetch_related('condition_limit_products').order_by('position', 'pk')
for discount in discount_qs:
result = discount.apply({
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions)
if not is_bundled and idx not in new_prices
})
for k in result.keys():
result[k] = (result[k], discount)
new_prices.update(result)
return [new_prices.get(idx, (p[2], None)) for idx, p in enumerate(positions)]

View File

@@ -132,6 +132,7 @@ def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
'already used in a voucher.', s.name))
Seat.objects.bulk_create(create_seats)
CartPosition.objects.filter(addon_to__seat__in=[s.pk for s in current_seats.values()]).delete()
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()
OrderPosition.all.filter(
Q(canceled=True) | Q(order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)),

View File

@@ -86,9 +86,9 @@ def primary_font_kwargs():
from pretix.presale.style import get_fonts
choices = [('Open Sans', 'Open Sans')]
choices += [
(a, {"title": a, "data": v}) for a, v in get_fonts().items()
]
choices += sorted([
(a, {"title": a, "data": v}) for a, v in get_fonts().items() if not v.get('pdf_only', False)
], key=lambda a: a[0])
return {
'choices': choices,
}
@@ -555,9 +555,11 @@ DEFAULTS = {
'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict(
min_value=0,
max_value=60 * 24 * 7,
),
'form_kwargs': dict(
min_value=0,
max_value=60 * 24 * 7,
label=_("Reservation period"),
required=True,
help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
@@ -1195,7 +1197,20 @@ DEFAULTS = {
help_text=_("If you ask for a phone number, explain why you do so and what you will use the phone number for.")
)
},
'show_checkin_number_user': {
'default': 'False',
'type': bool,
'serializer_class': serializers.BooleanField,
'form_class': forms.BooleanField,
'form_kwargs': dict(
label=_("Show number of check-ins to customer"),
help_text=_('With this option enabled, your customers will be able how many times they entered '
'the event. This is usually not necessary, but might be useful in combination with tickets '
'that are usable a specific number of times, so customers can see how many times they have '
'already been used. Exits or failed scans will not be counted, and the user will not see '
'the different check-in lists.'),
)
},
'ticket_download': {
'default': 'False',
'type': bool,
@@ -1901,6 +1916,8 @@ Your {event} team"""))
your order {code} for {event} has been canceled.
{comment}
You can view the details of your order at
{url}

View File

@@ -399,7 +399,10 @@ order_modified = EventPluginSignal()
Arguments: ``order``
This signal is sent out every time an order's information is modified. The order object is given
as the first argument.
as the first argument. In contrast to ``order_changed``, this signal is sent out if information
of an order or any of it's position is changed that concerns user input, such as attendee names,
invoice addresses or question answers. If the order changes in a material way, such as changed
products, prices, or tax rates, ``order_changed`` is used instead.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -409,7 +412,10 @@ order_changed = EventPluginSignal()
Arguments: ``order``
This signal is sent out every time an order's content is changed. The order object is given
as the first argument.
as the first argument. In contrast to ``modified``, this signal is sent out if the order or
any of its positions changes in a material way, such as changed products, prices, or tax rates,
``order_changed`` is used instead. If "only" user input is changed, such as attendee names,
invoice addresses or question answers, ``order_modified`` is used instead.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""

View File

View File

@@ -84,13 +84,17 @@ def timeline_for_event(event, subevent=None):
edit_url=ev_edit_url
))
if ev.presale_end:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=ev.presale_end,
description=pgettext_lazy('timeline', 'End of ticket sales'),
edit_url=ev_edit_url
))
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=(
ev.presale_end or ev.date_to or ev.date_from.astimezone(ev.timezone).replace(hour=23, minute=59, second=59)
),
description='{}{}'.format(
pgettext_lazy('timeline', 'End of ticket sales'),
f" ({pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured')})" if not ev.presale_end else ""
),
edit_url=ev_edit_url
))
rd = event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper)
if rd:
@@ -217,6 +221,30 @@ def timeline_for_event(event, subevent=None):
})
))
for d in event.discounts.filter(Q(available_from__isnull=False) | Q(available_until__isnull=False)):
if d.available_from:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=d.available_from,
description=pgettext_lazy('timeline', 'Discount "{name}" becomes active').format(name=str(d)),
edit_url=reverse('control:event.items.discounts.edit', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'discount': d.pk,
})
))
if d.available_until:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=d.available_until,
description=pgettext_lazy('timeline', 'Discount "{name}" becomes inactive').format(name=str(d)),
edit_url=reverse('control:event.items.discounts.edit', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'discount': d.pk,
})
))
for p in event.items.filter(Q(available_from__isnull=False) | Q(available_until__isnull=False)):
if p.available_from:
tl.append(TimelineEvent(

View File

@@ -29,13 +29,15 @@ from celery.result import AsyncResult
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.http import JsonResponse, QueryDict
from django.http import HttpResponse, JsonResponse, QueryDict
from django.shortcuts import redirect, render
from django.test import RequestFactory
from django.utils import timezone, translation
from django.utils.timezone import get_current_timezone
from django.utils.translation import get_language, gettext as _
from django.views import View
from django.views.generic import FormView
from redis import ResponseError
from pretix.base.models import User
from pretix.base.services.tasks import ProfiledEventTask
@@ -68,6 +70,11 @@ class AsyncMixin:
res.get(timeout=timeout, propagate=False)
except celery.exceptions.TimeoutError:
pass
except ResponseError:
# There is a long-standing concurrency issue in either celery or redis-py that hasn't been fixed
# yet. Instead of crashing, we can ignore it and the client will retry their request and hopefully
# it is fixed next time.
logger.warning('Ignored ResponseError in AsyncResult.get()')
except ConnectionError:
# Redis probably just restarted, let's just report not ready and retry next time
data = self._ajax_response_data()
@@ -306,3 +313,94 @@ class AsyncFormView(AsyncMixin, FormView):
else:
return self.error(res.info)
return redirect(self.get_check_url(res.id, False))
class AsyncPostView(AsyncMixin, View):
"""
View variant in which instead of ``post``, an ``async_post`` is executed in a celery task.
Note that this places some severe limitations on the form and the view, e.g. ``async_post`` may not
depend on the request object unless specifically supported by this class. File upload is currently also
not supported.
"""
known_errortypes = ['ValidationError']
expected_exceptions = (ValidationError,)
task_base = ProfiledEventTask
def __init_subclass__(cls):
def async_execute(self, *, request_path, url_args, url_kwargs, query_string, post_data, locale, tz,
organizer=None, event=None, user=None, session_key=None):
view_instance = cls()
req = RequestFactory().post(
request_path + '?' + query_string,
data=post_data,
content_type='application/x-www-form-urlencoded'
)
view_instance.request = req
if event:
view_instance.request.event = event
view_instance.request.organizer = event.organizer
elif organizer:
view_instance.request.organizer = organizer
if user:
view_instance.request.user = User.objects.get(pk=user) if isinstance(user, int) else user
if session_key:
engine = import_module(settings.SESSION_ENGINE)
self.SessionStore = engine.SessionStore
view_instance.request.session = self.SessionStore(session_key)
with translation.override(locale), timezone.override(pytz.timezone(tz)):
return view_instance.async_post(view_instance.request, *url_args, **url_kwargs)
cls.async_execute = app.task(
base=cls.task_base,
bind=True,
name=cls.__module__ + '.' + cls.__name__ + '.async_execute',
throws=cls.expected_exceptions
)(async_execute)
def async_post(self, request, *args, **kwargs):
pass
def get(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
return HttpResponse(status=405)
def post(self, request, *args, **kwargs):
if request.FILES:
raise TypeError('File upload currently not supported in AsyncPostView')
kwargs = {
'request_path': self.request.path,
'query_string': self.request.GET.urlencode(),
'post_data': self.request.POST.urlencode(),
'locale': get_language(),
'url_args': args,
'url_kwargs': kwargs,
'tz': get_current_timezone().zone,
}
if hasattr(self.request, 'organizer'):
kwargs['organizer'] = self.request.organizer.pk
if self.request.user.is_authenticated:
kwargs['user'] = self.request.user.pk
if hasattr(self.request, 'event'):
kwargs['event'] = self.request.event.pk
if hasattr(self.request, 'session'):
kwargs['session_key'] = self.request.session.session_key
try:
res = type(self).async_execute.apply_async(kwargs=kwargs)
except ConnectionError:
# Task very likely not yet sent, due to redis restarting etc. Let's try once again
res = type(self).async_execute.apply_async(kwargs=kwargs)
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
data = self._return_ajax_result(res)
data['check_url'] = self.get_check_url(res.id, True)
return JsonResponse(data)
else:
if res.ready():
if res.successful() and not isinstance(res.info, Exception):
return self.success(res.info)
else:
return self.error(res.info)
return redirect(self.get_check_url(res.id, False))

View File

@@ -0,0 +1,109 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
from decimal import Decimal
from django import forms
from django.utils.translation import gettext_lazy as _
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import I18nModelForm
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Discount
from pretix.control.forms import ItemMultipleChoiceField, SplitDateTimeField
class DiscountForm(I18nModelForm):
class Meta:
model = Discount
localized_fields = '__all__'
fields = [
'active',
'internal_name',
'sales_channels',
'available_from',
'available_until',
'subevent_mode',
'condition_all_products',
'condition_limit_products',
'condition_min_count',
'condition_min_value',
'condition_apply_to_addons',
'condition_ignore_voucher_discounted',
'benefit_discount_matching_percent',
'benefit_only_apply_to_cheapest_n_matches',
]
field_classes = {
'available_from': SplitDateTimeField,
'available_until': SplitDateTimeField,
'condition_limit_products': ItemMultipleChoiceField,
}
widgets = {
'subevent_mode': forms.RadioSelect,
'available_from': SplitDateTimePickerWidget(),
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
'condition_limit_products': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '<[name$=all_products]',
'class': 'scrolling-multiple-choice',
}),
'benefit_only_apply_to_cheapest_n_matches': forms.NumberInput(
attrs={
'data-display-dependency': '#id_condition_min_count',
}
)
}
def __init__(self, *args, **kwargs):
self.event = kwargs['event']
super().__init__(*args, **kwargs)
self.fields['sales_channels'] = forms.MultipleChoiceField(
label=_('Sales channels'),
required=True,
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
if c.discounts_supported
),
widget=forms.CheckboxSelectMultiple,
)
self.fields['condition_limit_products'].queryset = self.event.items.all()
self.fields['condition_min_count'].required = False
self.fields['condition_min_count'].widget.is_required = False
self.fields['condition_min_value'].required = False
self.fields['condition_min_value'].widget.is_required = False
if not self.event.has_subevents:
del self.fields['subevent_mode']
def clean(self):
d = super().clean()
if d.get('condition_min_value') and d.get('benefit_only_apply_to_cheapest_n_matches'):
# field is hidden by JS
d['benefit_only_apply_to_cheapest_n_matches'] = None
if d.get('subevent_mode') == Discount.SUBEVENT_MODE_DISTINCT and d.get('condition_min_value'):
# field is hidden by JS
d['condition_min_value'] = Decimal('0.00')
if d.get('condition_min_count') is None:
d['condition_min_count'] = 0
if d.get('condition_min_value') is None:
d['condition_min_value'] = Decimal('0.00')
return d

View File

@@ -41,7 +41,9 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db.models import Prefetch, Q, prefetch_related_objects
from django.forms import CheckboxSelectMultiple, formset_factory
from django.forms import (
CheckboxSelectMultiple, formset_factory, inlineformset_factory,
)
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape
@@ -58,7 +60,7 @@ from pretix.base.channels import get_all_sales_channels
from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.models import Event, Organizer, TaxRule, Team
from pretix.base.models.event import EventMetaValue, SubEvent
from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
@@ -523,6 +525,7 @@ class EventSettingsForm(SettingsForm):
'last_order_modification_date',
'allow_modifications_after_checkin',
'checkout_show_copy_answers_button',
'show_checkin_number_user',
'primary_color',
'theme_color_success',
'theme_color_danger',
@@ -1074,7 +1077,7 @@ class MailSettingsForm(SettingsForm):
'mail_text_order_free': ['event', 'order'],
'mail_text_order_free_attendee': ['event', 'order', 'position'],
'mail_text_order_changed': ['event', 'order'],
'mail_text_order_canceled': ['event', 'order'],
'mail_text_order_canceled': ['event', 'order', 'comment'],
'mail_text_order_expire_warning': ['event', 'order'],
'mail_text_order_custom_mail': ['event', 'order'],
'mail_text_download_reminder': ['event', 'order'],
@@ -1483,3 +1486,25 @@ ConfirmTextFormset = formset_factory(
formset=BaseConfirmTextFormSet,
can_order=True, can_delete=True, extra=0
)
class EventFooterLinkForm(I18nModelForm):
class Meta:
model = EventFooterLink
fields = ('label', 'url')
class BaseEventFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet):
def __init__(self, *args, **kwargs):
event = kwargs.pop('event', None)
if event:
kwargs['locales'] = event.settings.get('locales')
super().__init__(*args, **kwargs)
EventFooterLinkFormset = inlineformset_factory(
Event, EventFooterLink,
EventFooterLinkForm,
formset=BaseEventFooterLinkFormSet,
can_order=False, can_delete=True, extra=0
)

View File

@@ -1265,7 +1265,8 @@ class CustomerFilterForm(FilterForm):
orders = {
'email': 'email',
'identifier': 'identifier',
'name_cached': 'name_cached',
'name': 'name_cached',
'external_identifier': 'external_identifier',
}
query = forms.CharField(
label=_('Search query'),
@@ -1309,6 +1310,8 @@ class CustomerFilterForm(FilterForm):
Q(email__icontains=query)
| Q(name_cached__icontains=query)
| Q(identifier__istartswith=query)
| Q(external_identifier__icontains=query)
| Q(notes__icontains=query)
)
if fdata.get('status') == 'active':
@@ -1859,11 +1862,11 @@ class VoucherFilterForm(FilterForm):
for i in self.event.items.prefetch_related('variations').all():
variations = list(i.variations.all())
if variations:
choices.append((str(i.pk), _('{product} Any variation').format(product=i.name)))
choices.append((str(i.pk), _('{product} Any variation').format(product=str(i))))
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (i.name, v.value)))
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value)))
else:
choices.append((str(i.pk), i.name))
choices.append((str(i.pk), str(i)))
for q in self.event.quotas.all():
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
self.fields['itemvar'].choices = choices
@@ -2120,7 +2123,7 @@ class CheckinFilterForm(FilterForm):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.fields['device'].queryset = self.event.organizer.devices.all()
self.fields['device'].queryset = self.event.organizer.devices.all().order_by('device_id')
self.fields['gate'].queryset = self.event.organizer.gates.all()
self.fields['checkin_list'].queryset = self.event.checkin_lists.all()
@@ -2141,11 +2144,11 @@ class CheckinFilterForm(FilterForm):
for i in self.event.items.prefetch_related('variations').all():
variations = list(i.variations.all())
if variations:
choices.append((str(i.pk), _('{product} Any variation').format(product=i.name)))
choices.append((str(i.pk), _('{product} Any variation').format(product=str(i))))
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (i.name, v.value)))
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value)))
else:
choices.append((str(i.pk), i.name))
choices.append((str(i.pk), str(i)))
self.fields['itemvar'].choices = choices
def filter_qs(self, qs):

View File

@@ -32,6 +32,7 @@
# 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 copy
import os
from decimal import Decimal
from urllib.parse import urlencode
@@ -423,9 +424,10 @@ class ItemCreateForm(I18nModelForm):
if self.cleaned_data.get('has_variations'):
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
for variation in self.cleaned_data['copy_from'].variations.all():
ItemVariation.objects.create(item=instance, value=variation.value, active=variation.active,
position=variation.position, default_price=variation.default_price,
description=variation.description, original_price=variation.original_price)
v = copy.copy(variation)
v.pk = None
v.item = instance
v.save()
else:
ItemVariation.objects.create(
item=instance, value=__('Standard')
@@ -434,6 +436,9 @@ class ItemCreateForm(I18nModelForm):
if self.cleaned_data.get('copy_from'):
for question in self.cleaned_data['copy_from'].questions.all():
question.items.add(instance)
question.log_action('pretix.event.question.changed', user=self.user, data={
'item_added': self.instance.pk
})
for a in self.cleaned_data['copy_from'].addons.all():
instance.addons.create(addon_category=a.addon_category, min_count=a.min_count, max_count=a.max_count,
price_included=a.price_included, position=a.position,
@@ -563,7 +568,7 @@ class ItemUpdateForm(I18nModelForm):
if d['tax_rule'] and d['tax_rule'].rate > 0:
self.add_error(
'tax_rule',
_("Gift card products should not be associated with non-zero tax rates since sales tax will be applied when the gift card is redeemed.")
_("Gift card products should use a tax rule with a rate of 0 percent since sales tax will be applied when the gift card is redeemed.")
)
if d['admission']:
self.add_error(

View File

@@ -167,6 +167,12 @@ class CancelForm(ForceQuotaConfirmationForm):
initial=True,
required=False
)
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,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -844,7 +850,7 @@ class EventCancelForm(forms.Form):
label=_("Subject"),
required=True,
widget_kwargs={'attrs': {'data-display-dependency': '#id_send'}},
initial=_('Canceled: {event}'),
initial=LazyI18nString.from_gettext(gettext_noop('Canceled: {event}')),
widget=I18nTextInput,
locales=self.event.settings.get('locales'),
)
@@ -870,7 +876,7 @@ class EventCancelForm(forms.Form):
self.fields['send_waitinglist_subject'] = I18nFormField(
label=_("Subject"),
required=True,
initial=_('Canceled: {event}'),
initial=LazyI18nString.from_gettext(gettext_noop('Canceled: {event}')),
widget=I18nTextInput,
widget_kwargs={'attrs': {'data-display-dependency': '#id_send_waitinglist'}},
locales=self.event.settings.get('locales'),

View File

@@ -39,11 +39,13 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.forms import inlineformset_factory
from django.forms.utils import ErrorDict
from django.utils.crypto import get_random_string
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea
from i18nfield.forms import I18nFormField, I18nFormSetMixin, I18nTextarea
from phonenumber_field.formfields import PhoneNumberField
from pytz import common_timezones
@@ -59,6 +61,7 @@ from pretix.base.models import (
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
MembershipType, Organizer, Team,
)
from pretix.base.models.organizer import OrganizerFooterLink
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
from pretix.control.forms import ExtFileField, SplitDateTimeField
from pretix.control.forms.event import (
@@ -268,6 +271,69 @@ class DeviceForm(forms.ModelForm):
}
class DeviceBulkEditForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
organizer = kwargs.pop('organizer')
self.mixed_values = kwargs.pop('mixed_values')
self.queryset = kwargs.pop('queryset')
super().__init__(*args, **kwargs)
self.fields['limit_events'].queryset = organizer.events.all().order_by(
'-has_subevents', '-date_from'
)
self.fields['gate'].queryset = organizer.gates.all()
def clean(self):
d = super().clean()
if self.prefix + '__events' in self.data.getlist('_bulk') and not d['all_events'] and not d['limit_events']:
raise ValidationError(_('Your device will not have access to anything, please select some events.'))
return d
class Meta:
model = Device
fields = ['all_events', 'limit_events', 'security_profile', 'gate']
widgets = {
'limit_events': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '#id_all_events',
'class': 'scrolling-multiple-choice scrolling-multiple-choice-large',
}),
}
field_classes = {
'limit_events': SafeEventMultipleChoiceField
}
def save(self, commit=True):
objs = list(self.queryset)
fields = set()
check_map = {
'all_events': '__events',
'limit_events': '__events',
}
for k in self.fields:
cb_val = self.prefix + check_map.get(k, k)
if cb_val not in self.data.getlist('_bulk'):
continue
fields.add(k)
for obj in objs:
if k == 'limit_events':
getattr(obj, k).set(self.cleaned_data[k])
else:
setattr(obj, k, self.cleaned_data[k])
if fields:
Device.objects.bulk_update(objs, [f for f in fields if f != 'limit_events'], 200)
def full_clean(self):
if len(self.data) == 0:
# form wasn't submitted
self._errors = ErrorDict()
return
super().full_clean()
class OrganizerSettingsForm(SettingsForm):
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
@@ -543,7 +609,7 @@ class CustomerUpdateForm(forms.ModelForm):
class Meta:
model = Customer
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'phone', 'locale']
fields = ['is_active', 'external_identifier', 'name_parts', 'email', 'is_verified', 'phone', 'locale', 'notes']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -587,7 +653,7 @@ class CustomerCreateForm(CustomerUpdateForm):
class Meta:
model = Customer
fields = ['identifier', 'is_active', 'name_parts', 'email', 'is_verified', 'locale']
fields = ['is_active', 'identifier', 'external_identifier', 'name_parts', 'email', 'is_verified', 'phone', 'locale', 'notes']
class MembershipUpdateForm(forms.ModelForm):
@@ -618,3 +684,25 @@ class MembershipUpdateForm(forms.ModelForm):
titles=self.instance.customer.organizer.settings.name_scheme_titles,
label=_('Attendee name'),
)
class OrganizerFooterLinkForm(I18nModelForm):
class Meta:
model = OrganizerFooterLink
fields = ('label', 'url')
class BaseOrganizerFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet):
def __init__(self, *args, **kwargs):
organizer = kwargs.pop('organizer', None)
if organizer:
kwargs['locales'] = organizer.settings.get('locales')
super().__init__(*args, **kwargs)
OrganizerFooterLinkFormset = inlineformset_factory(
Organizer, OrganizerFooterLink,
OrganizerFooterLinkForm,
formset=BaseOrganizerFooterLinkFormSet,
can_order=False, can_delete=True, extra=0
)

View File

@@ -314,6 +314,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.object.cloned': _('This object has been created by cloning.'),
'pretix.organizer.changed': _('The organizer has been changed.'),
'pretix.organizer.settings': _('The organizer settings have been changed.'),
'pretix.organizer.footerlinks.changed': _('The footer links have been changed.'),
'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.webhook.created': _('The webhook has been created.'),
@@ -341,7 +342,6 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.paid': _('The order has been marked as paid.'),
'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'),
'pretix.event.order.refunded': _('The order has been refunded.'),
'pretix.event.order.canceled': _('The order has been canceled.'),
'pretix.event.order.reactivated': _('The order has been reactivated.'),
'pretix.event.order.deleted': _('The test mode order {code} has been deleted.'),
'pretix.event.order.placed': _('The order has been created.'),
@@ -450,6 +450,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.question.added': _('The question has been added.'),
'pretix.event.question.deleted': _('The question has been deleted.'),
'pretix.event.question.changed': _('The question has been changed.'),
'pretix.event.discount.added': _('The discount has been added.'),
'pretix.event.discount.deleted': _('The discount has been deleted.'),
'pretix.event.discount.changed': _('The discount has been changed.'),
'pretix.event.taxrule.added': _('The tax rule has been added.'),
'pretix.event.taxrule.deleted': _('The tax rule has been deleted.'),
'pretix.event.taxrule.changed': _('The tax rule has been changed.'),
@@ -466,6 +469,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.testmode.deactivated': _('The test mode has been disabled.'),
'pretix.event.added': _('The event has been created.'),
'pretix.event.changed': _('The event details have been changed.'),
'pretix.event.footerlinks.changed': _('The footer links have been changed.'),
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
'pretix.event.question.option.changed': _('An answer option has been changed.'),
@@ -532,6 +536,27 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
bleach.clean(logentry.parsed_data.get('msg'), tags=[], strip=True)
)
if logentry.action_type == 'pretix.event.order.canceled':
comment = logentry.parsed_data.get('comment')
if comment:
return _('The order has been canceled (comment: "{comment}").').format(comment=comment)
else:
return _('The order has been canceled.')
if logentry.action_type in ('pretix.control.views.checkin.reverted', 'pretix.event.checkin.reverted'):
if 'list' in data:
try:
checkin_list = sender.checkin_lists.get(pk=data.get('list')).name
except CheckinList.DoesNotExist:
checkin_list = _("(unknown)")
else:
checkin_list = _("(unknown)")
return _('The check-in of position #{posid} on list "{list}" has been reverted.').format(
posid=data.get('positionid'),
list=checkin_list,
)
if sender and logentry.action_type.startswith('pretix.event.checkin'):
return _display_checkin(sender, logentry)
@@ -560,20 +585,6 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
list=checkin_list
)
if logentry.action_type in ('pretix.control.views.checkin.reverted', 'pretix.event.checkin.reverted'):
if 'list' in data:
try:
checkin_list = sender.checkin_lists.get(pk=data.get('list')).name
except CheckinList.DoesNotExist:
checkin_list = _("(unknown)")
else:
checkin_list = _("(unknown)")
return _('The check-in of position #{posid} on list "{list}" has been reverted.').format(
posid=data.get('positionid'),
list=checkin_list,
)
if logentry.action_type == 'pretix.team.member.added':
return _('{user} has been added to the team.').format(user=data.get('email'))

View File

@@ -186,6 +186,14 @@ def get_event_navigation(request: HttpRequest):
}),
'active': 'event.items.questions' in url.url_name,
},
{
'label': _('Discounts'),
'url': reverse('control:event.items.discounts', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.items.discounts' in url.url_name,
},
]
})

View File

@@ -57,7 +57,11 @@
{% endif %}
{% elif payment_info.payment_type == "izettle" %}
<dt>{% trans "Payment provider" %}</dt>
<dd>iZettle</dd>
<dd>Zettle</dd>
{% if payment_info.payment_data.reference %}
<dt>{% trans "Payment reference" %}</dt>
<dd>{{ payment_info.payment_data.reference }}</dd>
{% endif %}
<dt>{% trans "Payment Application" %}</dt>
<dd>{{ payment_info.payment_data.applicationName }}</dd>
<dt>{% trans "Card Entry Mode" %}</dt>
@@ -68,5 +72,31 @@
</dd>
<dt>{% trans "Authorization Code" %}</dt>
<dd>{{ payment_info.payment_data.authorizationCode }}</dd>
{% elif payment_info.payment_type == "izettle_qrc" %}
<dt>{% trans "Payment provider" %}</dt>
<dd>PayPal QRC via Zettle</dd>
<dt>{% trans "Payment reference" %}</dt>
<dd>{{ payment_info.payment_data.reference }}</dd>
<dt>{% trans "Transaction ID" %}</dt>
<dd>{{ payment_info.payment_data.transactionId }}</dd>
{% elif payment_info.payment_type == "adyen_legacy" %}
<dt>{% trans "Payment provider" %}</dt>
<dd>Adyen POS</dd>
<dt>{% trans "Reference" %}</dt>
<dd>{{ payment_info.payment_data.pspReference }}</dd>
<dt>{% trans "Terminal ID" %}</dt>
<dd>{{ payment_info.payment_data.terminalId }}</dd>
<dt>{% trans "Payment method" %}</dt>
<dd>{{ payment_info.payment_data.paymentMethod }} ({{ payment_info.payment_data.cardType }} / {{ payment_info.payment_data.cardScheme }} / {{ payment_info.payment_data.paymentMethodVariant }})</dd>
<dt>{% trans "Card holder" %}</dt>
<dd>{{ payment_info.payment_data.cardHolderName }}</dd>
<dt>{% trans "Card number" %}</dt>
<dd>{{ payment_info.payment_data.cardBin }} **** {{ payment_info.payment_data.cardSummary }}</dd>
<dt>{% trans "Card expiration" %}</dt>
<dd>{{ payment_info.payment_data.expiryMonth }} / {{ payment_info.payment_data.expiryYear }}</dd>
<dt>{% trans "Card Entry Mode" %}</dt>
<dd>{{ payment_info.payment_data.posEntryMode }}</dd>
<dt>{% trans "Result Code" %}</dt>
<dd>{{ payment_info.payment_data.posResultCode }}</dd>
{% endif %}
</dl>

View File

@@ -80,13 +80,17 @@
{% elif c.forced and c.successful %}
<span class="fa fa-fw fa-warning" data-toggle="tooltip"
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}"></span>
{% elif c.forced and not c.successful %}
<br>
<small class="text-muted">{% trans "Failed in offline mode" %}</small>
{% elif c.force_sent %}
<span class="fa fa-fw fa-cloud-upload" data-toggle="tooltip"
title="{% blocktrans trimmed with date=c.created|date:'SHORT_DATETIME_FORMAT' %}Offline scan. Upload time: {{ date }}{% endblocktrans %}"></span>
{% elif c.auto_checked_in %}
<span class="fa fa-fw fa-magic" data-toggle="tooltip"
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}"></span>
{% endif %}
{% if c.forced and not c.successful %}
<br>
<small class="text-muted">{% trans "Failed in offline mode" %}</small>
{% endif %}
</td>
<td>
{% if c.type == "exit" %}<span class="fa fa-fw fa-sign-out"></span>{% endif %}

View File

@@ -71,13 +71,20 @@
</p>
</div>
{% else %}
<form method="post" action="">
<form method="post" action="{% url "control:event.orders.checkinlists.bulk_action" event=request.event.slug organizer=request.event.organizer.slug list=checkinlist.pk %}" data-asynctask>
<div class="hidden">
{{ filter_form.as_p }}
<input name="returnquery" type="hidden" value="{{ request.META.QUERY_STRING }}">
</div>
{% csrf_token %}
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th></th>
<th>
<label aria-label="{% trans "select all rows for batch-operation" %}"
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
</th>
<th>{% trans "Order code" %} <a href="?{% url_replace request 'ordering' '-code'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Item" %} <a href="?{% url_replace request 'ordering' '-item'%}"><i class="fa fa-caret-down"></i></a>
@@ -100,6 +107,19 @@
<th>{% trans "Timestamp" %} <a href="?{% url_replace request 'ordering' '-timestamp'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'timestamp'%}"><i class="fa fa-caret-up"></i></a></th>
</tr>
{% if page_obj.paginator.num_pages > 1 %}
<tr class="table-select-all warning hidden">
<td>
<input type="checkbox" name="__ALL" id="__all"
data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="8">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for e in entries %}
@@ -180,13 +200,16 @@
</div>
{% if "can_change_orders" in request.eventpermset %}
<button type="submit" class="btn btn-primary btn-save">
<span class="fa fa-sign-in" aria-hidden="true"></span>
{% trans "Check-In selected attendees" %}
</button>
<button type="submit" class="btn btn-default btn-save" name="checkout" value="true">
<span class="fa fa-sign-out" aria-hidden="true"></span>
{% trans "Check-Out selected attendees" %}
</button>
<button type="submit" class="btn btn-default btn-save" name="revert" value="true">
{% trans "Revert selected check-ins" %}
<button type="submit" class="btn btn-danger btn-save" name="revert" value="true">
<span class="fa fa-trash" aria-hidden="true"></span>
{% trans "Delete all check-ins of selected attendees" %}
</button>
{% endif %}
</form>

View File

@@ -93,6 +93,17 @@
</div>
</div>
<div class="alert alert-info" v-if="missingItems.length">
<p>
{% trans "Your rule always filters by product or variation, but the following products or variations are not contained in any of your rule parts so people with these tickets will not get in:" %}
</p>
<ul>
<li v-for="h in missingItems">{{ "{" }}{h}{{ "}" }}</li>
</ul>
<p>
{% trans "Please double-check if this was intentional." %}
</p>
</div>
</div>
<div class="disabled-withoutjs sr-only">
{{ form.rules }}
@@ -105,6 +116,7 @@
</button>
</div>
</form>
{{ items|json_script:"items" }}
{% compress js %}
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
@@ -118,6 +130,7 @@
<script type="text/javascript" src="{% static "d3/d3-transition.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/datetimefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/timefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/lookup-select2.vue' %}"></script>

View File

@@ -69,7 +69,8 @@
</label>
<div class="col-md-9">
<div class="checkbox">
<label><input type="checkbox" checked="checked" disabled="disabled"> {% trans "Ask and require input" %}</label>
<label><input type="checkbox" checked="checked"
disabled="disabled"> {% trans "Ask and require input" %}</label>
</div>
</div>
</div>
@@ -81,7 +82,8 @@
</label>
<div class="col-md-9 static-form-row">
<p>
<a href="{% url "control:event.settings.invoice" event=request.event.slug organizer=request.organizer.slug %}#tab-0-1-open" target="_blank">
<a href="{% url "control:event.settings.invoice" event=request.event.slug organizer=request.organizer.slug %}#tab-0-1-open"
target="_blank">
{% trans "See invoice settings" %}
</a>
</p>
@@ -101,7 +103,8 @@
</label>
<div class="col-md-9 static-form-row">
<p>
<a href="{% url "control:event.items.questions" event=request.event.slug organizer=request.organizer.slug %}" target="_blank">
<a href="{% url "control:event.items.questions" event=request.event.slug organizer=request.organizer.slug %}"
target="_blank">
{% trans "Manage questions" %}
</a>
</p>
@@ -221,6 +224,7 @@
{% bootstrap_field sform.show_items_outside_presale_period layout="control" %}
{% bootstrap_field sform.last_order_modification_date layout="control" %}
{% bootstrap_field sform.allow_modifications_after_checkin layout="control" %}
{% bootstrap_field sform.show_checkin_number_user layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Display" %}</legend>
@@ -231,10 +235,74 @@
{% bootstrap_field sform.display_net_prices layout="control" %}
{% bootstrap_field sform.show_variations_expanded layout="control" %}
{% bootstrap_field sform.hide_sold_out layout="control" %}
{% url "control:organizer.edit" organizer=request.organizer.slug as org_url %}
{% propagated request.event org_url "meta_noindex" %}
{% bootstrap_field sform.meta_noindex layout="control" %}
{% endpropagated %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Footer links" %}<br>
<span class="optional">{% trans "Optional" %}</span>
</label>
<div class="col-md-9">
<p class="help-block">
{% blocktrans trimmed %}
These links will be shown in the footer of your ticket shop. You could
for example link your terms of service here. Your contact address, imprint, and privacy
policy will be linked automatically (if you configured them), so you do not need to add
them here.
{% endblocktrans %}
</p>
<div class="formset" data-formset data-formset-prefix="{{ footer_links_formset.prefix }}">
{{ footer_links_formset.management_form }}
{% bootstrap_formset_errors footer_links_formset %}
<div data-formset-body>
{% for form in footer_links_formset %}
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-5">
{% bootstrap_form_errors form %}
{% bootstrap_field form.label layout='inline' form_group_class="" %}
</div>
<div class="col-md-5">
{% bootstrap_field form.url layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{{ footer_links_formset.empty_form.id }}
{% bootstrap_field footer_links_formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-5">
{% bootstrap_field footer_links_formset.empty_form.label layout='inline' form_group_class="" %}
</div>
<div class="col-md-5">
{% bootstrap_field footer_links_formset.empty_form.url layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add link" %}</button>
</p>
</div>
</div>
</div>
{% if sform.frontpage_subevent_ordering %}
{% bootstrap_field sform.frontpage_subevent_ordering layout="control" %}
{% endif %}
@@ -244,6 +312,11 @@
{% if sform.event_list_available_only %}
{% bootstrap_field sform.event_list_available_only layout="control" %}
{% endif %}
{% url "control:organizer.edit" organizer=request.organizer.slug as org_url %}
{% propagated request.event org_url "meta_noindex" %}
{% bootstrap_field sform.meta_noindex layout="control" %}
{% endpropagated %}
</fieldset>
<fieldset>
<legend>{% trans "Cart" %}</legend>
@@ -261,13 +334,15 @@
</div>
<div class="alert alert-info">
{% blocktrans trimmed %}
The waiting list determines availability mainly based on quotas. If you use a seating plan and your
The waiting list determines availability mainly based on quotas. If you use a seating plan and
your
number of available seats is less than the available quota, you might run into situations where
people are sent an email from the waiting list but still are unable to book a seat.
{% endblocktrans %}
<strong>
{% blocktrans trimmed %}
Specifically, this means the waiting list is not safe to use together with the minimum distance
Specifically, this means the waiting list is not safe to use together with the minimum
distance
feature of our seating plan module.
{% endblocktrans %}
</strong>

View File

@@ -20,6 +20,11 @@
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Product type" %}</label>
<div class="col-md-9">
{% for e in form.errors.admission %}
<div class="alert alert-danger has-error">
{{ e }}
</div>
{% endfor %}
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}>

View File

@@ -0,0 +1,74 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Automatic discount" %}{% endblock %}
{% block inside %}
<h1>{% trans "Automatic discount" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
<div class="row">
<div class="col-xs-12{% if discount %} col-lg-10{% endif %}">
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.active layout="control" %}
{% bootstrap_field form.internal_name layout="control" %}
{% bootstrap_field form.available_from layout="control" %}
{% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Condition" context "discount" %}</legend>
{% bootstrap_field form.condition_all_products layout="control" %}
{% bootstrap_field form.condition_limit_products layout="control" %}
{% bootstrap_field form.condition_apply_to_addons layout="control" %}
{% bootstrap_field form.condition_ignore_voucher_discounted layout="control" %}
{% if form.subevent_mode %}
{% bootstrap_field form.subevent_mode layout="control" %}
{% endif %}
<div class="form-group form-alternatives">
<label class="col-md-3 control-label">
{% trans "Minimum cart content" %}<br>
<span class="optional">{% trans "Optional" %}</span>
</label>
<div class="col-md-4">
{% bootstrap_field form.condition_min_count form_group_class="" %}
</div>
<div class="col-md-1 text-center condition-or" data-display-dependency="#id_subevent_mode_2" data-inverse>
<div class="hr">
<div class="sep">
<div class="sepText">{% trans "OR" %}</div>
</div>
</div>
</div>
<div class="col-md-4" data-display-dependency="#id_subevent_mode_2" data-inverse>
{% bootstrap_field form.condition_min_value form_group_class="" %}
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Benefit" context "discount" %}</legend>
{% bootstrap_field form.benefit_discount_matching_percent layout="control" addon_after="%" %}
{% bootstrap_field form.benefit_only_apply_to_cheapest_n_matches layout="control" %}
</fieldset>
</div>
{% if discount %}
<div class="col-xs-12 col-lg-2">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Discount history" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=discount %}
</div>
</div>
{% endif %}
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete discount" %}{% endblock %}
{% block inside %}
<h1>{% trans "Delete discount" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if not possible and not item.active %}
<p>{% blocktrans %}You cannot delete the discount <strong>{{ discount }}</strong> because it already has
been used as part of an order.{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "control:event.discounts" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<div class="clearfix"></div>
</div>
{% else %}
{% if possible %}
<p>{% blocktrans trimmed with name=discount.internal_name %}
Are you sure you want to delete the discount <strong>{{ name }}</strong>?
{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed with name=discount.internal_name %}
You cannot delete the discount <strong>{{ name }}</strong> because it already has been used as part
of an order, but you can deactivate it.
{% endblocktrans %}</p>
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:event.items.discounts" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% if possible %}{% trans "Delete" %}{% else %}{% trans "Deactivate" %}{% endif %}
</button>
</div>
{% endif %}
</form>
{% endblock %}

View File

@@ -0,0 +1,147 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% block title %}{% trans "Automatic discounts" %}{% endblock %}
{% block inside %}
<h1>{% trans "Automatic discounts" %}</h1>
<p>
{% blocktrans trimmed %}
With automatic discounts, you can automatically apply a discount to purchases from your customers based
on certain conditions. For example, you can create group discounts like "get 20% off if you buy 3 or more
tickets" or "buy 2 tickets, get 1 free".
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
Automatic discounts are available to all customers as long as they are active. If you want to offer special
prices only to specific customers, you can use vouchers instead. If you want to offer discounts across
multiple purchases ("buy a package of 10 you can turn into individual tickets later"), you can use
customer accounts and memberships instead.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
Discounts are only automatically applied during an initial purchase. They are not applied if an existing
order is changed through any of the available options.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
Every product in the cart can only be affected by one discount. If you have overlapping discounts, the
first one in the order of the list below will apply.
{% endblocktrans %}
</p>
{% if discounts|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any discounts yet.
{% endblocktrans %}
</p>
<a href="{% url "control:event.items.discounts.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 discount" %}</a>
</div>
{% else %}
<p>
<a href="{% url "control:event.items.discounts.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new discount" %}
</a>
</p>
<form method="post">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Internal name" %}</th>
<th></th>
<th></th>
<th>{% trans "Products" %}</th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody data-dnd-url="{% url "control:event.items.discounts.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
{% for d in discounts %}
<tr data-dnd-id="{{ d.id }}">
<td>
{% if d.active %}
<strong>
{% else %}
<del>
{% endif %}
<a href="{% url "control:event.items.discounts.edit" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}">
{{ d.internal_name }}</a>
{% if d.active %}
</strong>
{% else %}
</del>
{% endif %}
</td>
<td>
{% for k, c in sales_channels.items %}
{% if k in d.sales_channels %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted"
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
{% else %}
{% endif %}
{% endfor %}
</td>
<td>
{% if d.available_from or d.available_until %}
{% if not d.is_available_by_time %}
<span class="label label-danger" data-toggle="tooltip"
title="{% trans "Currently unavailable since a limited timeframe for this product has been set" %}">
<span class="fa fa-clock-o fa-fw" data-toggle="tooltip">
</span>
</span>
{% else %}
<span class="fa fa-clock-o fa-fw text-muted" data-toggle="tooltip"
title="{% trans "Only available in a limited timeframe" %}">
</span>
{% endif %}
{% endif %}
</td>
<td>
{% if d.condition_all_products %}
<em>{% trans "All" %}</em>
{% else %}
<ul>
{% for item in d.condition_limit_products.all %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</td>
<td>
<button formaction="{% url "control:event.items.discounts.up" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm sortable-up"
{% if forloop.counter0 == 0 and not page_obj.has_previous %}
disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:event.items.discounts.down" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm sortable-down"
{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}>
<i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span>
</td>
<td class="text-right flip">
<a href="{% url "control:event.items.discounts.edit" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.discounts.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ d.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
<a href="{% url "control:event.items.discounts.delete" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}

View File

@@ -1,6 +1,7 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% block title %}
{% trans "Cancel order" %}
{% endblock %}
@@ -22,13 +23,22 @@
{% csrf_token %}
<input type="hidden" name="status" value="c"/>
{% bootstrap_form_errors form %}
{% if form.cancellation_fee %}
{% if fee %}
{% with fee|money:request.event.currency as f %}
<p>{% blocktrans trimmed with fee="<strong>"|add:f|add:"</strong>"|safe %}
The configured cancellation fee for a self-service cancellation would be {{ fee }} for this
order, but for a cancellation performed by you, you need to set the cancellation fee here:
{% endblocktrans %}</p>
{% endwith %}
{% endif %}
{% bootstrap_field form.cancellation_fee layout='' %}
{% endif %}
{% bootstrap_field form.send_email layout='' %}
{% bootstrap_field form.comment layout='' %}
{% if form.cancel_invoice %}
{% bootstrap_field form.cancel_invoice layout='' %}
{% endif %}
{% if form.cancellation_fee %}
{% bootstrap_field form.cancellation_fee layout='' %}
{% endif %}
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"

View File

@@ -387,7 +387,7 @@
{% if line.voucher %}
<br/><span class="fa fa-tags fa-fw"></span> {% trans "Voucher code used:" %}
<a
{% if line.price_before_voucher|default_if_none:"NONE" != "NONE" %}data-toggle="tooltip" title="{% blocktrans trimmed with price=line.price_before_voucher|money:request.event.currency %}Original price: {{ price }}{% endblocktrans %}"{% endif %}
{% if line.voucher.budget and line.voucher_budget_use|default_if_none:"NONE" != "NONE" %}data-toggle="tooltip" title="{% blocktrans trimmed with amount=line.voucher_budget_use|money:request.event.currency %}Used {{ amount }} discount from budget{% endblocktrans %}"{% endif %}
href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">
{{ line.voucher.code }}
</a>
@@ -406,6 +406,15 @@
{{ line.used_membership }}
</a>
{% endif %}
{% if line.discount %}
<br />
<span class="text-success discounted" data-toggle="tooltip" title="{% trans "The price of this product was reduced because of an automatic discount or this product was part of the discount calculation for a different product in this order." %}">
<span class="fa fa-percent fa-fw" aria-hidden="true"></span>
<a href="{% url "control:event.items.discounts.edit" organizer=request.event.organizer.slug event=request.event.slug discount=line.discount.id %}">
{{ line.discount.internal_name }}
</a>
</span>
{% endif %}
{% if not line.canceled %}
<div class="position-buttons">
{% if line.generate_ticket %}

View File

@@ -153,7 +153,7 @@
{% endif %}
</td>
<td>
<span class="fa fa-{{ o.sales_channel_obj.icon }} text-muted"
<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>

View File

@@ -27,6 +27,10 @@
<dl class="dl-horizontal">
<dt>{% trans "Customer ID" %}</dt>
<dd>#{{ customer.identifier }}</dd>
{% if customer.external_identifier %}
<dt>{% trans "External identifier" %}</dt>
<dd>{{ customer.external_identifier }}</dd>
{% endif %}
<dt>{% trans "Status" %}</dt>
<dd>
{% if not customer.is_active %}
@@ -59,6 +63,10 @@
<dt>{% trans "Last login" %}</dt>
<dd>{% if customer.last_login %}{{ customer.last_login|date:"SHORT_DATETIME_FORMAT" }}{% else %}
{% endif %}</dd>
{% if customer.notes %}
<dt>{% trans "Notes" %}</dt>
<dd>{{ customer.notes|linebreaks }}</dd>
{% endif %}
</dl>
</form>
<div class="text-right">

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