Compare commits

...
Author SHA1 Message Date
Mira Weller 34bffe7406 new datafield attribute, add new /datafields/.../.../options/ endpoints, allow lookup by datafield identifier, make CompatQuestionViewSet readonly 2026-10-30 14:47:24 +01:00
Mira Weller 308e715c6f bugfixes 2026-10-30 14:24:21 +01:00
Mira Weller 1a1c08f4ce change datafields API endpoint URLs 2026-10-30 12:48:31 +01:00
Mira Weller 2c88efceb1 QuestionnaireChild -> QuestionnaireRow 2026-10-30 12:39:16 +01:00
Mira Weller c8964fad9d _presale -> _purchase 2026-10-30 11:47:47 +01:00
Mira Weller 7c30b97799 Use unabreviated strings for QuestionnaireType 2026-10-29 18:47:33 +01:00
Mira Weller 6b6dc1199b API change: questionnaire.children.datafield is now always a string, either the user datafield identifier ('internal reference'), or the system datafield key
System datafield keys now always start with an @ (e.g. "@attendee_email"), to distinguish from user datafields

Renamed SystemQuestion enum to SystemDatafield

Improve details in editor UI
2026-10-29 15:46:19 +01:00
Mira Weller fdd20606df Better help text 2026-10-28 10:23:54 +01:00
Mira Weller f00e2b1175 Fix question_edit.html 2026-10-27 17:52:44 +01:00
Mira Weller b14cbcbdd0 Fix questionnaires list/retrieve API 2026-10-27 17:52:34 +01:00
Mira Weller 45f0bfab74 Change wording 2026-10-27 16:09:50 +01:00
Mira Weller 3c76a5bb5f Fix item forms 2026-10-27 16:09:50 +01:00
Mira Weller 188d4b81e5 Adapt datasync-sourcefields 2026-10-27 16:09:49 +01:00
Mira Weller a3ebcbd25e API: ItemSerializer: personalized field deprecated
Personalization is now configured by attaching a questionnaire asking for the relevant information to the product. For example, you can use the "Attendee data" questionnaire that is created by default on new events.

For compatibility with existing applications:

    setting the personalized field when creating a new product will attach the questionnaire with internal_name == "Attendee data" to the new product.
    when reading a product, personalized is true if any attached questionnaire asks for attendee_name and/or attendee_email.
    when updating a product, the personalized field cannot be changed. (TBD: ignore or raise error?)
2026-10-27 16:09:49 +01:00
Mira Weller 70278c3ceb Label for hidden questionnaires, icons 2026-10-01 18:57:01 +02:00
Mira Weller f27b192796 Make JSON output more similar to old version 2026-10-01 18:56:42 +02:00
Mira Weller 365419d441 fix migration 2026-10-01 18:56:04 +02:00
Mira Weller f8f3c4a810 wip QuestionFormSerializer 2026-10-01 17:42:17 +02:00
Mira Weller 045f238373 Add helper properties 2026-10-01 17:41:27 +02:00
Mira Weller a9da6994d9 Move exception to separate file 2026-10-01 17:22:33 +02:00
Mira Weller 085272a866 New CompatDatafieldViewSet at /questions/
Returns a list of Datafield/Question in identical form as old API, but only as long as the configuration can be converted losslessly to old data format.
If that's not possible, returns 410 gone (except if query param ignore_errors=true is set)

Special case for apps:
If ?sales_channel=<identifier> is specified (or the request is authenticated with a Device token, in which case a sales_channel is guessed from the device brand), it defers to POSCompatQuestionViewSet (read-only, formerly CompatQuestionViewSet).
2026-10-01 17:04:36 +02:00
Mira Weller 3f7cd16045 Split datafields in datafields and orderdatafields; rename QC datafield ref from 'question' to 'datafield' 2026-10-01 16:07:29 +02:00
Mira Weller a887d9cbad rebase 2026-09-30 21:04:55 +02:00
Mira Weller 8b52351eff isort 2026-09-30 21:03:28 +02:00
Mira Weller 37cf0f39f5 Fix typo 2026-09-30 21:03:28 +02:00
Mira Weller 6ed34abb99 pretixSCAN compat 2026-09-30 21:03:28 +02:00
Mira Weller 38838c0309 refactor: move some helpers to better files 2026-09-30 21:03:28 +02:00
Mira Weller 8764ad5059 Allow same datafields on product if different sales channel selected 2026-09-30 21:03:28 +02:00
Mira Weller 7bc2eb04cd Filter question by sales channel, default to pretixpos for POS devices 2026-09-30 21:03:28 +02:00
Mira Weller 3e087a4c9c Improve UI for changing Questionnaire sales_channels 2026-09-30 21:03:28 +02:00
Mira Weller 449fd0ac35 Fix order detail page 2026-09-30 21:03:28 +02:00
Mira Weller 3294e8c8f3 UX 2026-09-30 21:03:28 +02:00
Mira Weller 2f638a51b7 pretixPOS API compat 2026-09-30 21:03:28 +02:00
Mira Weller 0d99672197 Translate questionnaire internal_names in migration (todo: test) 2026-09-30 21:03:27 +02:00
Mira Weller 49ed8419bd MixedAnswerListSerializer - wip 2026-09-30 21:03:27 +02:00
Mira Weller ae62a45a6a Add 'state' to SystemQuestion enum 2026-09-30 21:03:27 +02:00
Mira Weller 67db5f6d7f CompatQuestionViewset / Serializer 2026-09-30 21:03:27 +02:00
Mira Weller d3434cc719 ItemCreate/ItemUpdate: replace personalized with questionnaires selection 2026-09-30 21:03:27 +02:00
Mira Weller 83b19efcde Fix checkboxes 2026-09-30 21:03:27 +02:00
Mira Weller 3a97ad20d8 Improve validation 2026-09-30 21:03:27 +02:00
Mira Weller 703aeee776 Fix access to old fields 2026-09-30 21:03:27 +02:00
Mira Weller 485ec5f262 New load indicator, centralize environment data, extract QuestionnaireDetailForm 2026-09-30 21:03:27 +02:00
Mira Weller af02c885e0 Return object_str in notify_parent 2026-09-30 21:03:27 +02:00
Mira Weller 3cd7063986 Add clear button to scrolling-multiple-choice search box 2026-09-30 21:03:27 +02:00
Mira Weller 3d586ca1fd Improve dialog event handling 2026-09-30 21:03:27 +02:00
Mira Weller 071942e095 Move content-placeholder handling out of form_handlers 2026-09-30 21:03:27 +02:00
Mira Weller 2fe077d958 Prevent running form_handlers multiple times on the same elements 2026-09-30 21:03:27 +02:00
Mira Weller 1ea3aff7e6 experimental: use dialog for adding category 2026-09-30 21:03:27 +02:00
Mira Weller d6091a0756 wip2 2026-09-30 21:03:27 +02:00
Mira Weller 14a3056bad add DjangoDialog / notify_parent - allow to load existing views in iframe
...and some more changes
2026-09-30 21:03:27 +02:00
Mira Weller 49ffd49bd9 Remove print() 2026-09-30 21:03:27 +02:00
Mira Weller 9e8fc20c26 Fix checkout 2026-09-30 21:03:27 +02:00
Mira Weller 87f6a3e156 Add validations in QuestionnaireSerializer 2026-09-30 21:03:27 +02:00
Mira Weller d69d48975b Remove old code from DatafieldSerializer 2026-09-30 21:03:27 +02:00
Mira Weller 272fa99ea2 Fix setting question to null in QuestionnaireSerializer.set_children 2026-09-30 21:03:27 +02:00
Mira Weller 4054b6ccde Support order-level questionnaires in OrderLevelQuestionsForm 2026-09-30 21:03:27 +02:00
Mira Weller 830887a75f Add (somewhat hacky) support for text blocks in QuestionsForms 2026-09-30 21:03:27 +02:00
Mira Weller 83d2d4e081 Refactor attendee_name handling in Order.save 2026-09-30 21:03:27 +02:00
Mira Weller b15cd0e60a Fix some system datafields 2026-09-30 21:03:27 +02:00
Mira Weller d484caff1f Hide text blocks in question answer views 2026-09-30 21:03:27 +02:00
Mira Weller 8c7a79b80e Editor UX rework 2026-09-30 21:03:27 +02:00
Mira Weller d5a41b52fa Flip order-level / ticket-level data field lists 2026-09-30 21:03:27 +02:00
Mira Weller 4140ac9486 Fix changing visibility, add sales channel checkbox + default 2026-09-30 21:03:27 +02:00
Mira Weller ae27933498 Fix sorting questions 2026-09-30 21:03:27 +02:00
Mira Weller c9d2371841 Fix sorting questionnaires 2026-09-30 21:03:27 +02:00
Mira Weller d33d9c10ba Create a default questionnaire for new events 2026-09-30 21:03:27 +02:00
Mira Weller 4664a8fa21 Fix checkoutflow 2026-09-30 21:03:27 +02:00
Mira Weller 6d638d49fa Remove old settings 2026-09-30 21:03:27 +02:00
Mira Weller 1ec9094ae2 rename some instances of Question -> Datafield 2026-09-30 21:03:27 +02:00
Mira Weller 59b36604bd progress bar 2026-09-30 21:03:27 +02:00
Mira Weller 701636f0a6 Move App -> Editor 2026-09-30 21:03:27 +02:00
Mira Weller 0b2b9ce286 Pass choice field options to JS 2026-09-30 21:03:27 +02:00
Mira Weller f3e2a964da Remove feature flag from order-level data fields 2026-09-30 21:03:27 +02:00
Mira Weller 6e8073681e fix migration 2026-09-30 21:03:27 +02:00
Mira Weller 44dc4c1929 TODO markings 2026-09-30 21:03:27 +02:00
Mira Weller 9d2e5a1cf3 rebase migration and migrate order-level questions as well 2026-09-30 21:03:27 +02:00
Mira Weller fc79223356 QuestionnaireChild: system_question und user_question in system_datafield und user_datafield umbenennen 2026-09-30 21:03:27 +02:00
Mira Weller b9f3ca807f Rebased --- Squashed commit of the following:
commit 2177bebf6a
Merge: ea613bf271 d08216d8c5
Author: Mira Weller <weller@rami.io>
Date:   Fri Aug 7 21:22:31 2026 +0200

    Merge branch 'master' into questions-vue3

commit ea613bf271
Author: Mira Weller <weller@rami.io>
Date:   Fri Aug 7 21:21:35 2026 +0200

    wip

commit c8bed5e3b9
Author: Mira Weller <weller@rami.io>
Date:   Fri Aug 7 10:15:00 2026 +0200

    rename get_cart to get_cart_positions (keep old name alive)

commit 1ec27fff05
Author: Mira Weller <weller@rami.io>
Date:   Fri Aug 7 10:01:38 2026 +0200

    filter questionnaires by sales channel

commit 0294f205d0
Author: Mira Weller <weller@rami.io>
Date:   Thu Aug 6 20:06:05 2026 +0200

    refactor: extract build_requested_valid_from_field method

commit 3424aacdc3
Author: Mira Weller <weller@rami.io>
Date:   Thu Aug 6 20:05:44 2026 +0200

    rename presale question forms and mixins

commit 4012889076
Author: Mira Weller <weller@rami.io>
Date:   Thu Aug 6 19:42:29 2026 +0200

    rebase migration

commit fe15d71cd2
Merge: 3ea7ea31b6 4d9dfa88fe
Author: Mira Weller <weller@rami.io>
Date:   Thu Aug 6 19:25:44 2026 +0200

    Merge branch 'master' into questions-vue3

    # Conflicts:
    #	src/pretix/base/forms/questions.py
    #	src/pretix/base/middleware.py
    #	src/pretix/base/templates/pretixbase/redirect.html

commit 3ea7ea31b6
Author: Mira Weller <weller@rami.io>
Date:   Fri Jul 24 17:37:46 2026 +0200

    wip

commit 5b34a25fb5
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 16:21:11 2026 +0200

    use questionnaire data for rendering checkout questions step

commit 9467f1d08d
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 15:54:14 2026 +0200

    refactor: extract build_system_question_field method

commit 85344a7690
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 15:50:41 2026 +0200

    refactor: extract build_user_question_field method

commit 3b47f4aaa7
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 15:49:34 2026 +0200

    refactor: cartpos / orderpos

commit 0309b05121
Author: Mira Weller <weller@rami.io>
Date:   Thu Mar 19 13:52:18 2026 +0100

    vite config: allow cors access from pretix.work subdomains

commit ad092ae0d1
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 12:35:33 2026 +0200

    re-add vue-slicksort dep

commit 9f42d679cc
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 12:28:53 2026 +0200

    rebase migration

commit 9eae9c6de7
Merge: 84c51e031e 694b915d89
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 12:24:49 2026 +0200

    Merge branch 'master' into questions-vue3

    # Conflicts:
    #	.github/workflows/tests.yml
    #	.gitignore
    #	Dockerfile
    #	package-lock.json
    #	package.json
    #	src/pretix/base/management/commands/runserver.py
    #	src/pretix/base/middleware.py
    #	src/pretix/base/templatetags/vite.py
    #	src/pretix/control/forms/global_settings.py
    #	src/pretix/control/templates/pretixcontrol/checkin/list_edit.html
    #	src/pretix/control/views/item.py
    #	src/pretix/presale/views/widget.py
    #	src/pretix/settings.py
    #	src/pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
    #	src/pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
    #	src/pretix/static/pretixcontrol/js/ui/checkinrules/django-interop.ts
    #	src/pretix/static/pretixcontrol/js/ui/checkinrules/lookup-select2.vue
    #	src/pretix/static/pretixcontrol/js/ui/checkinrules/timefield.vue
    #	src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue
    #	src/pretix/static/pretixpresale/widget/index.html
    #	src/pretix/static/pretixpresale/widget/src/api.ts
    #	src/pretix/static/pretixpresale/widget/src/button.ts
    #	src/pretix/static/pretixpresale/widget/src/components/PriceBox.vue
    #	src/pretix/static/pretixpresale/widget/src/main.ts
    #	src/pretix/static/pretixpresale/widget/src/sharedStore.ts
    #	src/pretix/static/pretixpresale/widget/src/utils.ts
    #	src/pretix/static/pretixpresale/widget/src/widget.ts
    #	src/tests/e2e/conftest.py
    #	vite.config.ts

commit 84c51e031e
Merge: 902ee9242e 222b453b43
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 12:20:05 2026 +0200

    Merge branch 'master' into questions-vue3

commit 902ee9242e
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 12:19:26 2026 +0200

    css comment

commit b758efd4a9
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 12:19:19 2026 +0200

    question dependencies

commit c68fe00502
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 12:18:00 2026 +0200

    add data field edit link

commit b7da9bb6c1
Author: Mira Weller <weller@rami.io>
Date:   Wed Jun 17 12:16:20 2026 +0200

    handle hidden questions

commit 897120d3da
Author: Mira Weller <weller@rami.io>
Date:   Tue May 12 13:04:08 2026 +0200

    add ts model definitions

commit 09abc5725e
Author: Mira Weller <weller@rami.io>
Date:   Tue May 12 11:30:12 2026 +0200

    reorder and save

commit 9a7d5abb1f
Author: Mira Weller <weller@rami.io>
Date:   Tue May 12 11:29:37 2026 +0200

    fix unhandled exception in taxrules API

commit 3cee5f524b
Author: Mira Weller <weller@rami.io>
Date:   Tue May 5 18:12:21 2026 +0200

    use slicksort for reordering questionnaires and questions

commit 3a1a6b988e
Author: Mira Weller <weller@rami.io>
Date:   Tue Apr 28 19:23:35 2026 +0200

    improve i18n, implement "add text block" dialog

commit bdc720f5a2
Author: Mira Weller <weller@rami.io>
Date:   Fri Mar 27 18:57:22 2026 +0100

    wip

commit 583184af38
Author: Mira Weller <weller@rami.io>
Date:   Thu Mar 19 21:45:32 2026 +0100

    change existing views

commit 63d9f7cea8
Author: Mira Weller <weller@rami.io>
Date:   Thu Mar 19 21:45:32 2026 +0100

    adapt vue app to new data model (wip)

commit c3d6fb1bd6
Author: Mira Weller <weller@rami.io>
Date:   Thu Mar 19 21:44:47 2026 +0100

    add Questionnaire and QuestionnaireChild models and their API; migrate existing data

commit bb43acd257
Author: Mira Weller <weller@rami.io>
Date:   Thu Mar 19 13:52:28 2026 +0100

    vite config: add questionnaires app

commit e0744951e8
Author: Mira Weller <weller@rami.io>
Date:   Thu Mar 19 13:52:18 2026 +0100

    vite config: allow cors access from pretix.work subdomains

commit fef1e356f7
Author: Mira Weller <weller@rami.io>
Date:   Thu Mar 19 13:33:24 2026 +0100

    fix out-of-the-box experience

commit 81775f5d2d
Author: Mira Weller <weller@rami.io>
Date:   Thu Mar 19 13:33:24 2026 +0100

    setup questionnaires vue app, add code from question editor proof of concept

commit 4bbcc398cc
Merge: a924d0a266 58840a5fd6
Author: Mira Weller <weller@rami.io>
Date:   Thu Mar 19 13:13:32 2026 +0100

    Merge remote-tracking branch 'refs/remotes/origin/master' into questions-vue3
2026-09-30 21:03:26 +02:00
Richard Schreiber 3dd8cc5862 PDF Editor: select newly created element after insert (#6596)
* PDF Editor: select newly created element after insert

* Do not create so many savepoints
2026-09-30 12:19:03 +02:00
dependabot[bot] 23e9ca8f34 Bump brace-expansion (#6625)
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 5.0.4 to 5.0.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.4...v5.0.12)

Updates `brace-expansion` from 2.0.2 to 2.1.7
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.4...v5.0.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.12
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 2.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-30 10:53:41 +02:00
Raphael Michel e19afd39fb Bump version to 2026.9.0.dev0 2026-09-30 10:50:09 +02:00
Raphael Michel f7863c9fee Bump version to 2026.8.0 2026-09-30 10:50:03 +02:00
Raphael Michel fc3150fecc Revert "DE: Update Wordlist"
This reverts commit 89318bcc24.
2026-09-30 10:49:57 +02:00
Martin Gross 89318bcc24 DE: Update Wordlist 2026-09-30 10:24:13 +02:00
dependabot[bot] a388391f06 Update pyjwt requirement from ==2.14.* to ==2.15.* (#6620) 2026-09-30 10:12:38 +02:00
Raphael Michel b4d000379c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Phin Wolkwitz ba9bba49c6 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Daniel Musketa 824624cce6 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Phin Wolkwitz 8601f62581 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Daniel Musketa 7547d64dea Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Matthias Schrumpf d1186c83ef Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel e4782ff217 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Matthias Schrumpf 3328dc28c7 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel 6c83b4288a Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel 6ff37796c3 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel fa60c320b9 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel ca9d9faef4 Translations: Update German
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-09-30 10:10:52 +02:00
Raphael Michel 9718d1ee83 Translations: Update wordlist 2026-09-30 10:09:23 +02:00
pajowu 7bdb2c1555 Email: always use quoted-printable (#6617) 2026-09-30 09:54:21 +02:00
Raphael Michel da6753b53f Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@pretix.eu>
2026-09-30 09:26:26 +02:00
CVZ-es 33df9d13be Translations: Update French
Currently translated at 100.0% (6419 of 6419 strings)

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

powered by weblate
2026-09-30 09:25:43 +02:00
Nate Horst 582f9ac642 Translations: Update Thai
Currently translated at 81.1% (5206 of 6419 strings)

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

powered by weblate
2026-09-30 09:25:00 +02:00
Translate pretix user 594 5ce2957965 Translations: Update Russian
Currently translated at 20.0% (1285 of 6419 strings)

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

powered by weblate
2026-09-30 09:25:00 +02:00
Ыукпун eb9d6ad9a3 Translations: Update Russian
Currently translated at 20.0% (1285 of 6419 strings)

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

powered by weblate
2026-09-30 09:25:00 +02:00
Raphael Michel d339e1d594 Hotfix: Perform validation on the selected step, not on all steps before 2026-09-29 20:11:51 +02:00
Raphael Michel cb15559ac6 Scheduled export: Fix copying of email subject and text (Z#23221224) (#6616) 2026-09-29 16:44:42 +02:00
pajowu 3c95f1fee7 Add HERMA 9012 90 x 90mm Badge format (Z#23248189) (#6619) 2026-09-29 16:44:02 +02:00
Richard Schreiber 6a380c9356 Presale: add js-helper to disable submit for x seconds (#6592)
* Presale: add js-helper to disable submit for x seconds

* Remove disabled-class from button, if any

* Use Intl.RelativeTimeFormat for time formatting
2026-09-29 16:36:09 +02:00
Kara Engelhardt eeea01b31a Fix linter errors 2026-09-29 15:34:26 +02:00
Kara Engelhardt 470621b5cb Add missing licenseheader 2026-09-29 15:34:23 +02:00
dependabot[bot] 5802153101 Update sentry-sdk requirement from ==2.69.* to ==2.70.* (#6600)
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.69.0...2.70.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.70.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-29 15:33:24 +02:00
Nate Horst e9f89d5d92 Translations: Update Thai
Currently translated at 70.0% (4494 of 6419 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Nate Horst 695689ed06 Translations: Update Thai
Currently translated at 98.8% (257 of 260 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Nate Horst 12d405d861 Translations: Update Thai
Currently translated at 69.5% (4463 of 6419 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Nate Horst 649eee3025 Translations: Update Thai
Currently translated at 65.8% (4230 of 6419 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Tim 149b4f23aa Translations: Update Spanish
Currently translated at 100.0% (6419 of 6419 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Nate Horst fa40ccc623 Translations: Update Thai
Currently translated at 55.9% (3593 of 6419 strings)

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

powered by weblate
2026-09-29 15:33:15 +02:00
Mira Weller 2ba74b1697 Fix various redirects for events on MODE_ORG_ALT_DOMAIN 2026-09-29 14:34:49 +02:00
Mira Weller e54cd6af44 Fix redirect to customer login page when:
- event on custom domain (MODE_EVENT_DOMAIN or MODE_ORG_ALT_DOMAIN)
- organizer on system domain
2026-09-29 14:34:49 +02:00
Mira Weller d2b58d428e Fix customer logout when:
- event on custom domain (MODE_EVENT_DOMAIN or MODE_ORG_ALT_DOMAIN)
- organizer on system domain
2026-09-29 14:34:49 +02:00
Raphael Michel 74332faa7b [SECURITY] API: Fix session validation for uploaded files (CVE-2026-101269, Z#23247174) 2026-09-29 14:30:54 +02:00
Mira Weller bbf391f7d5 Block out of bounds image crop dimensions (Z#23245937 / PRT-009) 2026-09-29 14:30:54 +02:00
Mira Weller d78d5b52fa Prevent parsing non-standard-compliant JSON float values (Z#23245937 / PRT-021) 2026-09-29 14:30:54 +02:00
Mira Weller 9c0fef3d72 Fix potential infinite loop in pdf render (Z#23245937 / PRT-021) 2026-09-29 14:30:54 +02:00
Mira Weller c7d6630979 Fix inefficient loop in compute_validity (Z#23245937 / PRT-013) 2026-09-29 14:30:53 +02:00
Raphael Michel 4edd3c4654 [SECURITY] OAuth: Disable existing tokens when deactivating Application (CVE-2026-101271, Z#23247296) 2026-09-29 14:30:53 +02:00
Mira Weller b1ae638394 [SECURITY] Escape help texts (CVE-2026-101270) 2026-09-29 14:30:53 +02:00
Raphael Michel ae9bb68645 [SECURITY] Fix customer session fixation on cross-domain login (CVE-2026-101268, Z#23247268) 2026-09-29 14:30:53 +02:00
Raphael Michel 48f5a7c8cc [SECURITY] Fix information leak in widgets.json on dashboard (CVE-2026-101267, Z#23247172)
Thanks to Wenhao Wu, Southeast University
2026-09-29 14:30:53 +02:00
Raphael Michel d43032274b [SECURITY] Fix checkout validation bypass (CVE-2026-101266, Z#23245008) 2026-09-29 14:30:53 +02:00
Raphael Michel ae9a744fd9 Revert "Always base64-encode email attachments (Z#23242540) (#6476)"
This reverts commit ff4d3cd403.
2026-09-29 14:07:57 +02:00
pajowu ff4d3cd403 Always base64-encode email attachments (Z#23242540) (#6476)
It might sound unneccesary to base64-encode plaintext attachments, but some smtp providers modify them otherwise
2026-09-29 12:27:43 +02:00
pajowu 3440ee16f2 Plugin List: Apply search filter if prefilled by browser (#6543)
* Plugin List: Apply searchfilter if prefilled by browser

Browsers often prefill the form fields, e.g. with their old content when reloading or going back from the previous page. However these filters were not applied until you changed one of the form fields

* Fix linting errors in plugins.js

* Apply review suggestions
2026-09-29 11:51:29 +02:00
pajowu 43e5b62db3 Banktransfer: Make actionvie atomic (Z#23246414) (#6604) 2026-09-29 11:51:12 +02:00
dependabot[bot] b249c7b417 Bump markdown from 3.10.3 to 3.11 (#6608)
Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.10.3 to 3.11.
- [Release notes](https://github.com/Python-Markdown/markdown/releases)
- [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md)
- [Commits](https://github.com/Python-Markdown/markdown/compare/3.10.3...3.11.0)

---
updated-dependencies:
- dependency-name: markdown
  dependency-version: '3.11'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-29 10:17:48 +02:00
dependabot[bot] b68c324389 Update flake8 requirement from ==7.3.* to ==7.4.* (#6609)
Updates the requirements on [flake8](https://github.com/pycqa/flake8) to permit the latest version.
- [Commits](https://github.com/pycqa/flake8/compare/7.3.0...7.4.1)

---
updated-dependencies:
- dependency-name: flake8
  dependency-version: 7.4.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-29 10:17:29 +02:00
Aodhán Burke 4b320b6dab Fix typo: then -> than (#6615) 2026-09-29 10:16:54 +02:00
Richard Schreiber df74cbf5fc Remove Vue2-based widget (#6610)
* Remove Vue2-based widget

* move floatformat.js

* Delete docready.js

* Update widget.py
2026-09-29 10:14:21 +02:00
pajowu c875d758f9 Fix crash on too old time machine date (Z#23245824) (#6599)
* Fix crash on too old time machine date (Z#23245824)

dateutil.parser seems to reject dates with second-precision timezone-offsets, which datetime produces for some dates aroung the year 200. datetime.fromisoformat has no problem parsing them

* Review comments

* Add min date
2026-09-28 17:28:26 +02:00
Martin Gross 2ebdd048c2 PayPal2: Drop maximum length of client ID (Rel: #6424) 2026-09-28 16:12:55 +02:00
Raphael Michel ca40b09080 Fix API documentation bug 2026-09-28 15:46:41 +02:00
pajowuandRaphael Michel 558bf910fd Use fragment_product_list in voucher redemption view (Z#23246929) (#6567)
* Use fragment_product_list in voucher redemption view (Z#23246929)

* Formatting

* Fix usage in templates

* Review comments

* handle form prefix in fragment_product_list.html

---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-09-28 13:01:15 +02:00
pajowu 7e1e472691 Monkeypatch stock csrfmiddleware (#6401)
* Monkeypatch stock csrfmiddleware

* Add test for ensure_csrf_cookie
2026-09-28 11:46:23 +02:00
Richard Schreiber b06c9ef463 Control: do not fail on missing reason when user confirm (#6602) 2026-09-28 11:43:52 +02:00
pajowu 2af8d29ca9 Add pdf render tests (#6566)
* Add pdf tests

* pdf tests: Save temporary pdfs for failed test debugging

* Review comments
2026-09-25 16:54:58 +02:00
Raphael Michel 2791d5e49e Celery: Fix missing log if workers are recycled due to RAM usage (#6588) 2026-09-25 08:56:49 +02:00
Richard Schreiber ed68719731 Widget: fix calendar view showing old events due to non-unique key (Z#23247433) (#6585)
* Widget: fix calendar view showing old events due to cache-key (Z#23247433)

* Update EventCalendarCell.vue
2026-09-24 12:00:57 +02:00
9aeb4b9731 Organizer API: Add endpoint for event-meta-properties
* Add API-endpoint for event-meta-properties

* Add new doc-file to index, fix spelling and description

* Fix logentry

* Fix logentry again

* validate and add tests

* add meta_properties from organizer only

* fix choices validation

* filter unknown keys from choices due to django-formsets

* Apply batched suggestions from code review

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

* add safe-guard normalization to None to to_representation

* Apply batched suggestions from code review

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* update tests to check for error-messages as well

* fix flake8

* fix permission tests

* Make ObjectListField more flexibel for re-use

* fix validation result

* Improve validation

* Change to I18nField for validation

* update MetaPropertyDictField

* fix docs for i18n strings

* make label_child configurable if MetaPropertyDictField should contain non-localized stuff

* undo test changes in events test

* fix flake8

* Apply batched suggestions from code review

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* improve code formatting

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
2026-09-24 10:51:23 +02:00
Minding 9324887790 Translate OAuth buttons (#6590)
* Translate OAuth buttons

* Revert .po changes
2026-09-24 09:52:47 +02:00
Nate Horst d353384555 Translations: Update Thai
Currently translated at 53.0% (3407 of 6419 strings)

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

powered by weblate
2026-09-24 09:16:49 +02:00
Raphael Michel b8f8e49cce API: Add additional tests for modifying meta data (Z#23247634) 2026-09-23 19:01:52 +02:00
Raphael Michel 806d0a5748 User details: Show list of 2FA devices and allow to reset drift (#6569)
* User details: Show list of 2FA devices

* Add reset button

* Reset throttle

* Refactoring

* Fix delete paths in tests
2026-09-23 17:51:10 +02:00
Raphael Michel 69e541e5af Product list: Fix edge case in dependent availabilities (Z#23243701) (#6532) 2026-09-23 17:22:54 +02:00
Raphael Michel 0723e6015c Event header: Remove date directly above other date (Z#23245589) (#6524)
* Event header: Fix wrapping of date in h2 header (Z#23245589)

This was already correct if the header was a h1

* Revert "Event header: Fix wrapping of date in h2 header (Z#23245589)"

This reverts commit 3d25316fe3.

* Event header: Remove date directly above other date

This removes the date from the <h2> option used when a header image is
uploaded and the header should still be shown. Rendering the date here
makes little sense, since the infobox with the date is always just a few
lines below. It still makes sense for the <h1> option where it becomes
part of the site header.
2026-09-23 15:54:47 +02:00
Raphael Michel b8e1ab8258 Voucher API: Add search parameter (Z#23240589) (#6523) 2026-09-23 15:54:44 +02:00
Raphael Michel 81764278ae Email settings: Collapse attendee texts if unused (Z#23243417) (#6535) 2026-09-23 14:00:56 +02:00
Nate Horst f3068e627a Translations: Update Thai
Currently translated at 52.2% (3356 of 6419 strings)

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

powered by weblate
2026-09-23 11:41:45 +02:00
Nate Horst 993a3f96e9 Translations: Update Thai
Currently translated at 52.1% (3346 of 6419 strings)

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

powered by weblate
2026-09-23 11:41:45 +02:00
Nate Horst 1af0b5e442 Translations: Update Thai
Currently translated at 51.8% (3330 of 6419 strings)

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

powered by weblate
2026-09-23 11:41:45 +02:00
Nate Horst 71b6bf630f Translations: Update Thai
Currently translated at 45.6% (2930 of 6419 strings)

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

powered by weblate
2026-09-23 11:41:45 +02:00
Raphael MichelandMartin Gross 59026da06b Delete PayPal integration v1 (#6530)
* Delete PayPal integration v1

Leaving only migrations behind

* Try to fix CI

* Fix tests some more

* Rename more plugin name instances in tests and docs

---------

Co-authored-by: Martin Gross <martin@pc-coholic.de>
2026-09-23 11:38:16 +02:00
Raphael Michel b3e0892d76 Conditionally import sentry SDK during error page rendering 2026-09-23 11:33:05 +02:00
Raphael Michel 3029a6839b Prevent Sentry SDK from being loaded during tests 2026-09-23 11:29:28 +02:00
Raphael Michel a826b0a1bd Customer area: Hide password change link for SSO users (fixes #6572) (#6580) 2026-09-22 15:22:43 +02:00
Raphael Michel 7d7474c07d Refund form: Do not allow individual negative amounts (#6579) 2026-09-22 12:47:06 +02:00
Raphael Michel 58023381b9 Merge branch 'refund-offset-perm-check' into 'master'
Offset refund: Do not allow to offset to order without access to

See merge request pretix/pretix!52
2026-09-22 12:46:20 +02:00
Raphael Michel e5c44e7aed Offset refund: Do not allow to offset to order without access to 2026-09-22 12:46:20 +02:00
Raphael Michel d77a179606 2FA: Show info on throttled request (#6581)
* 2FA: Show info on throttled request

* Round up the time
2026-09-22 10:38:26 +02:00
Raphael Michel 29614db1e2 InvoiceShredder: Fix shredding of new fields (#6577)
* InvoiceShredder: Fix shredding of new fields

* Update src/pretix/base/migrations/0311_fix_unshredded_invoices.py
2026-09-22 10:37:58 +02:00
Raphael Michel 365051cc21 Event metadata: Fix server-side validation of write protection (#6578) 2026-09-22 10:00:15 +02:00
Raphael Michel 5c6b0eef6f Plugin configuration: Allow organizers to see all events (#6576) 2026-09-22 09:51:18 +02:00
Raphael Michel bc0a6b662b Checkout: Fix VAT ID revalidation after country change (#6575) 2026-09-22 09:51:10 +02:00
dependabot[bot]andRichard Schreiber 0bc1aed1a0 Update pypdf requirement from ==6.11.* to ==6.19.* (#6570)
* Update pypdf requirement from ==6.11.* to ==6.18.*

Updates the requirements on [pypdf](https://github.com/py-pdf/pypdf) to permit the latest version.
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.11.0...6.18.1)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.18.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update pyproject.toml

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-09-22 09:38:28 +02:00
Rita Gimenez 0eaa5a081c Translations: Update Catalan
Currently translated at 29.4% (1892 of 6419 strings)

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

powered by weblate
2026-09-21 14:56:27 +02:00
Nate Horst 688c434c4f Translations: Update Thai
Currently translated at 37.1% (2387 of 6419 strings)

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

powered by weblate
2026-09-21 14:56:27 +02:00
dependabot[bot]andRaphael Michel c0474604a1 Update isort requirement from ==8.0.* to ==9.0.* (#6510)
* Update isort requirement from ==8.0.* to ==9.0.*

Updates the requirements on [isort](https://github.com/PyCQA/isort) to permit the latest version.
- [Release notes](https://github.com/PyCQA/isort/releases)
- [Changelog](https://github.com/PyCQA/isort/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PyCQA/isort/compare/8.0.0...9.0.1)

---
updated-dependencies:
- dependency-name: isort
  dependency-version: 9.0.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* Run isort

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-09-21 14:56:14 +02:00
dependabot[bot] 1741d08c2c Update cryptography requirement from >=50.0.0 to >=50.0.1 (#6509)
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/50.0.0...50.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:14:06 +02:00
dependabot[bot] 6bbd808aaa Bump postcss-selector-parser from 7.1.1 to 7.1.5 (#6513)
Bumps [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser) from 7.1.1 to 7.1.5.
- [Release notes](https://github.com/postcss/postcss-selector-parser/releases)
- [Changelog](https://github.com/postcss/postcss-selector-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-selector-parser/compare/v7.1.1...7.1.5)

---
updated-dependencies:
- dependency-name: postcss-selector-parser
  dependency-version: 7.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:13:46 +02:00
dependabot[bot] aa90c5b3b1 Bump @humanfs/node from 0.16.7 to 0.16.8 (#6517)
Bumps [@humanfs/node](https://github.com/humanwhocodes/humanfs/tree/HEAD/packages/node) from 0.16.7 to 0.16.8.
- [Release notes](https://github.com/humanwhocodes/humanfs/releases)
- [Changelog](https://github.com/humanwhocodes/humanfs/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/humanwhocodes/humanfs/commits/node-v0.16.8/packages/node)

---
updated-dependencies:
- dependency-name: "@humanfs/node"
  dependency-version: 0.16.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:13:39 +02:00
dependabot[bot] bfbabe0e58 Update fakeredis requirement from ==2.37.* to ==2.38.* (#6563)
Updates the requirements on [fakeredis](https://github.com/cunla/fakeredis-py) to permit the latest version.
- [Release notes](https://github.com/cunla/fakeredis-py/releases)
- [Commits](https://github.com/cunla/fakeredis-py/compare/v2.37.0...v2.38.0)

---
updated-dependencies:
- dependency-name: fakeredis
  dependency-version: 2.38.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:08:32 +02:00
Raphael Michelandpajowu aa14505d2c Money representation in templates: Allow more precision (#6454)
* Money representation in templates: Allow more precision

When rendering money in templates, we used to have the following logic:

- When the decimal places fit the currency, render with Babel
- When they don't, e.g. we stored 123.67 JPY, even though there are no
  fractional Yens, render without Babel with a custom format, but render
  the fractional Yens because we'd rather *show* wrong data and make the
  bug obvious than hide it.

However, we only did that up to a prevision of two places, we never
showed more. This is still sufficient for core pretix, but we have
plugins that need to operate in fractional cents. Also, we CAN render
everything through babel for consistent formatting.

There is one **risk**: This might cause weird results on SQLite. Since
SQLite has no concept of precise decimal math, results of in-SQL
computations can sometimes experience floating point errors and show
with A LOT of decimal palces. This used to be invisible since the UI
performed the rounding. With this PR – not any more. We'll need to see
how annoying it is, but it should only affect development mode.

This PR also fixes a bug in tax_rate_format that for some reason did not
do what it was supposed to do, even though I tested it back then, weird.
Might even be a Python version thing?

* Update src/pretix/base/templatetags/money.py

Co-authored-by: pajowu <engelhardt@pretix.eu>

* Apply suggestion from @pajowu

Co-authored-by: pajowu <engelhardt@pretix.eu>

* Fix typing stuff

* Fix precision issue

---------

Co-authored-by: pajowu <engelhardt@pretix.eu>
2026-09-18 11:49:12 +02:00
Kara Engelhardt df69656364 PDF: Fix background page being rotated multiple times
transfer_rotation_to_content (or rather add_transformation/replace_content) do not properly duplicate the contentstream they're modifying. this means that if you pass a pdf-level copy of the same bg_page multiple times, the content will be rotated multiple times but the rotation-property ('/Rotate') will only be changed for the first page

pypdfs docs explicitly say that you should not to add_transformation + merge, but instead use merge_transformed_page, which this now does for everything
2026-09-18 11:35:01 +02:00
Kara Engelhardt 82cffd1519 pdf: calculate sizebox based on cropbox and mediabox 2026-09-18 11:33:34 +02:00
Richard Schreiber 7fc527135d PDF: use artbox before trimbox before mediabox from background-PDF as size for canvas 2026-09-18 11:33:34 +02:00
Raphael Michel 8e39b67ee9 Fix missing translatable field label 2026-09-17 11:56:12 +02:00
dependabot[bot] 289cbe5bfc Update django-countries requirement from ==9.0.* to ==9.1.* (#6562)
Updates the requirements on [django-countries](https://github.com/SmileyChris/django-countries) to permit the latest version.
- [Changelog](https://github.com/SmileyChris/django-countries/blob/main/CHANGES.md)
- [Commits](https://github.com/SmileyChris/django-countries/compare/v9.0.0...v9.1.0)

---
updated-dependencies:
- dependency-name: django-countries
  dependency-version: 9.1.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-17 10:43:55 +02:00
Raphael Michel 7143b877c8 Translations: Update German
Currently translated at 100.0% (6419 of 6419 strings)

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

powered by weblate
2026-09-16 15:46:28 +02:00
dependabot[bot] e2a78100af Update pyjwt requirement from ==2.13.* to ==2.14.* (#6557)
Updates the requirements on [pyjwt](https://github.com/jpadilla/pyjwt) to permit the latest version.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.13.0...2.14.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.14.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 15:46:16 +02:00
dependabot[bot] ca77467e48 Update sentry-sdk requirement from ==2.68.* to ==2.69.* (#6556)
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.68.0...2.69.1)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.69.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 15:46:07 +02:00
Raphael Michel d3cbfcd4da Invoice export: Add transmission-specific data (Z#23239732) (#6522) 2026-09-15 16:44:00 +02:00
322 changed files with 33800 additions and 29912 deletions

No files matched your search

+1 -2
View File
@@ -16,8 +16,6 @@ recursive-include src/pretix/plugins/banktransfer/templates *
recursive-include src/pretix/plugins/banktransfer/static *
recursive-include src/pretix/plugins/manualpayment/templates *
recursive-include src/pretix/plugins/manualpayment/static *
recursive-include src/pretix/plugins/paypal/templates *
recursive-include src/pretix/plugins/paypal/static *
recursive-include src/pretix/plugins/paypal2/templates *
recursive-include src/pretix/plugins/paypal2/static *
recursive-include src/pretix/plugins/src/pretixdroid/templates *
@@ -44,6 +42,7 @@ recursive-include src *.py
recursive-include src *.svg
recursive-include src *.txt
recursive-include src Makefile
recursive-include src *.pdf
recursive-exclude doc *
recursive-exclude deployment *
+2 -2
View File
@@ -53,6 +53,8 @@ Checking a ticket in
Defaults to ``false`` in which case the server will determine the language (currently
the event default language, might change in the future with support for the
``Accept-Language`` header).
:<json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
object will not reflect the simulated changes.
:>json string status: ``"ok"``, ``"incomplete"``, ``"exchange"``, or ``"error"``
:>json string reason: Reason code, only set on status ``"error"``, see below for possible values.
:>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null.
@@ -71,8 +73,6 @@ Checking a ticket in
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
:>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``.
:>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``.
:>json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
object will not reflect the simulated changes.
**Example request**:
+241
View File
@@ -0,0 +1,241 @@
Event Meta Properties
=====================
Resource description
--------------------
An event meta property is used to to define meta information fields for its events.
This information can be re-used, for example, in ticket layouts.
The event meta property resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Unique ID for this property
name string Name of the property
default string Value of the default option
required boolean If ``true``, an event can only be taken live if the
property is set. In event series, it's always optional
to set a value for individual dates
protected boolean If ``true``, the value for an event can only be changed
by organizer-level administrators
filter_public boolean If ``true``, this property will be shown to filter
events in the public event list and calendar
public_label string Public name of the property
filter_allowed boolean If ``true``, this property will be shown to filter
events or reports in the backend, and it can also be
used for hidden filter parameters in the frontend
choices list of objects List of JSON objects representing all permitted values
for this property, or ``null`` for no limitation.
Each choice object has a required internal name named
``key`` and optional public name named ``label``
consisting of a dictionary of i18n string translations
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/
Returns a list of all meta properties for the organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/meta_properties/ 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,
"name": "Color",
"default": "blue",
"required": false,
"protected": false,
"filter_public": false,
"public_label": {},
"filter_allowed": true,
"choices": [
{
"key": "blue",
"label": {
"en": "Blue"
},
}
]
}
]
}
:param organizer: The ``slug`` field of the organizer
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Returns information on one property, identified by its id.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/event_meta_properties/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
{
"id": 1,
"name": "Color",
"default": "blue",
"required": false,
"protected": false,
"filter_public": false,
"public_label": {},
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:param id: The ``id`` field of the meta property to retrieve
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/event_meta_properties/
Creates a new meta property
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/event_meta_properties/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "ref-code",
"default": "abcde",
"required": true,
"choices": null
}
**Example response**:
.. sourcecode:: http
{
"id": 2,
"name": "reference",
"default": "abcde",
"required": true,
"protected": false,
"filter_public": false,
"public_label": null,
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:statuscode 201: no error
:statuscode 400: The meta property could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Update a meta property. 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/event_meta_properties/2/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"required": false
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 3,
"name": "reference",
"default": "abcde",
"required": false,
"protected": false,
"filter_public": false,
"public_label": null,
"filter_allowed": true,
"choices": null
}
:param organizer: The ``slug`` field of the organizer
:param id: The ``id`` field of the meta property to modify
:statuscode 200: no error
:statuscode 400: The property could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
Delete a meta property.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/event_meta_properties/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
:param id: The ``id`` field of the meta property to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource.
+8 -8
View File
@@ -110,7 +110,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.ticketoutputpdf"
],
"all_sales_channels": false,
@@ -199,7 +199,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.ticketoutputpdf"
],
"valid_keys": {
@@ -262,7 +262,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -299,7 +299,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -364,7 +364,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -401,7 +401,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
"pretix.plugins.paypal2"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -438,7 +438,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.pretixdroid"
]
}
@@ -475,7 +475,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.paypal2",
"pretix.plugins.pretixdroid"
],
"all_sales_channels": true,
+1
View File
@@ -12,6 +12,7 @@ at :ref:`plugin-docs`.
organizers
events
subevents
event_meta_properties
taxrules
categories
items
+1
View File
@@ -116,6 +116,7 @@ Endpoints
:query integer page: The page number in case of a multi-page result set, default is 1
:query string code: Only show the voucher with the given voucher code.
:query string search: Only show the voucher with the given query found in the code, tag, or comment.
:query integer max_usages: Only show vouchers with the given maximal number of usages.
:query integer redeemed: Only show vouchers with the given number of redemptions. Note that this doesn't tell you if
the voucher can still be redeemed, as this also depends on ``max_usages``. See the
-1
View File
@@ -49,7 +49,6 @@ const ignores = globalIgnores([
'src/pretix/static/pretixcontrol/js/ui/editor.js',
'src/pretix/static/pretixcontrol/js/ui/geo.js',
'src/pretix/static/pretixcontrol/js/ui/main.js',
'src/pretix/static/pretixcontrol/js/ui/plugins.js',
'src/pretix/static/pretixcontrol/js/ui/subevent.js',
'src/pretix/static/pretixcontrol/js/ui/variations.js',
'src/pretix/static/pretixcontrol/js/ui/webauthn.js',
+50 -26
View File
@@ -9,7 +9,8 @@
"version": "1.0.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"vue": "^3.5.30"
"vue": "^3.5.30",
"vue-slicksort": "^2.0.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -194,16 +195,16 @@
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
@@ -294,29 +295,43 @@
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -1325,16 +1340,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
@@ -1683,9 +1698,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2108,16 +2123,16 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
@@ -3380,9 +3395,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
"integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4635,6 +4650,15 @@
"vue-eslint-parser": "^10.0.0"
}
},
"node_modules/vue-slicksort": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/vue-slicksort/-/vue-slicksort-2.0.5.tgz",
"integrity": "sha512-fXz1YrNjhUbJK7o0tMk27mIr4pMAZYLSYvtmLazCtfpvz+zafPCn34ILDL8B7hT7WLVZKreYs6JVe5VWymqmzA==",
"license": "MIT",
"peerDependencies": {
"vue": ">=3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2 -1
View File
@@ -27,7 +27,8 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"vue": "^3.5.30"
"vue": "^3.5.30",
"vue-slicksort": "^2.0.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+10 -9
View File
@@ -33,14 +33,14 @@ dependencies = [
"bleach==6.4.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=50.0.0",
"cryptography>=50.0.1",
"css-inline==0.21.*",
"defusedcsv>=3.0.0",
"dnspython==2.*",
"Django[argon2]==5.2.*,>=5.2.17",
"django-bootstrap3==26.2",
"django-compressor==4.6.0",
"django-countries==9.0.*",
"django-countries==9.1.*",
"django-filter==26.1",
"django-formset-js-improved==0.5.0.5",
"django-formtools==2.7",
@@ -67,7 +67,7 @@ dependencies = [
"kombu==5.6.*",
"libsass==0.23.*",
"lxml",
"markdown==3.10.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
"markdown==3.11", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*",
"oauthlib==3.3.*",
@@ -75,7 +75,7 @@ dependencies = [
"packaging",
"paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.13.*",
"PyJWT==2.15.*",
"phonenumberslite==9.0.*",
"Pillow==12.3.*",
"pretix-plugin-build",
@@ -84,7 +84,7 @@ dependencies = [
"pycountry",
"pycparser==3.0",
"pycryptodome==3.23.*",
"pypdf==6.11.*",
"pypdf==6.19.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
@@ -94,7 +94,7 @@ dependencies = [
"redis==7.4.*",
"reportlab==5.0.*",
"requests==2.34.*",
"sentry-sdk==2.68.*",
"sentry-sdk==2.70.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
@@ -112,10 +112,10 @@ dev = [
"aiohttp==3.14.*",
"coverage",
"coveralls",
"fakeredis==2.37.*",
"flake8==7.3.*",
"fakeredis==2.38.*",
"flake8==7.4.*",
"freezegun",
"isort==8.0.*",
"isort==9.0.*",
"pep8-naming==0.15.*",
"potypo",
"pytest-asyncio>=1.4.0",
@@ -129,6 +129,7 @@ dev = [
"pytest==9.1.*",
"playwright",
"responses",
"pypdfium2"
]
[project.entry-points."distutils.commands"]
-1
View File
@@ -26,7 +26,6 @@ ignore =
src/tests/plugins/*
src/tests/plugins/badges/*
src/tests/plugins/banktransfer/*
src/tests/plugins/paypal/*
src/tests/plugins/paypal2/*
src/tests/plugins/pretixdroid/*
src/tests/plugins/stripe/*
+2
View File
@@ -30,3 +30,5 @@ npminstall:
npmbuild:
npm run build
licenseheaders:
licenseheaders -t ../.licenseheader -E .py -x "*/migrations/*.py"
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2026.8.0.dev0"
__version__ = "2026.9.0.dev0"
+12
View File
@@ -100,6 +100,10 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:itemcategory-list'),
('GET', 'api-v1:item-list'),
('GET', 'api-v1:question-list'),
('GET', 'api-v1:compatquestion-list'),
('GET', 'api-v1:datafield-list'),
('GET', 'api-v1:questionnaire-list'),
('GET', 'api-v1:questionnairechild-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:checkinlist-list'),
@@ -141,6 +145,10 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:itemcategory-list'),
('GET', 'api-v1:item-list'),
('GET', 'api-v1:question-list'),
('GET', 'api-v1:compatquestion-list'),
('GET', 'api-v1:datafield-list'),
('GET', 'api-v1:questionnaire-list'),
('GET', 'api-v1:questionnairechild-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:checkinlist-list'),
@@ -177,6 +185,10 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:itemcategory-list'),
('GET', 'api-v1:item-list'),
('GET', 'api-v1:question-list'),
('GET', 'api-v1:compatquestion-list'),
('GET', 'api-v1:datafield-list'),
('GET', 'api-v1:questionnaire-list'),
('GET', 'api-v1:questionnairechild-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:checkinlist-list'),
@@ -19,15 +19,12 @@
# 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/>.
#
def get_session_key_for_api_auth(user, auth):
if user.is_authenticated:
return f'api-upload-User-{user.pk}'
else:
return f'api-upload-{str(type(auth))}-{auth.pk}'
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
def get_session_key_for_api_request(request):
return get_session_key_for_api_auth(request.user, request.auth)
+80
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-today pretix 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 functools import reduce
from rest_framework import serializers
from pretix.api.exceptions import CompatException
class MergeField(serializers.Field):
"""
Read-only field that iterates over a list of related objects, picks a value from each, and merges the values
in a configurable way (mode parameter).
:param mode: list: returns a list of all picked values
consensus: returns the value only if it is equal on all related objects, otherwise
raises or returns an error object
union: only supported if picked values are iterables - generates union of all sub-items
bool_or: returns a boolean that is true if at least one picked value is truthy
"""
def __init__(self, related_name, inner_field, mode, **kwargs):
self.inner_field = inner_field
self.related_name = related_name
self.merge_mode = mode
assert mode in ('list', 'consensus', 'union', 'bool_or',)
kwargs['source'] = '*'
kwargs['read_only'] = True
super().__init__(**kwargs)
def bind(self, field_name, parent):
self.field_name = field_name
self.parent = parent
self.inner_field.bind(field_name, parent)
def get_attribute(self, instance):
vals = []
for ref in getattr(instance, self.related_name).all():
val = self.inner_field.get_attribute(ref)
if val is not None:
val = self.inner_field.to_representation(val)
vals.append(val)
if self.merge_mode == 'list':
return vals
elif self.merge_mode == 'consensus':
if len(vals) >= 1 and all(val == vals[0] for val in vals):
return vals[0]
else:
errmes = 'Questionnaire configuration incompatible with old data model due to conflicting values of ' + self.field_name
if self.context['ignore_errors']:
return {'_error': errmes, '_values': vals}
else:
raise CompatException({'detail': errmes, '_values': vals})
elif self.merge_mode == 'union':
return sorted(set(sum(vals, [])))
elif self.merge_mode == 'bool_or':
return reduce(lambda a, b: a or bool(b), vals, False)
else:
raise Exception('Unknown merge mode: ' + self.merge_mode)
def to_representation(self, value):
return value
@@ -19,13 +19,10 @@
# 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 django.dispatch import receiver
from pretix.base.signals import register_payment_providers
from rest_framework.exceptions import APIException
@receiver(register_payment_providers, dispatch_uid="payment_paypal")
def register_payment_provider(sender, **kwargs):
from .payment import Paypal
return Paypal
class CompatException(APIException):
status = 410
default_code = 'incompatible_data'
+4
View File
@@ -101,6 +101,10 @@ class OAuthAccessToken(AbstractAccessToken):
self.expires = now() - timedelta(hours=1)
self.save(update_fields=['expires'])
def is_valid(self, scopes=None):
# Can maybe be removed after upgrading django-oauth-toolkit to 3.4.1
return super().is_valid(scopes) and self.application.is_usable(None)
class OAuthRefreshToken(AbstractRefreshToken):
application = models.ForeignKey(
+3 -2
View File
@@ -32,6 +32,7 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
MixedAnswerListSerializer,
)
from pretix.base.models import SalesChannel, Seat, Voucher
from pretix.base.models.orders import CartPosition
@@ -43,7 +44,7 @@ class TaxIncludedField(serializers.Field):
class CartPositionSerializer(I18nAwareModelSerializer):
answers = AnswerSerializer(many=True)
answers = MixedAnswerListSerializer(child=AnswerSerializer(), source='*')
seat = InlineSeatSerializer()
includes_tax = TaxIncludedField(source='*')
@@ -55,7 +56,7 @@ class CartPositionSerializer(I18nAwareModelSerializer):
class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
answers = MixedAnswerListSerializer(child=AnswerCreateSerializer(required=False), source='*', required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
includes_tax = serializers.BooleanField(required=False, allow_null=True)
+12 -17
View File
@@ -764,14 +764,6 @@ class EventSettingsSerializer(SettingsSerializer):
'event_calendar_future_only',
'frontpage_text',
'event_info_text',
'attendee_names_asked',
'attendee_names_required',
'attendee_emails_asked',
'attendee_emails_required',
'attendee_addresses_asked',
'attendee_addresses_required',
'attendee_company_asked',
'attendee_company_required',
'attendee_data_explanation_text',
'confirm_texts',
'order_email_asked_twice',
@@ -941,14 +933,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'show_quota_left',
'show_dates_on_frontpage',
'max_items_per_order',
'attendee_names_asked',
'attendee_names_required',
'attendee_emails_asked',
'attendee_emails_required',
'attendee_addresses_asked',
'attendee_addresses_required',
'attendee_company_asked',
'attendee_company_required',
'ticket_download',
'ticket_download_addons',
'ticket_download_nonadm',
@@ -979,7 +963,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'reusable_media_type_nfc_mf0aes',
'reusable_media_type_nfc_mf0aes_random_uid',
'reusable_media_usage_enforced',
'system_question_order',
'tax_rule_payment',
'tax_rule_cancellation',
]
@@ -1004,6 +987,18 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
)
)
# Provide fixed values for old configuration fields to ensure apps never use their built-in FakeQuestions, only the
# ones we provide from POSCompatQuestionViewSet
self.fields['system_question_order'] = serializers.JSONField(read_only=True, default=dict(), source='_invalid')
self.fields['attendee_names_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_names_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_emails_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_emails_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_addresses_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_addresses_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_company_asked'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
self.fields['attendee_company_required'] = serializers.BooleanField(read_only=True, default=False, source='_invalid')
class MultiLineStringField(serializers.Field):
+55 -1
View File
@@ -36,6 +36,9 @@ from collections import OrderedDict
from django.core.exceptions import ValidationError
from rest_framework import serializers
from rest_framework.fields import SerializerMethodField
from pretix.api.auth.utils import get_session_key_for_api_request
def remove_duplicates_from_list(data):
@@ -83,10 +86,16 @@ class UploadedFileField(serializers.Field):
request = self.context.get('request', None)
try:
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}',
file__isnull=False,
pk=data[len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
# also be harmless because all files with this key are expired one day after deployment of this fix
# and no new files with this key are created
pass
elif cf.session_key != get_session_key_for_api_request(request):
self.fail('not_found')
except (ValidationError, IndexError): # invalid uuid
self.fail('not_found')
except CachedFile.DoesNotExist:
@@ -125,3 +134,48 @@ class PluginsField(serializers.Field):
return {
'plugins': data
}
class WritableSerializerMethodField(SerializerMethodField):
"""
A field that get its representation from calling a method on the
parent serializer class. The method called will be of the form
"get_{field_name}", and should take a single argument, which is the
object being serialized.
When the field is written, a method of the form "put_{field_name}"
is called, with the instance and the incoming data as arguments. It
should return a dictionary of data to be update validated_data with.
For example:
class ExampleSerializer(Serializer):
extra_info = WritableSerializerMethodField()
def get_extra_info(self, obj):
return ... # Calculate some data to return.
def put_extra_info(self, obj, new_value):
# Store new value
"""
def __init__(self, method_name=None, put_method_name=None, **kwargs):
self.put_method_name = put_method_name
super().__init__(method_name, **kwargs)
self.read_only = False
def bind(self, field_name, parent):
# The method name defaults to `get_{field_name}`.
if self.method_name is None:
self.method_name = f'get_{field_name}'
if self.put_method_name is None:
self.put_method_name = f'put_{field_name}'
super().bind(field_name, parent)
def to_representation(self, value):
method = getattr(self.parent, self.method_name)
return method(value)
def to_internal_value(self, data):
method = getattr(self.parent, self.put_method_name)
return method(self.parent.instance, data)
+439 -45
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import logging
# 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>.
#
@@ -33,26 +34,41 @@
# License for the specific language governing permissions and limitations under the License.
import os.path
from decimal import Decimal
from itertools import zip_longest
import rest_framework
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import QuerySet
from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _
from i18nfield.rest_framework import I18nField
from rest_framework import serializers
from rest_framework.exceptions import ValidationError as DrfValidationError
from rest_framework.serializers import as_serializer_error
from pretix.api.compat import MergeField
from pretix.api.serializers import (
CompatDecimalField, SalesChannelMigrationMixin,
)
from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.fields import UploadedFileField, WritableSerializerMethodField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.forms.questions import REQUIRED_NAME_PARTS
from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemProgramTime,
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SalesChannel,
ItemVariation, ItemVariationMetaValue, Question, QuestionAnswer,
QuestionOption, Quota, SalesChannel,
)
from pretix.base.models.items import Questionnaire, QuestionnaireRow
from pretix.base.settings import (
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
)
from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers.i18n import i18n_all_from_gettext
logger = logging.getLogger(__name__)
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
@@ -293,6 +309,8 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
tax_rate = ItemTaxRateField(source='*', read_only=True, max_digits=7, decimal_places=4)
meta_data = MetaDataField(required=False, source='*')
personalized = WritableSerializerMethodField(required=False)
questionnaires = serializers.PrimaryKeyRelatedField(many=True, queryset=Questionnaire.objects.none(), allow_null=True, required=False)
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
'image/png', 'image/jpeg', 'image/gif'
), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
@@ -320,7 +338,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
'grant_membership_duration_months', 'validity_mode', 'validity_fixed_from', 'validity_fixed_until',
'validity_dynamic_duration_minutes', 'validity_dynamic_duration_hours', 'validity_dynamic_duration_days',
'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit',
'media_policy', 'media_type')
'media_policy', 'media_type', 'questionnaires')
read_only_fields = ('has_variations',)
def __init__(self, *args, **kwargs):
@@ -328,11 +346,35 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
self.fields['default_price'].allow_null = False
self.fields['default_price'].required = True
if not self.read_only:
self.fields['questionnaires'].child_relation.queryset = self.context['event'].questionnaires.filter(type__startswith='position_')
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
self.fields['variations'].child.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
def get_personalized(self, item):
return QuestionnaireRow.objects.filter(questionnaire__items=item, system_datafield__in=('@attendee_name_parts', '@attendee_email')).exists()
def put_personalized(self, item, new_value):
if item and new_value != self.get_personalized(item):
raise ValidationError('Deprecated field "personalized" can not be updated.')
if new_value and not item:
try:
return {'questionnaires': [self.context['request'].event.questionnaires.get(
type=Questionnaire.QuestionnaireType.ORDER_POSITION_SALE,
internal_name='Attendee data', # TODO(questionnaires): how to deal with localized internal name? maybe add a Questionnaire.identifier?
)]}
except Questionnaire.DoesNotExist:
raise ValidationError({'personalized': 'To create new products with personalized=True, a questionnaire named "Attendee data" must exist.'})
return {}
def to_internal_value(self, data):
if data.get('admission') and data.get('personalized') is None and 'questionnaires' not in data and not self.instance:
# Backwards compatibility
data['personalized'] = True
return super().to_internal_value(data)
def validate(self, data):
data = super().validate(data)
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data or 'program_times' in data):
@@ -343,15 +385,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
Item.clean_available(data.get('available_from'), data.get('available_until'))
Item.clean_media_settings(self.context['event'], data.get('media_policy'), data.get('media_type'), data.get('issue_giftcard'))
if data.get('personalized') and not data.get('admission'):
raise ValidationError(_('Only admission products can currently be personalized.'))
if data.get('admission') and 'personalized' not in data and not self.instance:
# Backwards compatibility
data['personalized'] = True
elif 'admission' in data and not data['admission']:
data['personalized'] = False
if data.get('issue_giftcard'):
if data.get('tax_rule') and data.get('tax_rule').rate > 0:
raise ValidationError(
@@ -417,9 +450,11 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
picture = validated_data.pop('picture', None)
require_membership_types = validated_data.pop('require_membership_types', [])
limit_sales_channels = validated_data.pop('limit_sales_channels', [])
questionnaires = validated_data.pop('questionnaires', [])
item = Item.objects.create(**validated_data)
if limit_sales_channels and not validated_data.get('all_sales_channels'):
item.limit_sales_channels.add(*limit_sales_channels)
item.questionnaires.add(*questionnaires)
if picture:
item.picture.save(os.path.basename(picture.name), picture)
if require_membership_types:
@@ -458,9 +493,11 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
value=value,
item=item
)
return item
def update(self, instance, validated_data):
validated_data.pop('compat_personalized', None)
meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None)
item = super().update(instance, validated_data)
@@ -530,27 +567,149 @@ class InlineQuestionOptionSerializer(I18nAwareModelSerializer):
fields = ('id', 'identifier', 'answer', 'position')
class DatafieldRelatedField(serializers.SlugRelatedField):
default_error_messages = {
'invalid_choice': _('"{input}" is not a valid choice.')
}
def __init__(self, *args, **kwargs):
super().__init__(slug_field='identifier', **kwargs)
def to_representation(self, qc):
if not qc:
return None
elif qc.system_datafield:
return qc.system_datafield
elif qc.user_datafield_id:
return qc.user_datafield.identifier
else:
return None
def to_internal_value(self, data):
if data is None:
return {'user_datafield': None, 'system_datafield': None}
elif type(data) == str and data.startswith('@'):
if data not in QuestionnaireRow.SystemDatafield.values:
self.fail("invalid_choice", input=data)
return {'user_datafield': None, 'system_datafield': data}
elif type(data) == str:
return {'user_datafield': super().to_internal_value(data), 'system_datafield': None}
else:
self.fail('incorrect_type', data_type=type(data).__name__)
def use_pk_only_optimization(self):
return self.source == '*'
class LegacyDependencyValueField(serializers.CharField):
def to_representation(self, obj):
return obj[0] if obj else None
def to_internal_value(self, data):
return [data] if data else []
class QuestionFormSerializer(I18nAwareModelSerializer):
datafield = DatafieldRelatedField(allow_null=True, source='*', queryset=Question.objects.none())
question = I18nField(source='label', allow_null=True)
type = serializers.CharField(source='user_datafield.type', allow_null=True, default=Question.FieldType.STRING)
identifier = serializers.SerializerMethodField(read_only=True)
show_during_checkin = serializers.BooleanField(source='user_datafield.show_during_checkin', allow_null=True, default=False)
print_on_invoice = serializers.BooleanField(source='user_datafield.print_on_invoice', allow_null=True, default=False)
valid_number_min = serializers.IntegerField(source='user_datafield.valid_number_min', allow_null=True)
valid_number_max = serializers.IntegerField(source='user_datafield.valid_number_max', allow_null=True)
valid_date_min = serializers.DateField(source='user_datafield.valid_date_min', allow_null=True)
valid_date_max = serializers.DateField(source='user_datafield.valid_date_max', allow_null=True)
valid_datetime_min = serializers.DateTimeField(source='user_datafield.valid_datetime_min', allow_null=True)
valid_datetime_max = serializers.DateTimeField(source='user_datafield.valid_datetime_max', allow_null=True)
valid_string_length_max = serializers.IntegerField(source='user_datafield.valid_string_length_max', allow_null=True)
valid_string_length_min = serializers.IntegerField(source='user_datafield.valid_string_length_min', allow_null=True)
valid_file_portrait = serializers.BooleanField(source='user_datafield.valid_file_portrait', allow_null=True)
dependency_question = DatafieldRelatedField(allow_null=True, required=False, queryset=Question.objects.none())
items = serializers.PrimaryKeyRelatedField(source='questionnaire.items', many=True, queryset=Item.objects.none(), allow_null=True, required=False)
questionnaire_id = serializers.IntegerField(source='questionnaire.id', read_only=True)
options = InlineQuestionOptionSerializer(source='user_datafield.options', many=True, required=False, default=[])
class Meta:
model = QuestionnaireRow
fields = ('datafield', 'label', 'type', 'required', 'help_text', 'items', 'options', 'position',
'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
'print_on_invoice', 'valid_number_min',
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait', 'questionnaire_id')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['items'].child_relation.queryset = self.context['event'].items.all() if 'event' in self.context else Item.objects.none()
def get_attribute(self, instance):
if isinstance(instance, QuestionAnswer):
position = instance.cartposition if instance.cartposition_id else instance.orderposition
print('searching ', repr(instance), repr(position), repr(instance.question), instance.pk, position.item_id, self.context) # TODO remove print
qcs = list(QuestionnaireRow.objects.filter(user_datafield_id=instance.question_id, questionnaire__items__in=[position.item_id]))
return next((qc for qc in qcs if qc.questionnaire.type == self.context.get('preferred_questionnaire_type', Questionnaire.QuestionnaireType.ORDER_POSITION_SALE)), qcs[0])
return super().get_attribute(instance)
def cook_dependency_question_id(self, qc):
if qc.dependency_question:
return qc.dependency_question.cooked_id
else:
return None
def get_identifier(self, qc):
return qc.user_datafield.identifier if qc.user_datafield else qc.system_datafield
@property
def data(self):
results = super().data
mangled_results = []
pos_offset = 0
for row in results:
row['position'] += pos_offset
if row['identifier'] == '@attendee_name_parts' and self.context.get('expand_attendee_name'):
settings = self.context['event'].settings
for k, v, w, *__ in PERSON_NAME_SCHEMES.get(settings.name_scheme)['fields']:
part = dict(row)
part["identifier"] += ":" + k
part["question"] = i18n_all_from_gettext(v, settings.locales)
if k == "title" and settings.name_scheme_titles:
part["type"] = Question.FieldType.CHOICE
part["options"] = [{"id": idx + 1, "identifier": k, "answer": i18n_all_from_gettext(k, settings.locales), "position": idx + 1} for idx, k in
enumerate(PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1])]
elif k == "salutation":
part["type"] = Question.FieldType.CHOICE
part["options"] = [{"id": idx + 1, "identifier": k, "answer": i18n_all_from_gettext(v, settings.locales), "position": idx + 1} for idx, (k, v) in
enumerate(PERSON_NAME_SALUTATIONS)]
part["id"] = QuestionnaireRow.cook_id_from_parts(row['questionnaire_id'], None, part["identifier"])
part["required"] = part["required"] and k in REQUIRED_NAME_PARTS
mangled_results.append(part)
pos_offset += 1
row["position"] += 1
else:
if row['identifier'] == '@country':
row['type'] = Question.FieldType.COUNTRYCODE
mangled_results.append(row)
return mangled_results
class QuestionSerializer(I18nAwareModelSerializer):
class DatafieldSerializer(I18nAwareModelSerializer):
internal_name = serializers.CharField(allow_null=True, source='question', read_only=True)
options = InlineQuestionOptionSerializer(many=True, required=False)
identifier = serializers.CharField(allow_null=True)
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
referenced_by_questionnaires = MergeField('referenced_by', serializers.IntegerField(source='questionnaire.id'), mode='list')
questionnaire_types = MergeField('referenced_by', serializers.CharField(source='questionnaire.type'), mode='list')
class Meta:
model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
fields = ('id', 'internal_name', 'identifier', 'type',
'show_during_checkin',
'hidden', 'print_on_invoice', 'valid_number_min',
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait')
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait',
'referenced_by_questionnaires', 'questionnaire_types',
'options')
def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance)
@@ -561,14 +720,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
self.instance.clean_type_change(self.instance.type, value)
return value
def validate_dependency_question(self, value):
if value:
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
if value == self.instance:
raise ValidationError('A question cannot depend on itself.')
return value
def validate(self, data):
data = super().validate(data)
if self.instance and 'options' in data:
@@ -580,21 +731,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
raise ValidationError('Dependencies are not supported during check-in.')
dep = full_data.get('dependency_question')
if dep:
if dep.ask_during_checkin:
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
seen_ids = {self.instance.pk} if self.instance else set()
while dep:
if dep.pk in seen_ids:
raise ValidationError(_('Circular dependency between questions detected.'))
seen_ids.add(dep.pk)
dep = dep.dependency_question
if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
raise ValidationError(_('This type of question cannot be asked during check-in.'))
@@ -619,13 +755,271 @@ class QuestionSerializer(I18nAwareModelSerializer):
options_data = validated_data.pop('options') if 'options' in validated_data else []
items = validated_data.pop('items', [])
question = Question.objects.create(**validated_data, container_type=Question.ContainerType.ORDERPOSITION)
question = Question.objects.create(**validated_data, container_type=self.context['container_type'])
question.items.set(items)
for opt_data in options_data:
QuestionOption.objects.create(question=question, **opt_data)
return question
class CompatQuestionSerializer(DatafieldSerializer):
question = MergeField('referenced_by', I18nField(source='label'), mode='consensus')
required = MergeField('referenced_by', serializers.BooleanField(), mode='consensus')
items = MergeField('referenced_by', serializers.PrimaryKeyRelatedField(source='questionnaire.items', many=True, queryset=Item.objects.none(), allow_null=True, required=False), mode='union')
position = serializers.IntegerField(required=False, read_only=True, default=0)
ask_during_checkin = MergeField('referenced_by', serializers.BooleanField(source='questionnaire.ask_during_checkin'), mode='bool_or')
dependency_question = MergeField('referenced_by', serializers.IntegerField(source='dependency_question.user_datafield_id', required=False, default=None, allow_null=True), mode='consensus')
dependency_values = MergeField('referenced_by', serializers.JSONField(), mode='consensus')
dependency_value = MergeField('referenced_by', LegacyDependencyValueField(source='dependency_values'), mode='consensus')
help_text = MergeField('referenced_by', I18nField(), mode='consensus')
class Meta:
model = Question
fields = (
'id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait',
# new
#'internal_name', 'referenced_by_questionnaires', 'questionnaire_types',
)
def __init__(self, *args, **kwargs):
self.fields['items'].inner_field.child_relation.queryset = kwargs['context']['event'].items.all()
super().__init__(*args, **kwargs)
class POSCompatQuestionSerializer(QuestionFormSerializer):
id = serializers.IntegerField(source='cooked_id', read_only=True)
question = I18nField(source='label', allow_null=True)
type = serializers.CharField(source='user_datafield.type', allow_null=True, default=Question.FieldType.STRING)
identifier = serializers.SerializerMethodField(read_only=True)
show_during_checkin = serializers.BooleanField(source='user_datafield.show_during_checkin', allow_null=True, default=False)
ask_during_checkin = serializers.BooleanField(source='questionnaire.ask_during_checkin')
hidden = serializers.BooleanField(source='questionnaire.hidden')
print_on_invoice = serializers.BooleanField(source='user_datafield.print_on_invoice', allow_null=True, default=False)
valid_number_min = serializers.IntegerField(source='user_datafield.valid_number_min', allow_null=True)
valid_number_max = serializers.IntegerField(source='user_datafield.valid_number_max', allow_null=True)
valid_date_min = serializers.DateField(source='user_datafield.valid_date_min', allow_null=True)
valid_date_max = serializers.DateField(source='user_datafield.valid_date_max', allow_null=True)
valid_datetime_min = serializers.DateTimeField(source='user_datafield.valid_datetime_min', allow_null=True)
valid_datetime_max = serializers.DateTimeField(source='user_datafield.valid_datetime_max', allow_null=True)
valid_string_length_max = serializers.IntegerField(source='user_datafield.valid_string_length_max', allow_null=True)
valid_string_length_min = serializers.IntegerField(source='user_datafield.valid_string_length_min', allow_null=True)
valid_file_portrait = serializers.BooleanField(source='user_datafield.valid_file_portrait', allow_null=True)
dependency_question = serializers.SerializerMethodField('cook_dependency_question_id', read_only=True)
dependency_value = serializers.SerializerMethodField('empty', read_only=True)
position = serializers.SerializerMethodField(read_only=True)
items = serializers.PrimaryKeyRelatedField(source='questionnaire.items', many=True, queryset=Item.objects.none(), allow_null=True, required=False)
questionnaire_id = serializers.IntegerField(source='questionnaire.id', read_only=True)
options = InlineQuestionOptionSerializer(source='user_datafield.options', many=True, required=False, default=[])
class Meta:
model = QuestionnaireRow
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait', 'questionnaire_id')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['items'].child_relation.queryset = self.context['event'].items.all() if 'event' in self.context else Item.objects.none()
def get_attribute(self, instance):
if isinstance(instance, QuestionAnswer):
position = instance.cartposition if instance.cartposition_id else instance.orderposition
print('searching ', repr(instance), repr(position), repr(instance.question), instance.pk, position.item_id, self.context) # TODO remove print
qcs = list(QuestionnaireRow.objects.filter(user_datafield_id=instance.question_id, questionnaire__items__in=[position.item_id]))
return next((qc for qc in qcs if qc.questionnaire.type == self.context.get('preferred_questionnaire_type', Questionnaire.QuestionnaireType.ORDER_POSITION_SALE)), qcs[0])
return super().get_attribute(instance)
def cook_dependency_question_id(self, qc):
if qc.dependency_question:
return qc.dependency_question.cooked_id
else:
return None
def get_identifier(self, qc):
return qc.user_datafield.identifier if qc.user_datafield else qc.system_datafield
def empty(self, qc):
return None
def get_position(self, qc):
return qc.questionnaire.position * 1000000000 + qc.position
@staticmethod
def mangle_results(event, results):
mangled_results = []
pos_offset = 0
for row in results:
row['position'] += pos_offset
if row['identifier'] == '@attendee_name_parts':
settings = event.settings
for k, v, w, *__ in PERSON_NAME_SCHEMES.get(settings.name_scheme)['fields']:
part = dict(row)
part["identifier"] += ":" + k
part["question"] = i18n_all_from_gettext(v, settings.locales)
if k == "title" and settings.name_scheme_titles:
part["type"] = Question.FieldType.CHOICE
part["options"] = [{"id": idx + 1, "identifier": k, "answer": i18n_all_from_gettext(k, settings.locales), "position": idx + 1} for idx, k in
enumerate(PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1])]
elif k == "salutation":
part["type"] = Question.FieldType.CHOICE
part["options"] = [{"id": idx + 1, "identifier": k, "answer": i18n_all_from_gettext(v, settings.locales), "position": idx + 1} for idx, (k, v) in
enumerate(PERSON_NAME_SALUTATIONS)]
part["id"] = QuestionnaireRow.cook_id_from_parts(row['questionnaire_id'], None, part["identifier"])
part["required"] = part["required"] and k in REQUIRED_NAME_PARTS
mangled_results.append(part)
pos_offset += 1
row["position"] += 1
else:
if row['identifier'] == '@country':
row['type'] = Question.FieldType.COUNTRYCODE
mangled_results.append(row)
return mangled_results
class RenderedMarkdownField(serializers.CharField):
def to_representation(self, value):
return rich_text(value)
class InlineQuestionnaireRowSerializer(I18nAwareModelSerializer):
datafield = DatafieldRelatedField(allow_null=True, source='*', queryset=Question.objects.none())
dependency_question = DatafieldRelatedField(allow_null=True, required=False, queryset=Question.objects.none())
rendered_help_text = RenderedMarkdownField(read_only=True, source='help_text')
cooked_id = serializers.IntegerField(read_only=True, allow_null=True)
class Meta:
model = QuestionnaireRow
fields = ('datafield', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values', 'rendered_help_text', 'cooked_id')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["datafield"].queryset = self.context["event"].questions.all()
self.fields["dependency_question"].queryset = self.context["event"].questions.all()
def validate_dependency_question(self, value):
if value:
if value['user_datafield']:
if value['user_datafield'].type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
return value
class QuestionnaireSerializer(I18nAwareModelSerializer):
items = serializers.PrimaryKeyRelatedField(many=True, queryset=Item.objects.none(), allow_null=True, required=False)
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta:
model = Questionnaire
fields = ('id', 'type', 'internal_name', 'items', 'position', 'all_sales_channels', 'limit_sales_channels', 'rows')
def __init__(self, *args, **kwargs):
self.fields['rows'] = InlineQuestionnaireRowSerializer(many=True, required=True, context=kwargs['context'], partial=False)
self.fields['items'].child_relation.queryset = kwargs['context']['event'].items.all()
self.fields['limit_sales_channels'].child_relation.queryset = kwargs['context']['event'].organizer.sales_channels.all()
super().__init__(*args, **kwargs)
def validate(self, data):
data = super().validate(data)
try:
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
except rest_framework.exceptions.ValidationError as e:
# we already have invalid state in the database, what should we do? hope it gets better after saving?
logger.exception("Invalid state in database, ignoring some validations!")
else:
#if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
# raise ValidationError('Dependencies are not supported during check-in.')
#if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
# raise ValidationError(_('This type of question cannot be asked during check-in.'))
#if full_data.get('show_during_checkin') and full_data.get('type') in Question.SHOW_DURING_CHECKIN_UNSUPPORTED:
# raise ValidationError(_('This type of question cannot be shown during check-in.'))
#Question.clean_items(event, full_data.get('items') or [])
if (full_data.get('type') == Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN
and any(c['dependency_question'] for c in full_data['rows'])):
raise ValidationError('Dependencies are not supported during check-in.')
if (not full_data.get('type').startswith('position_')
and any(c['system_datafield'] for c in full_data['rows'])):
raise ValidationError('System data fields are only supported on order positions.')
system_fields = set(c['system_datafield'] for c in full_data['rows'])
if ('zipcode' in system_fields or 'state' in system_fields or 'street' in system_fields or 'city' in system_fields) and 'country' not in system_fields:
raise ValidationError('The system data fields "street", "zip code", "city", and "state" can only be used in combination with the "country" field.')
return data
@transaction.atomic
def create(self, validated_data):
rows_data = validated_data.pop('rows') if 'rows' in validated_data else []
questionnaire = super().create(validated_data)
self.set_rows(questionnaire, rows_data)
try:
Questionnaire.check_constraints(questionnaire.event)
except ValidationError as e:
raise DrfValidationError(as_serializer_error(e))
return questionnaire
@transaction.atomic
def update(self, instance, validated_data):
rows_data = validated_data.pop('rows', None)
questionnaire = super().update(instance, validated_data)
if rows_data is not None:
self.set_rows(questionnaire, rows_data)
try:
Questionnaire.check_constraints(questionnaire.event)
except ValidationError as e:
raise DrfValidationError(as_serializer_error(e))
return questionnaire
def set_rows(self, questionnaire, new_data):
result = {}
child_serializer = self.fields['rows'].child
existing_rows = questionnaire.rows.all()
def q(qc):
return qc['user_datafield'] or qc['system_datafield']
for i, d in enumerate(new_data):
d['questionnaire'] = questionnaire
d['position'] = i + 1
d.setdefault('required', False)
d.setdefault('help_text', None)
d.setdefault('dependency_question', None)
d.setdefault('dependency_values', None)
for existing, update_data in zip_longest(existing_rows, new_data):
if update_data:
if update_data.get('dependency_question'):
try:
update_data['dependency_question'] = result[q(update_data['dependency_question'])]
except KeyError:
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
if existing:
result[q(update_data)] = child_serializer.update(existing, update_data)
else:
result[q(update_data)] = child_serializer.create(update_data)
else:
existing.delete()
return result
class QuotaSerializer(I18nAwareModelSerializer):
available = serializers.BooleanField(read_only=True)
available_number = serializers.IntegerField(read_only=True)
+118 -18
View File
@@ -40,13 +40,15 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse
from rest_framework.serializers import ListSerializer
from pretix.api.auth.utils import get_session_key_for_api_request
from pretix.api.serializers import CompatDecimalField, CompatibleJSONField
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.forms import form_field_to_serializer_field
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
InlineItemVariationSerializer, ItemSerializer, POSCompatQuestionSerializer,
)
from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal
@@ -56,8 +58,8 @@ from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
Voucher,
QuestionAnswer, QuestionnaireRow, ReusableMedium, SalesChannel, Seat,
SubEvent, TaxRule, Voucher,
)
from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -73,7 +75,8 @@ from pretix.base.services.pricing import (
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, ROUNDING_MODES,
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES,
)
from pretix.base.signals import register_ticket_outputs
from pretix.helpers.countries import CachedCountries
@@ -209,11 +212,6 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
return data
class AnswerQuestionIdentifierField(serializers.Field):
def to_representation(self, instance: QuestionAnswer):
return instance.question.identifier
class AnswerQuestionOptionsIdentifierField(serializers.Field):
def to_representation(self, instance: QuestionAnswer):
if isinstance(instance, WrappedModel) or instance.pk:
@@ -228,10 +226,106 @@ class InlineSeatSerializer(I18nAwareModelSerializer):
fields = ('id', 'name', 'seat_guid', 'zone_name', 'row_name', 'row_label', 'seat_label', 'seat_number')
class MixedAnswerListSerializer(ListSerializer):
def __init__(self, instance=None, *args, **kwargs):
self.parent_instance = instance
super().__init__(instance.answers if instance else None, *args, **kwargs)
def get_value(self, dictionary):
return dictionary
def to_representation(self, instance):
answers = super().to_representation(instance.answers)
answers_dict = {answer["question"]: answer for answer in answers}
for qc in QuestionnaireRow.objects.filter(
Q(system_datafield__isnull=False) | Q(user_datafield_id__isnull=False),
questionnaire__items__id=instance.item_id
):
if qc.user_datafield_id:
if answer := answers_dict.get(qc.user_datafield_id):
answer = dict(answer)
answer["question"] = qc.cooked_id
answer["questionnaire_id"] = qc.questionnaire_id
answer["user_datafield_id"] = qc.user_datafield_id
answers.append(answer)
elif (sys_answer := instance.get_system_answer(qc.system_datafield)) is not None:
if qc.system_datafield == QuestionnaireRow.SystemDatafield.ATTENDEE_NAME_PARTS:
name_parts = instance.attendee_name_parts
settings = self.context['event'].settings
for k, v, w, *__ in PERSON_NAME_SCHEMES.get(settings.name_scheme)['fields']:
if k in name_parts:
options = []
options_ident = []
try:
if k == "salutation":
salutation_keys = [key for key, value in PERSON_NAME_SALUTATIONS]
options = [salutation_keys.index(name_parts[k]) + 1]
options_ident = [name_parts[k]]
elif k == "title" and settings.name_scheme_titles:
options = [PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1].index(name_parts[k]) + 1]
options_ident = [name_parts[k]]
except ValueError:
pass
answers.append({
"question": QuestionnaireRow.cook_id_from_parts(qc.questionnaire_id, None, f'{qc.system_datafield}:{k}'),
"answer": name_parts[k],
"question_identifier": f'{qc.system_datafield}:{k}',
"options": options,
"option_identifiers": options_ident,
"questionnaire_id": qc.questionnaire_id,
})
answers.append({
"question": qc.cooked_id,
"answer": sys_answer,
"question_identifier": f'{qc.system_datafield}',
"options": [],
"option_identifiers": [],
"questionnaire_id": qc.questionnaire_id,
})
return answers
def to_internal_value(self, data):
result = {}
answers = []
attendee_name_parts = {}
settings = self.context['event'].settings
for answer in data["answers"]:
try:
qid, dfid, sys_df = QuestionnaireRow.uncook_id(answer["question"])
except ValueError:
if type(answer["question"]) is str and answer["question"] in QuestionnaireRow.SYSTEM_DATAFIELD_NUMBERS:
qid, dfid, sys_df = None, None, answer["question"]
else:
raise ValidationError('Invalid question ID')
if dfid:
answer["question"] = dfid
answers.append(answer)
elif sys_df == "@country":
result['country'] = Country(answer["answer"])
elif sys_df == "@attendee_name_parts:salutation":
attendee_name_parts['salutation'] = PERSON_NAME_SALUTATIONS[answer["options"][0] - 1][0]
elif sys_df == "@attendee_name_parts:title" and settings.name_scheme_titles:
attendee_name_parts['title'] = PERSON_NAME_TITLE_GROUPS.get(settings.name_scheme_titles)[1][answer["options"][0] - 1]
elif sys_df.startswith("@attendee_name_parts:"):
attendee_name_parts[sys_df[len("@attendee_name_parts:"):]] = answer["answer"]
else:
result[sys_df[1:]] = answer["answer"]
result["answers"] = super().to_internal_value(answers)
if attendee_name_parts:
result["attendee_name_parts"] = attendee_name_parts
return result
class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
datafield = serializers.SlugRelatedField(slug_field='identifier', source='question', queryset=Question.objects.none())
question_identifier = serializers.SlugRelatedField(slug_field='identifier', source='question', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if event := self.context.get('event'):
self.fields['datafield'].queryset = event.questions.filter(container_type=Question.ContainerType.ORDERPOSITION)
def to_representation(self, instance):
r = super().to_representation(instance)
if r['answer'].startswith('file://') and instance.orderposition:
@@ -245,7 +339,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
class Meta:
model = QuestionAnswer
fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers')
fields = ('datafield', 'answer', 'options', 'option_identifiers', 'question', 'question_identifier')
def validate_question(self, q):
if q.event != self.context['event']:
@@ -258,16 +352,21 @@ class AnswerSerializer(I18nAwareModelSerializer):
if data['answer'] == 'file:keep':
return data
try:
ao = self.context["request"].user or self.context["request"].auth
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(ao))}-{ao.pk}',
file__isnull=False,
pk=data['answer'][len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
# also be harmless because all files with this key are expired one day after deployment of this fix
# and no new files with this key are created
pass
elif cf.session_key != get_session_key_for_api_request(self.context["request"]):
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data['answer']))
except (ValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data['answer']))
except CachedFile.DoesNotExist:
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data['answer']))
allowed_types = (
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
@@ -585,7 +684,7 @@ class OrderPositionPluginDataField(serializers.Field):
class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = InlineCheckinSerializer(many=True, read_only=True)
print_logs = PrintLogSerializer(many=True, read_only=True)
answers = AnswerSerializer(many=True)
answers = MixedAnswerListSerializer(child=AnswerSerializer(), source='*')
downloads = PositionDownloadsField(source='*', read_only=True)
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*', read_only=True)
@@ -686,6 +785,7 @@ class AttendeeNamePartsField(serializers.Field):
class CheckinListOrderPositionSerializer(OrderPositionSerializer):
require_attention = RequireAttentionField(source='*')
answers = AnswerSerializer(many=True)
attendee_name = AttendeeNameField(source='*')
attendee_name_parts = AttendeeNamePartsField(source='*')
order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order')
@@ -715,7 +815,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
self.fields['variation'] = InlineItemVariationSerializer(read_only=True, context=self.context)
if 'answers.question' in self.context['expand']:
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
self.fields['answers'].child.fields['question'] = POSCompatQuestionSerializer(read_only=True) # TODO(questionnaires)
if 'addons' in self.context['expand']:
# Experimental feature, undocumented on purpose for now in case we need to remove it again
@@ -1035,7 +1135,7 @@ class OrderFeeCreateSerializer(I18nAwareModelSerializer):
class OrderPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
answers = MixedAnswerListSerializer(child=AnswerCreateSerializer(required=False), source='*', 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)
+4 -3
View File
@@ -30,7 +30,8 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField,
OrderFeeCreateSerializer, OrderPositionCreateSerializer,
MixedAnswerListSerializer, OrderFeeCreateSerializer,
OrderPositionCreateSerializer,
)
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
from pretix.base.services.orders import OrderChangeManager, OrderError
@@ -41,7 +42,7 @@ 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)
answers = MixedAnswerListSerializer(child=AnswerCreateSerializer(required=False), source='*', 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)
@@ -154,7 +155,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
answers = AnswerSerializer(many=True)
answers = MixedAnswerListSerializer(child=AnswerSerializer(), source='*')
country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False)
+98 -4
View File
@@ -26,8 +26,10 @@ from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Q
from django.templatetags.static import static
from django.utils.crypto import get_random_string
from django.utils.translation import gettext, gettext_lazy as _
from i18nfield.rest_framework import I18nField
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@@ -40,9 +42,10 @@ from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.auth import get_auth_backends
from pretix.base.i18n import get_language_without_region
from pretix.base.models import (
Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
Customer, Device, EventMetaProperty, GiftCard, GiftCardAcceptance,
GiftCardTransaction, Membership, MembershipType, OrderPosition, Organizer,
ReusableMedium, SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite,
User,
)
from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.permissions import (
@@ -217,10 +220,12 @@ class FlexibleTicketRelatedField(serializers.PrimaryKeyRelatedField):
class SalesChannelSerializer(I18nAwareModelSerializer):
type = serializers.CharField(default="api")
icon_url = serializers.SerializerMethodField()
class Meta:
model = SalesChannel
fields = ('identifier', 'type', 'label', 'position')
fields = ('identifier', 'type', 'label', 'position', 'icon', 'icon_url')
read_only_fields = ('icon',)
def validate_type(self, value):
if (not self.instance or not self.instance.pk) and value != "api":
@@ -244,6 +249,10 @@ class SalesChannelSerializer(I18nAwareModelSerializer):
)
return value
def get_icon_url(self, instance):
if '.' in instance.icon:
return static(instance.icon)
class GiftCardSerializer(I18nAwareModelSerializer):
value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00'))
@@ -640,3 +649,88 @@ class OrganizerSettingsSerializer(SettingsSerializer):
)
# TODO: make sure pub is always correct
return 'pub/' + fname
class MetaPropertyListField(serializers.ListField):
def __init__(self, *args, **kwargs):
kwargs["validators"] = kwargs.pop("validators", [])
def validate_keys_unique(choices):
if not choices:
return
keys = [c.get("key") for c in choices]
if len(set(keys)) < len(keys):
raise ValidationError("The key for each meta property value option must be unique.")
kwargs["validators"].append(
validate_keys_unique
)
super().__init__(*args, **kwargs)
class MetaPropertyDictField(serializers.DictField):
def __init__(self, **kwargs):
self.label_child = kwargs.pop("label_child", I18nField())
super().__init__(**kwargs)
def to_representation(self, value):
# django added unneccessary keys DELETE, ORDER through formsets, filter them here for backwards compat
d = {
"key": value["key"]
}
if "label" in value:
d["label"] = self.label_child.to_representation(value["label"])
return super().to_representation(d)
def to_internal_value(self, data):
if not isinstance(data, dict):
raise ValidationError("Meta property value options must be a dict.")
if not isinstance(data.get("key"), str):
raise ValidationError("Meta property value options must have a key of type string.")
if any(k not in {"key", "label"} for k in data.keys()):
raise ValidationError("Meta property value options may only have a key and optionally a label.")
if "label" in data:
try:
data["label"] = self.label_child.to_internal_value(data["label"])
except ValidationError as e:
raise ValidationError({"label": e.detail})
return super().to_internal_value(data)
class EventMetaPropertiesSerializer(I18nAwareModelSerializer):
choices = MetaPropertyListField(
child=MetaPropertyDictField(
label_child=I18nField()
),
allow_null=True,
)
class Meta:
model = EventMetaProperty
fields = (
'id', 'name', 'default', 'required', 'protected', 'filter_public', 'public_label', 'filter_allowed',
'choices'
)
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)
choices = full_data.get("choices")
default = full_data.get("default")
if choices and default:
choice_keys = [c.get("key") for c in choices]
if default not in choice_keys:
raise ValidationError("You cannot set a default value that is not a valid value.")
if not choices and "choices" in data:
# normalize empty dict to None
data["choices"] = None
return data
+15 -4
View File
@@ -68,6 +68,7 @@ orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportVie
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
orga_router.register(r'transactions', order.OrganizerTransactionViewSet)
orga_router.register(r'orderpositions', order.OrganizerOrderPositionViewSet, basename='orderpositions')
orga_router.register(r'event_meta_properties', organizer.EventMetaPropertiesViewSet)
team_router = routers.DefaultRouter()
team_router.register(r'members', organizer.TeamMemberViewSet)
@@ -79,7 +80,10 @@ event_router.register(r'subevents', event.SubEventViewSet)
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'questions', item.CompatQuestionViewSet, basename='compatquestion')
event_router.register(r'datafields/position', item.PositionDatafieldViewSet, basename='positiondatafield')
event_router.register(r'datafields/order', item.OrderDatafieldViewSet, basename='orderdatafield')
event_router.register(r'questionnaires', item.QuestionnaireViewSet)
event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet)
@@ -106,8 +110,11 @@ subevent_router.register(r'seats', event.SeatViewSet)
checkinlist_router = routers.DefaultRouter()
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')
question_router = routers.DefaultRouter()
question_router.register(r'options', item.QuestionOptionViewSet)
orderdatafield_router = routers.DefaultRouter()
orderdatafield_router.register(r'options', item.OrderDatafieldOptionViewSet)
positiondatafield_router = routers.DefaultRouter()
positiondatafield_router.register(r'options', item.PositionDatafieldOptionViewSet)
item_router = routers.DefaultRouter()
item_router.register(r'variations', item.ItemVariationViewSet)
@@ -147,7 +154,11 @@ urlpatterns = [
re_path(r'^organizers/(?P<organizer>[^/]+)/teams/(?P<team>[^/]+)/', include(team_router.urls)),
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/items/(?P<item>[^/]+)/', include(item_router.urls)),
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/questions/(?P<question>[^/]+)/',
include(question_router.urls)),
include(positiondatafield_router.urls)),
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/datafields/order/(?P<question>[^/]+)/',
include(orderdatafield_router.urls)),
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/datafields/position/(?P<question>[^/]+)/',
include(positiondatafield_router.urls)),
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/checkinlists/(?P<list>[^/]+)/',
include(checkinlist_router.urls)),
re_path(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/orders/(?P<order>[^/]+)/', include(order_router.urls)),
+25 -13
View File
@@ -50,11 +50,12 @@ from rest_framework.generics import ListAPIView
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response
from pretix.api.auth.utils import get_session_key_for_api_auth
from pretix.api.serializers.checkin import (
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
)
from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.item import POSCompatQuestionSerializer
from pretix.api.serializers.order import (
CheckinListOrderPositionSerializer, CheckinSerializer,
FailedCheckinSerializer,
@@ -64,7 +65,8 @@ from pretix.api.views.order import OrderPositionFilter
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
Question, Questionnaire, QuestionnaireRow, ReusableMedium,
RevokedTicketSecret, TeamAPIToken,
)
from pretix.base.models.orders import PrintLog
from pretix.base.permissions import AnyPermissionOf
@@ -331,10 +333,16 @@ with scopes_disabled():
def _handle_file_upload(data, user, auth):
try:
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
file__isnull=False,
pk=data[len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
# also be harmless because all files with this key are expired one day after deployment of this fix
# and no new files with this key are created
pass
elif cf.session_key != get_session_key_for_api_auth(user, auth):
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except (ValidationError, BaseValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except CachedFile.DoesNotExist:
@@ -482,6 +490,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'pdf_data': pdf_data and (
user if user and user.is_authenticated else auth
).has_event_permission(request.organizer, event, 'event.orders:read', request),
'preferred_questionnaire_type': Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN,
}
common_checkin_args = dict(
@@ -790,16 +799,19 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
# 5. Pre-validate all incoming answers, handle file upload
given_answers = {}
if answers_data:
for q in op.item.questions.filter(ask_during_checkin=True):
if str(q.pk) in answers_data:
for qc in QuestionnaireRow.objects.filter(
questionnaire__items__in=[op.item], questionnaire__type=Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN
):
if qc.user_datafield_id and (str(qc.user_datafield_id) in answers_data or str(qc.cooked_id) in answers_data):
try:
if q.type == Question.TYPE_FILE:
if answers_data[str(q.pk)]:
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
ans = answers_data.get(str(qc.user_datafield_id), answers_data[str(qc.cooked_id)])
if qc.user_datafield.type == Question.TYPE_FILE:
if ans:
given_answers[qc.user_datafield] = _handle_file_upload(ans, user, auth)
else:
given_answers[q] = None
given_answers[qc.user_datafield] = None
else:
given_answers[q] = q.clean_answer(answers_data[str(q.pk)])
given_answers[qc.user_datafield] = qc.user_datafield.clean_answer(ans)
except (ValidationError, BaseValidationError):
pass
@@ -860,14 +872,13 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
else:
perform_checkin(**checkin_args)
except RequiredQuestionsError as e:
qs = POSCompatQuestionSerializer(context={'event': op.order.event})
return Response({
'status': 'incomplete',
'require_attention': op.require_checkin_attention,
'checkin_texts': op.checkin_texts,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
'questions': [
QuestionSerializer(q).data for q in e.questions
],
'questions': POSCompatQuestionSerializer.mangle_results(op.order.event, (qs.to_representation(q) for q in e.questions)), # TODO(questionnaires)
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400)
except RequiredMediaExchangeError as e:
@@ -966,6 +977,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
ctx['event'] = self.request.event
ctx['expand'] = self.request.query_params.getlist('expand')
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
ctx['preferred_questionnaire_type'] = Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN
return ctx
def get_filterset_kwargs(self):
+438 -23
View File
@@ -32,6 +32,9 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import logging
from typing import Mapping
import django_filters
from django.db import transaction
from django.db.models import Q
@@ -39,26 +42,32 @@ from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
from rest_framework import generics, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.response import Response
from pretix.api.exceptions import CompatException
from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
CompatQuestionSerializer, DatafieldSerializer, ItemAddOnSerializer,
ItemBundleSerializer, ItemCategorySerializer, ItemProgramTimeSerializer,
ItemSerializer, ItemVariationSerializer, POSCompatQuestionSerializer,
QuestionFormSerializer, QuestionnaireSerializer, QuestionOptionSerializer, QuotaSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
ItemVariation, Question, QuestionOption, Quota,
CartPosition, Device, Item, ItemAddOn, ItemBundle, ItemCategory,
ItemProgramTime, ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import Questionnaire, QuestionnaireRow
from pretix.base.services.quotas import QuotaAvailability
from pretix.helpers.dicts import merge_dicts
from pretix.helpers.i18n import i18ncomp
logger = logging.getLogger(__name__)
with scopes_disabled():
class ItemFilter(FilterSet):
tax_rate = django_filters.CharFilter(method='tax_rate_qs')
@@ -458,27 +467,270 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
with scopes_disabled():
class QuestionFilter(FilterSet):
class DatafieldFilter(FilterSet):
class Meta:
model = Question
fields = ['ask_during_checkin', 'required', 'identifier']
fields = ['identifier']
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionSerializer
class BaseDatafieldViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = DatafieldSerializer
queryset = Question.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
filterset_class = QuestionFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
filterset_class = DatafieldFilter
ordering_fields = ('id')
ordering = ('id')
permission = None
write_permission = 'event.items:write'
def get_queryset(self):
return self.request.event.questions.filter(
# the container_type parameter is undocumented, this API is going to change in a later release
container_type=self.request.GET.get('container_type', Question.ContainerType.ORDERPOSITION),
).prefetch_related('options').all()
return self.request.event.questions.filter(container_type=self.container_type).prefetch_related('options')
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
lookup_value = self.kwargs[lookup_url_kwarg]
try:
numeric_id = int(lookup_value)
except ValueError:
obj = get_object_or_404(queryset, identifier=lookup_value)
else:
qid, dfid, sys_df = QuestionnaireRow.uncook_id(numeric_id)
self.via_questionnaire_id = qid
obj = get_object_or_404(queryset, pk=dfid)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['container_type'] = self.container_type
return ctx
class BaseWritableDatafieldViewSet(BaseDatafieldViewSet, viewsets.ModelViewSet):
write_permission = 'event.items:write'
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.question.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.question.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
'pretix.event.question.deleted',
user=self.request.user,
auth=self.request.auth,
)
super().perform_destroy(instance)
class PositionDatafieldViewSet(ConditionalListView, BaseWritableDatafieldViewSet):
container_type = Question.ContainerType.ORDERPOSITION
class OrderDatafieldViewSet(ConditionalListView, BaseWritableDatafieldViewSet):
container_type = Question.ContainerType.ORDER
def ordered_union(*lists):
from graphlib import TopologicalSorter
items_set = {item for lst in lists for __ignore, item in lst}
dependencies = {item: set() for item in items_set}
for lst in lists:
for i in range(1, len(lst)):
current_item = lst[i][1]
previous_item = lst[i - 1][1]
dependencies[current_item].add(previous_item)
ts = TopologicalSorter(dependencies)
try:
return list(ts.static_order())
except ValueError:
raise CompatException("Questionnaire configuration incompatible with old data model due to conflicting field orders.")
class DeprecationWarningMixin:
def finalize_response(self, request, response, *args, **kwargs):
if self.deprecation_warning and isinstance(getattr(response, 'data', None), Mapping) and not 'x-deprecated' in response.headers:
response.data = {
'deprecation_warning': self.deprecation_warning,
**response.data
}
response.headers['x-deprecated'] = self.deprecation_warning
return super().finalize_response(request, response, *args, **kwargs)
class CompatListMixin(DeprecationWarningMixin):
deprecation_warning = (
f'This API endpoint is deprecated and will be removed in the future. It only works as long as '
f'your event\'s questionnaire configuration is compatible with the old data model. '
f'Please switch to the .../datafields/ and .../questionnaires/ endpoints.'
)
@property
def ignore_errors(self):
return self.request.query_params.get('ignore_errors', '') == 'true'
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['ignore_errors'] = self.ignore_errors
return ctx
def do_the_topo_sort(self, queryset):
products = {}
for df in queryset:
for qc in df.referenced_by.all():
for item_id in qc.questionnaire.items.values_list('pk', flat=True):
k = (qc.questionnaire.type, item_id)
products[k] = products.get(k, []) + [((qc.questionnaire.position, qc.position), df)]
for q in products.values():
q.sort()
try:
result = ordered_union(*products.values())
for i, d in enumerate(result):
d.position = i
except ValueError:
if self.ignore_errors:
pass
else:
raise
return result
def list(self, request, **kwargs):
logger.info('Returning compatible datafield list on %s/%s', request.organizer.slug, request.event.slug)
queryset = self.filter_queryset(self.get_queryset())
queryset = self.do_the_topo_sort(queryset)
serializer = self.get_serializer(queryset, many=True)
resultlist = serializer.data
return Response({
'count': len(resultlist), 'next': None, 'previous': None,
'results': resultlist,
})
with scopes_disabled():
class CompatDatafieldFilter(FilterSet):
identifier = django_filters.CharFilter(field_name='question__identifier')
ask_during_checkin = django_filters.BooleanFilter(method='ask_during_checkin_qs')
class Meta:
model = QuestionnaireRow
fields = ['required']
def ask_during_checkin_qs(self, qs, name, value):
return qs.filter(
questionnaire__type=Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN if value else Questionnaire.QuestionnaireType.ORDER_POSITION_SALE
)
class CompatQuestionViewSet(ConditionalListView, CompatListMixin, BaseDatafieldViewSet):
container_type = Question.ContainerType.ORDERPOSITION
serializer_class = CompatQuestionSerializer
filterset_class = CompatDatafieldFilter
pagination_class = None
def get_queryset(self):
return super().get_queryset().prefetch_related('referenced_by', 'referenced_by__questionnaire')
def _check_defer(self, mode, request, *args, **kwargs):
sales_channel_ident = request.query_params.get('sales_channel', None)
if sales_channel_ident is None and isinstance(request.auth, Device):
sales_channel_ident = 'pretixpos' if request.auth.software_brand.startswith("pretixPOS") else 'web'
if sales_channel_ident:
logger.info('Returning app compatible question %s for %s (device: %r %s %s) on %s/%s', mode, sales_channel_ident, request.auth, getattr(request.auth, 'software_brand', ''), getattr(request.auth, 'software_version', ''), request.organizer.slug, request.event.slug)
return POSCompatQuestionViewSet.as_view(
{'get': mode}, sales_channel_ident=sales_channel_ident
)(*self.args, request=request._request, **self.kwargs)
def list(self, request, *args, **kwargs):
if response := self._check_defer('list', request, *args, **kwargs):
return response
logger.info('Returning compatible datafield list on %s/%s', request.organizer.slug, request.event.slug)
return super().list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
if response := self._check_defer('retrieve', request, *args, **kwargs):
return response
return super().retrieve(request, *args, **kwargs)
class POSCompatQuestionViewSet(DeprecationWarningMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = POSCompatQuestionSerializer
queryset = QuestionnaireRow.objects.none()
permission = None
sales_channel_ident = None
def __init__(self, sales_channel_ident, *args, **kwargs):
self.sales_channel_ident = sales_channel_ident
super().__init__(*args, **kwargs)
@property
def deprecation_warning(self):
return (
f'This API endpoint is deprecated and will be removed in the future. '
f'Results are generated on-the-fly, count and page size may be inaccurate. '
f'Filtered for sales_channel "{self.sales_channel_ident}"'
)
def get_queryset(self):
return QuestionnaireRow.objects.filter(
# only matching sales channel
Q(questionnaire__all_sales_channels=True) | Q(questionnaire__limit_sales_channels__identifier=self.sales_channel_ident),
# only real questions, no text blocks
Q(user_datafield_id__isnull=False) | Q(system_datafield__isnull=False),
questionnaire__event=self.request.event,
# we do legacy compat only for position-level questions
questionnaire__type__startswith='P',
).prefetch_related(
'user_datafield', 'user_datafield__options', 'questionnaire'
).order_by('questionnaire__position', 'position', 'id')
def list(self, request, **kwargs):
resp = super().list(request, **kwargs)
resp.data['results'] = POSCompatQuestionSerializer.mangle_results(self.request.event, resp.data['results'])
return resp
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
qid, dfid, sys_df = QuestionnaireRow.uncook_id(self.kwargs[lookup_url_kwarg])
print(self.kwargs[lookup_url_kwarg], qid, dfid, sys_df)
if qid and sys_df:
obj = get_object_or_404(queryset, questionnaire__pk=qid, system_datafield=sys_df)
elif qid and dfid:
obj = get_object_or_404(queryset, questionnaire__pk=qid, user_datafield__pk=dfid)
else:
obj = get_object_or_404(queryset, user_datafield__pk=dfid)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
@transaction.atomic()
def perform_create(self, serializer):
@@ -515,7 +767,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
super().perform_destroy(instance)
class QuestionOptionViewSet(viewsets.ModelViewSet):
class BaseDatafieldOptionViewSet(viewsets.ModelViewSet):
serializer_class = QuestionOptionSerializer
queryset = QuestionOption.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter,)
@@ -524,19 +776,25 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
permission = None
write_permission = 'event.items:write'
def get_question(self):
return get_object_or_404(
Question,
Q(pk=self.kwargs['question']) | Q(identifier=self.kwargs['question']),
event=self.request.event, container_type=self.container_type,
)
def get_queryset(self):
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
return q.options.all()
return self.get_question().options.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['question'] = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
ctx['question'] = self.get_question()
return ctx
@transaction.atomic()
def perform_create(self, serializer):
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
q = self.get_question()
serializer.save(question=q)
q.log_action(
'pretix.event.question.option.added',
@@ -566,6 +824,163 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
class OrderDatafieldOptionViewSet(BaseDatafieldOptionViewSet):
container_type = Question.ContainerType.ORDER
class PositionDatafieldOptionViewSet(BaseDatafieldOptionViewSet):
container_type = Question.ContainerType.ORDERPOSITION
with scopes_disabled():
class QuestionnaireFilter(FilterSet):
sales_channel = django_filters.CharFilter(method='sales_channel_qs')
class Meta:
model = Questionnaire
fields = ['type', 'items', 'sales_channel']
def sales_channel_qs(self, qs, name, value):
return qs.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=value)
)
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionnaireSerializer
queryset = Questionnaire.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
filterset_class = QuestionnaireFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = None
write_permission = 'event.items:write'
def get_queryset(self):
return self.request.event.questionnaires.prefetch_related('rows').all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.questionnaire.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.questionnaire.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def perform_destroy(self, instance):
instance.log_action(
'pretix.event.questionnaire.deleted',
user=self.request.user,
auth=self.request.auth,
)
super().perform_destroy(instance)
class QuestionFormView(generics.GenericAPIView):
# like POSCompatQuestionViewSet, but less compat
serializer_class = QuestionFormSerializer
queryset = QuestionnaireRow.objects.none()
permission = None
sales_channel_ident = None
def __init__(self, sales_channel_ident, *args, **kwargs):
self.sales_channel_ident = sales_channel_ident
super().__init__(*args, **kwargs)
@property
def deprecation_warning(self):
return (
f'This API endpoint is deprecated and will be removed in the future. '
f'Results are generated on-the-fly, count and page size may be inaccurate. '
f'Filtered for sales_channel "{self.sales_channel_ident}"'
)
def get_queryset(self):
return QuestionnaireRow.objects.filter(
Q(questionnaire__all_sales_channels=True) | Q(questionnaire__limit_sales_channels__identifier=self.sales_channel_ident),
questionnaire__event=self.request.event,
# legacy compat only for position-level questions
questionnaire__type__startswith='P',
).prefetch_related(
'user_datafield', 'user_datafield__options', 'questionnaire'
).order_by('questionnaire__position', 'position', 'id')
def list(self, request, **kwargs):
resp = super().list(request, **kwargs)
resp.data['results'] = POSCompatQuestionSerializer.mangle_results(self.request.event, resp.data['results'])
return resp
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
qid, dfid, sys_df = QuestionnaireRow.uncook_id(self.kwargs[lookup_url_kwarg])
print(self.kwargs[lookup_url_kwarg], qid, dfid, sys_df)
if qid and sys_df:
obj = get_object_or_404(queryset, questionnaire__pk=qid, system_datafield=sys_df)
elif qid and dfid:
obj = get_object_or_404(queryset, questionnaire__pk=qid, user_datafield__pk=dfid)
else:
obj = get_object_or_404(queryset, user_datafield__pk=dfid)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.question.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
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.event.question.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
'pretix.event.question.deleted',
user=self.request.user,
auth=self.request.auth,
)
super().perform_destroy(instance)
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
pass
+4 -2
View File
@@ -256,7 +256,7 @@ class OrderViewSetMixin:
[request.event],
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'),
'questions',
'questionnaires',
'item_meta_properties',
)
return Prefetch(
@@ -287,7 +287,9 @@ class OrderViewSetMixin:
Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'item', 'variation',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')),
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question')
# TODO(questionnaires) .order_by('question__position')
),
'seat',
)
)
+51 -4
View File
@@ -44,15 +44,16 @@ from pretix.api.models import OAuthAccessToken
from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.organizer import (
CustomerCreateSerializer, CustomerSerializer, DeviceSerializer,
GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer,
EventMetaPropertiesSerializer, GiftCardSerializer,
GiftCardTransactionSerializer, MembershipSerializer,
MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer,
SalesChannelSerializer, SeatingPlanSerializer, TeamAPITokenSerializer,
TeamInviteSerializer, TeamMemberSerializer, TeamSerializer,
)
from pretix.base.models import (
Customer, Device, Event, GiftCard, GiftCardTransaction, LogEntry,
Membership, MembershipType, Organizer, SalesChannel, SeatingPlan, Team,
TeamAPIToken, TeamInvite, User,
Customer, Device, Event, EventMetaProperty, GiftCard, GiftCardTransaction,
LogEntry, Membership, MembershipType, Organizer, SalesChannel, SeatingPlan,
Team, TeamAPIToken, TeamInvite, User,
)
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
@@ -846,3 +847,49 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
data={'id': instance.pk}
)
instance.delete()
class EventMetaPropertiesViewSet(viewsets.ModelViewSet):
serializer_class = EventMetaPropertiesSerializer
queryset = EventMetaProperty.objects.none()
write_permission = 'organizer.settings.general:write'
def get_queryset(self):
return self.request.organizer.meta_properties.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
'pretix.property.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(organizer_id=self.request.organizer.pk)
serializer.instance.log_action(
'pretix.property.created',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save(organizer_id=self.request.organizer.pk)
serializer.instance.log_action(
'pretix.property.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
+2 -1
View File
@@ -33,6 +33,7 @@ from rest_framework.views import APIView
from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
from pretix.api.auth.token import TeamTokenAuthentication
from pretix.api.auth.utils import get_session_key_for_api_request
from pretix.base.models import CachedFile
from pretix.helpers.images import (
IMAGE_TYPES, validate_uploaded_file_for_valid_image,
@@ -78,7 +79,7 @@ class UploadView(APIView):
web_download=False,
filename=file_obj.name,
type=content_type,
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}'
session_key=get_session_key_for_api_request(request)
)
cf.file.save(file_obj.name, file_obj)
cf.save()
+4
View File
@@ -40,6 +40,7 @@ with scopes_disabled():
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
code = CharFilter(lookup_expr='iexact')
search = CharFilter(method='search_qs')
class Meta:
model = Voucher
@@ -54,6 +55,9 @@ with scopes_disabled():
return queryset.filter(Q(redeemed__gte=F('max_usages')) |
(Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
def search_qs(self, qs, name, value):
return qs.filter(Q(code__icontains=value) | Q(tag__icontains=value) | Q(comment__icontains=value))
class VoucherViewSet(viewsets.ModelViewSet):
serializer_class = VoucherSerializer
+7 -3
View File
@@ -27,7 +27,7 @@ from datetime import timedelta
from functools import cached_property
from typing import List, Optional, Protocol
import sentry_sdk
from django.conf import settings
from django.db import DatabaseError, transaction
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -236,7 +236,9 @@ class OutboundSyncProvider:
# model changes saved by set_sync_error / clear_in_flight calls below
if sq.failed_attempts >= self.max_attempts:
logger.exception('Failed to sync order (max attempts exceeded)')
sentry_sdk.capture_exception(e)
if settings.SENTRY_ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
sq.set_sync_error("exceeded", e.messages, e.full_message)
else:
logger.info(
@@ -247,7 +249,9 @@ class OutboundSyncProvider:
sq.clear_in_flight()
except Exception as e:
logger.exception('Failed to sync order (unhandled exception)')
sentry_sdk.capture_exception(e)
if settings.SENTRY_ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
sq.set_sync_error("internal", [], str(e))
@cached_property
+45 -8
View File
@@ -31,7 +31,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.multidomain.urlreverse import eventreverse_absolute
def get_answer(op, question_identifier=None):
def get_position_answer(op, question_identifier=None):
a = None
if op.addon_to:
if "answers" in getattr(op.addon_to, "_prefetched_objects_cache", {}):
@@ -70,6 +70,30 @@ def get_answer(op, question_identifier=None):
return a.answer
def get_order_answer(order, question_identifier=None):
a = None
if "answers" in getattr(order, "_prefetched_objects_cache", {}):
try:
a = [
a
for a in order.answers.all()
if a.question.identifier == question_identifier
][0]
except IndexError:
pass
else:
a = order.answers.filter(question__identifier=question_identifier).first()
if not a:
return ""
else:
if a.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
return [str(o.identifier) for o in a.options.all()]
if a.question.type == Question.TYPE_BOOLEAN:
return a.answer == "True"
return a.answer
def get_payment_date(order):
if order.status == Order.STATUS_PENDING:
return None
@@ -127,10 +151,11 @@ DataFieldCategory = namedtuple(
CAT_ORDER_POSITION = DataFieldCategory(10, _('Order position details'))
CAT_ATTENDEE = DataFieldCategory(11, _('Attendee details'))
CAT_QUESTIONS = DataFieldCategory(12, _('Questions'))
CAT_ORDER_POSITION_DATAFIELDS = DataFieldCategory(12, _('Order position data fields'))
CAT_PRODUCT = DataFieldCategory(20, _('Product details'))
CAT_ORDER = DataFieldCategory(21, _('Order details'))
CAT_INVOICE_ADDRESS = DataFieldCategory(22, _('Invoice address'))
CAT_ORDER_DATAFIELDS = DataFieldCategory(22, _('Order data fields'))
CAT_INVOICE_ADDRESS = DataFieldCategory(23, _('Invoice address'))
CAT_EVENT = DataFieldCategory(30, _('Event information'))
CAT_EVENT_OR_SUBEVENT = DataFieldCategory(31, pgettext_lazy('subevent', 'Event or date information'))
@@ -584,15 +609,27 @@ def get_data_fields(event, for_model=None):
]
+ [
DataFieldInfo(
ORDER_POSITION,
CAT_QUESTIONS,
ORDER,
CAT_ORDER_POSITION_DATAFIELDS,
"question_" + q.identifier,
_("Question: {name}").format(name=str(q.question)),
_("Data field: {name}").format(name=str(q.question)),
q.type,
get_enum_opts(q),
partial(lambda qq, position: get_answer(position, qq.identifier), q),
partial(lambda qq, position: get_position_answer(position, qq.identifier), q),
)
for q in event.questions.filter(~Q(type=Question.TYPE_FILE)).prefetch_related("options")
for q in event.questions.filter(~Q(type=Question.TYPE_FILE), container_type=Question.ContainerType.ORDERPOSITION).prefetch_related("options")
]
+ [
DataFieldInfo(
ORDER,
CAT_ORDER_DATAFIELDS,
"orderdatafield_" + q.identifier,
_("Order data field: {name}").format(name=str(q.question)),
q.type,
get_enum_opts(q),
partial(lambda qq, order: get_order_answer(order, qq.identifier), q),
)
for q in event.questions.filter(~Q(type=Question.TYPE_FILE), container_type=Question.ContainerType.ORDER).prefetch_related("options")
]
)
if not any(field_name == "given_name" for field_name, label, weight in name_scheme["fields"]):
+27 -4
View File
@@ -54,6 +54,7 @@ from ...control.forms.filter import get_all_payment_providers
from ...helpers import GroupConcat
from ...helpers.iter import chunked_iterable
from ..exporter import BaseExporter, MultiSheetListExporter
from ..invoicing.transmission import get_transmission_types
from ..services.export import ExportError
from ..services.invoices import invoice_pdf_task
from ..signals import (
@@ -197,7 +198,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
def iterate_sheet(self, form_data, sheet):
_ = gettext
if sheet == 'invoices':
yield [
headers = [
_('Invoice number'),
_('Date'),
_('Order code'),
@@ -230,8 +231,18 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Total value (without taxes)'),
_('Payment matching IDs'),
_('Payment providers'),
_('Transmission type'),
_('Transmission status'),
_('Transmission date'),
]
transmission_types = get_transmission_types()
for tt in transmission_types:
for c in tt.describe_info_columns():
headers.append(str(tt.verbose_name) + ': ' + str(c))
yield headers
p_providers = OrderPayment.objects.filter(
order=OuterRef('order'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
@@ -242,7 +253,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
'm'
).order_by()
base_qs = self.invoices_queryset(form_data)\
base_qs = self.invoices_queryset(form_data)
qs = base_qs.select_related(
'order', 'refers'
@@ -280,7 +291,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
if mid:
pmis.append(mid)
pmi = '\n'.join(pmis)
yield [
line = [
i.full_invoice_no,
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
@@ -315,8 +326,20 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
', '.join([
str(self.providers.get(p, p)) for p in sorted(set((i.payment_providers or '').split(',')))
if p and p != 'free'
])
]),
i.transmission_type_instance.verbose_name,
i.get_transmission_status_display(),
date_format(i.transmission_date, "SHORT_DATETIME_FORMAT") if i.transmission_date else "",
]
for tt in transmission_types:
if tt.identifier == i.transmission_type:
described = dict(tt.describe_info(i.invoice_to_transmission_info, i.invoice_to_country, i.invoice_to_is_business))
for c in tt.describe_info_columns():
line.append(described.get(c, ""))
else:
for c in tt.describe_info_columns():
line.append("")
yield line
elif sheet == 'lines':
yield [
_('Invoice number'),
+176 -167
View File
@@ -36,7 +36,6 @@ import copy
import json
import logging
import re
from collections import namedtuple
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
@@ -50,7 +49,8 @@ from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db.models import QuerySet
from django.db import ProgrammingError
from django.db.models import Prefetch, Q, QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format
@@ -82,7 +82,10 @@ from pretix.base.i18n import (
from pretix.base.invoicing.transmission import (
get_transmission_types, transmission_types,
)
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models import (
InvoiceAddress, Item, Question, Questionnaire, QuestionnaireRow,
QuestionOption,
)
from pretix.base.models.tax import ask_for_vat_id
from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id,
@@ -600,12 +603,16 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
image = ImageOps.exif_transpose(image)
if f._cropdata:
image = image.crop((
f._cropdata.get('x', 0),
f._cropdata.get('y', 0),
f._cropdata.get('x', 0) + f._cropdata.get('width', image.width),
f._cropdata.get('y', 0) + f._cropdata.get('height', image.height),
))
left = int(f._cropdata.get('x', 0))
top = int(f._cropdata.get('y', 0))
right = left + int(f._cropdata.get('width', image.width))
bottom = top + int(f._cropdata.get('height', image.height))
if left >= image.width or top >= image.height or right > image.width or bottom > image.height:
raise ValidationError(
self.error_messages['max_dimension'],
code='max_dimension',
)
image = image.crop((left, top, right, bottom))
with BytesIO() as output:
# This might use a lot of memory, but temporary files are not a good option since
# we don't control the cleanup
@@ -643,49 +650,22 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
super().__init__(*args, **kwargs)
FakeQuestion = namedtuple(
'FakeQuestion', 'id question position required help_text container_type', defaults=('', Question.ContainerType.ORDERPOSITION)
)
def get_fake_attendee_questions(settings):
fq = []
sqo = settings.system_question_order
if settings.attendee_names_asked:
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), settings.attendee_names_required))
if settings.attendee_emails_asked:
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), settings.attendee_emails_required))
if settings.attendee_company_asked:
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), settings.attendee_company_required))
if settings.attendee_addresses_asked:
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('state', _('State'), sqo.get('country', 0), settings.attendee_addresses_required))
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), settings.attendee_addresses_required))
return fq
class BaseQuestionsForm(forms.Form):
"""
This is the base form class responsible for asking order- or ticket-related questions.
"""
address_validation = False
def build_user_question_field(self, request, event, answerlist, q):
def build_user_question_field(self, request, event, answerlist, qc, datafield):
# Do we already have an answer? Provide it as the initial value
answers = [a for a in answerlist if a.question_id == q.id]
answers = [a for a in answerlist if a.question_id == datafield.id]
if answers:
initial = answers[0]
else:
initial = None
tz = ZoneInfo(event.settings.timezone)
required = q.required and not self.all_optional
if q.type == Question.TYPE_BOOLEAN:
required = qc.required and not self.all_optional
if datafield.type == Question.TYPE_BOOLEAN:
if required:
# For some reason, django-bootstrap3 does not set the required attribute
# itself.
@@ -699,107 +679,105 @@ class BaseQuestionsForm(forms.Form):
initialbool = False
field = forms.BooleanField(
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
initial=initialbool, widget=widget,
)
elif q.type == Question.TYPE_NUMBER:
elif datafield.type == Question.TYPE_NUMBER:
field = forms.DecimalField(
label=escape(q.question), required=required,
min_value=q.valid_number_min or Decimal('0.00'),
max_value=q.valid_number_max,
help_text=rich_text(q.help_text),
label=escape(qc.label), required=required,
min_value=datafield.valid_number_min or Decimal('0.00'),
max_value=datafield.valid_number_max,
help_text=rich_text(qc.help_text),
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_STRING:
elif datafield.type == Question.TYPE_STRING:
field = forms.CharField(
label=escape(q.question), required=required,
min_length=q.valid_string_length_min,
max_length=q.valid_string_length_max,
help_text=rich_text(q.help_text),
label=escape(qc.label), required=required,
min_length=datafield.valid_string_length_min,
max_length=datafield.valid_string_length_max,
help_text=rich_text(qc.help_text),
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_TEXT:
elif datafield.type == Question.TYPE_TEXT:
field = forms.CharField(
label=escape(q.question), required=required,
min_length=q.valid_string_length_min,
max_length=q.valid_string_length_max,
help_text=rich_text(q.help_text),
label=escape(qc.label), required=required,
min_length=datafield.valid_string_length_min,
max_length=datafield.valid_string_length_max,
help_text=rich_text(qc.help_text),
widget=forms.Textarea,
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_COUNTRYCODE:
elif datafield.type == Question.TYPE_COUNTRYCODE:
field = CountryField(
countries=CachedCountries,
blank=True, null=True, blank_label=' ',
).formfield(
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
widget=forms.Select,
empty_label=' ',
initial=initial.answer if initial else (
guess_country_from_request(request, event) if required else None),
initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None),
)
elif q.type == Question.TYPE_CHOICE:
elif datafield.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField(
queryset=q.options,
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
queryset=datafield.options,
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
widget=forms.Select,
to_field_name='identifier',
empty_label='',
initial=initial.options.first() if initial else None,
)
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
elif datafield.type == Question.TYPE_CHOICE_MULTIPLE:
field = forms.ModelMultipleChoiceField(
queryset=q.options,
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
queryset=datafield.options,
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
to_field_name='identifier',
widget=QuestionCheckboxSelectMultiple,
initial=initial.options.all() if initial else None,
)
elif q.type == Question.TYPE_FILE:
if q.valid_file_portrait:
elif datafield.type == Question.TYPE_FILE:
if datafield.valid_file_portrait:
field = PortraitImageField(
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
initial=initial.file if initial else None,
widget=PortraitImageWidget(answer=initial, request=request,
attrs={'data-portrait-photo': 'true'}),
widget=PortraitImageWidget(answer=initial, request=request, attrs={'data-portrait-photo': 'true'}),
)
else:
field = ExtFileField(
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
initial=initial.file if initial else None,
widget=UploadedFileWidget(answer=initial, request=request),
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
)
elif q.type == Question.TYPE_DATE:
elif datafield.type == Question.TYPE_DATE:
attrs = {}
if q.valid_date_min:
attrs['data-min'] = q.valid_date_min.isoformat()
if q.valid_date_max:
attrs['data-max'] = q.valid_date_max.isoformat()
help_text = q.help_text
if datafield.valid_date_min:
attrs['data-min'] = datafield.valid_date_min.isoformat()
if datafield.valid_date_max:
attrs['data-max'] = datafield.valid_date_max.isoformat()
help_text = qc.help_text
if not help_text:
if q.valid_date_min and q.valid_date_max:
if datafield.valid_date_min and datafield.valid_date_max:
help_text = format_lazy(
_('Please enter a date between {min} and {max}.'),
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
)
elif q.valid_date_min:
elif datafield.valid_date_min:
help_text = format_lazy(
_('Please enter a date no earlier than {min}.'),
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
min=date_format(datafield.valid_date_min, "SHORT_DATE_FORMAT"),
)
elif q.valid_date_max:
elif datafield.valid_date_max:
help_text = format_lazy(
_('Please enter a date no later than {max}.'),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
max=date_format(datafield.valid_date_max, "SHORT_DATE_FORMAT"),
)
if initial and initial.answer:
try:
@@ -809,16 +787,16 @@ class BaseQuestionsForm(forms.Form):
else:
_initial = None
field = forms.DateField(
label=escape(q.question), required=required,
label=escape(qc.label), required=required,
help_text=rich_text(help_text),
initial=_initial,
widget=DatePickerWidget(attrs),
)
if q.valid_date_min:
field.validators.append(MinDateValidator(q.valid_date_min))
if q.valid_date_max:
field.validators.append(MaxDateValidator(q.valid_date_max))
elif q.type == Question.TYPE_TIME:
if datafield.valid_date_min:
field.validators.append(MinDateValidator(datafield.valid_date_min))
if datafield.valid_date_max:
field.validators.append(MaxDateValidator(datafield.valid_date_max))
elif datafield.type == Question.TYPE_TIME:
if initial and initial.answer:
try:
_initial = dateutil.parser.parse(initial.answer).time()
@@ -827,29 +805,29 @@ class BaseQuestionsForm(forms.Form):
else:
_initial = None
field = forms.TimeField(
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
initial=_initial,
widget=TimePickerWidget(without_seconds=True),
)
elif q.type == Question.TYPE_DATETIME:
help_text = q.help_text
elif datafield.type == Question.TYPE_DATETIME:
help_text = qc.help_text
if not help_text:
if q.valid_datetime_min and q.valid_datetime_max:
if datafield.valid_datetime_min and datafield.valid_datetime_max:
help_text = format_lazy(
_('Please enter a date and time between {min} and {max}.'),
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
elif q.valid_datetime_min:
elif datafield.valid_datetime_min:
help_text = format_lazy(
_('Please enter a date and time no earlier than {min}.'),
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
min=date_format(datafield.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
)
elif q.valid_datetime_max:
elif datafield.valid_datetime_max:
help_text = format_lazy(
_('Please enter a date and time no later than {max}.'),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
max=date_format(datafield.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
if initial and initial.answer:
@@ -861,20 +839,20 @@ class BaseQuestionsForm(forms.Form):
_initial = None
field = SplitDateTimeField(
label=escape(q.question), required=required,
label=escape(qc.label), required=required,
help_text=rich_text(help_text),
initial=_initial,
widget=SplitDateTimePickerWidget(
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
min_date=q.valid_datetime_min,
max_date=q.valid_datetime_max
min_date=datafield.valid_datetime_min,
max_date=datafield.valid_datetime_max
),
)
if q.valid_datetime_min:
field.validators.append(MinDateTimeValidator(q.valid_datetime_min))
if q.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
elif q.type == Question.TYPE_PHONENUMBER:
if datafield.valid_datetime_min:
field.validators.append(MinDateTimeValidator(datafield.valid_datetime_min))
if datafield.valid_datetime_max:
field.validators.append(MaxDateTimeValidator(datafield.valid_datetime_max))
elif datafield.type == Question.TYPE_PHONENUMBER:
if initial:
try:
initial = PhoneNumber().from_string(initial.answer)
@@ -887,28 +865,40 @@ class BaseQuestionsForm(forms.Form):
initial = "+{}.".format(phone_prefix)
field = PhoneNumberField(
label=escape(q.question), required=required,
help_text=rich_text(q.help_text),
label=escape(qc.label), required=required,
help_text=rich_text(qc.help_text),
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
field.question = q
else:
raise ProgrammingError('Invalid question type')
field.datafield = datafield
if answers:
# Cache the answer object for later use
field.answer = answers[0]
if q.dependency_question_id:
field.widget.attrs['data-question-dependency'] = q.dependency_question_id
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
if q.type != 'M':
field.widget.attrs['required'] = q.required and not self.all_optional
field._required = q.required and not self.all_optional
if qc.dependency_question_id:
field.widget.attrs['data-question-dependency'] = qc.dependency_question.form_field_name
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(qc.dependency_values))
if datafield.type != 'M':
field.widget.attrs['required'] = qc.required and not self.all_optional
field._required = qc.required and not self.all_optional
field.required = False
return field
def build_text_block(self, request, event, qc):
return forms.CharField( # TODO(questionnaires): use a less hacky way to format this field
required=False,
label="",
help_text=rich_text((f"#### {qc.label}\n" if qc.label else "") + str(qc.help_text)),
disabled=True,
widget=QuestionCheckboxSelectMultiple,
)
def check_user_questions(self, d):
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
@@ -960,8 +950,7 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
"""
Takes two additional keyword arguments:
:param checkoutsession: The checkout session the form should be for
:param order: The order the form should be for
:param container: The checkout session or order the form should be for
:param event: The event this belongs to
"""
request = kwargs.pop('request', None)
@@ -970,14 +959,35 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
super().__init__(*args, **kwargs)
questions = Question.objects.filter(
event=event, container_type=Question.ContainerType.ORDER,
ask_during_checkin=False, hidden=False,
).order_by('position')
questionnaires = Questionnaire.objects.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=container.sales_channel.identifier),
event=event, type=Questionnaire.QuestionnaireType.ORDER_SALE,
).order_by('position').prefetch_related(
Prefetch('rows', QuestionnaireRow.objects.prefetch_related(
Prefetch('user_datafield', Question.objects.prefetch_related(
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
))
),
to_attr='questionnairerowlist')
)
answerlist = container.answers.prefetch_related('options')
for q in questions:
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, answerlist, q)
idx = 0
for questionnaire in questionnaires:
for qrow in getattr(questionnaire, 'questionnairerowlist', questionnaire.rows.all()):
if qrow.user_datafield:
df = qrow.user_datafield
self.fields[qrow.form_field_name] = self.build_user_question_field(request, event, answerlist, qrow, df)
else:
self.fields[qrow.form_field_name] = self.build_text_block(request, event, qrow)
idx += 1
def clean(self):
d = super().clean()
@@ -1004,6 +1014,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
orderpos = self.orderpos = kwargs.pop('orderpos', None)
pos = cartpos or orderpos
item = pos.item
questionnaires = pos.item.relevant_questionnaires
event = kwargs.pop('event')
self.all_optional = kwargs.pop('all_optional', False)
self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
@@ -1013,18 +1024,15 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
self.fields['requested_valid_from'] = self.build_requested_valid_from_field(event, pos, item)
questions = []
if item.ask_attendee_data:
questions += get_fake_attendee_questions(event.settings)
questions += pos.item.questions_to_ask
questions.sort(key=lambda q: q.position)
for q in questions:
if isinstance(q, FakeQuestion):
self.fields[q.id] = self.build_system_question_field(request, event, pos, q)
else:
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos.answerlist, q)
for questionnaire in questionnaires:
for qrow in getattr(questionnaire, 'questionnairerowlist', questionnaire.rows.all()):
if qrow.user_datafield:
df = qrow.user_datafield
self.fields[qrow.form_field_name] = self.build_user_question_field(request, event, pos.answerlist, qrow, df)
elif qrow.system_datafield:
self.fields[qrow.form_field_name] = self.build_system_question_field(request, event, pos, qrow)
else:
self.fields[qrow.form_field_name] = self.build_text_block(request, event, qrow)
responses = question_form_fields.send(sender=event, position=pos)
data = pos.meta_info_data
@@ -1089,21 +1097,20 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
)
def build_system_question_field(self, request, event, pos, qc):
field_name = qc.id
if field_name == 'attendee_name_parts':
if qc.system_datafield == QuestionnaireRow.SystemDatafield.ATTENDEE_NAME_PARTS:
return NamePartsFormField(
max_length=255,
required=qc.required and not self.all_optional,
scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=escape(qc.question),
label=escape(qc.label),
help_text=rich_text(qc.help_text),
initial=pos.attendee_name_parts,
)
if field_name == 'attendee_email':
if qc.system_datafield == QuestionnaireRow.SystemDatafield.ATTENDEE_EMAIL:
return forms.EmailField(
required=qc.required and not self.all_optional,
label=escape(qc.question),
label=escape(qc.label),
help_text=rich_text(qc.help_text),
initial=pos.attendee_email,
widget=forms.EmailInput(
@@ -1112,19 +1119,19 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
}
)
)
if field_name == 'company':
if qc.system_datafield == QuestionnaireRow.SystemDatafield.COMPANY:
return forms.CharField(
required=qc.required and not self.all_optional,
label=escape(qc.question),
label=escape(qc.label),
help_text=rich_text(qc.help_text),
max_length=255,
initial=pos.company,
)
if field_name == 'street':
if qc.system_datafield == QuestionnaireRow.SystemDatafield.STREET:
return forms.CharField(
required=qc.required and not self.all_optional,
label=escape(qc.question),
label=escape(qc.label),
help_text=rich_text(qc.help_text),
widget=forms.Textarea(attrs={
'rows': 2,
@@ -1133,21 +1140,21 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
}),
initial=pos.street,
)
if field_name == 'zipcode':
if qc.system_datafield == QuestionnaireRow.SystemDatafield.ZIPCODE:
return forms.CharField(
required=False,
max_length=30,
label=escape(qc.question),
label=escape(qc.label),
help_text=rich_text(qc.help_text),
initial=pos.zipcode,
widget=forms.TextInput(attrs={
'autocomplete': 'postal-code',
}),
)
if field_name == 'city':
if qc.system_datafield == QuestionnaireRow.SystemDatafield.CITY:
return forms.CharField(
required=False,
label=escape(qc.question),
label=escape(qc.label),
help_text=rich_text(qc.help_text),
max_length=255,
initial=pos.city,
@@ -1155,13 +1162,13 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
'autocomplete': 'address-level2',
}),
)
if field_name == 'country':
if qc.system_datafield == QuestionnaireRow.SystemDatafield.COUNTRY:
country = pos.country or guess_country_from_request(request, event)
return CountryField(
countries=CachedCountries
).formfield(
required=qc.required and not self.all_optional,
label=escape(qc.question),
label=escape(qc.label),
help_text=rich_text(qc.help_text),
initial=country,
widget=forms.Select(attrs={
@@ -1169,7 +1176,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
'data-trigger-address-info': 'on',
}),
)
if field_name == 'state':
if qc.system_datafield == QuestionnaireRow.SystemDatafield.STATE:
country = pos.country or guess_country_from_request(request, event)
c = [('', '---')]
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
@@ -1189,7 +1196,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
del self.data[fprefix + 'state']
field = forms.ChoiceField(
label=escape(qc.question),
label=escape(qc.label),
help_text=rich_text(qc.help_text),
required=False,
choices=c,
@@ -1202,8 +1209,9 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
return field
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
from pretix.base.addressvalidation import ( # local import to prevent impact on startup time
validate_address,
)
d = super().clean()
@@ -1444,8 +1452,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['transmission_type'].widget.attrs['data-trigger-address-info'] = 'on'
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
from pretix.base.addressvalidation import ( # local import to prevent impact on startup time
validate_address,
)
data = self.cleaned_data
@@ -1499,7 +1508,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
"vat_id": _("This field is required.")
})
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
if self.validate_vat_id and self.instance.vat_id_validated and not any(v in self.changed_data for v in ('is_business', 'vat_id', 'country')):
pass # Skip re-validation if it is validated
elif self.validate_vat_id and vat_id_applicable:
try:
+2 -2
View File
@@ -82,8 +82,8 @@ class UserSettingsForm(forms.ModelForm):
class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('totp', _('Smartphone with the Authenticator application')),
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
('otp_totp.totpdevice', _('Smartphone with the Authenticator application')),
('pretixbase.webauthndevice', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
))
@@ -107,6 +107,9 @@ class TransmissionType:
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return transmission_info
def describe_info_columns(self):
return [f.label for f in self.invoice_address_form_fields.values()]
def describe_info(self, transmission_info: dict, country: Country, is_business: bool):
form_data = self.transmission_info_to_form_data(transmission_info)
data = []
@@ -0,0 +1,49 @@
# Generated by Django 5.2.17 on 2026-09-21 11:30
import django.db.models.deletion
from django.db import migrations, models
def fix_unshredded_invoices(apps, _):
Invoice = apps.get_model("pretixbase", "Invoice")
InvoiceLine = apps.get_model("pretixbase", "InvoiceLine")
ignore_fields = (
# bool/int fields are not listed and skipped automatically
'prefix', 'invoice_no', 'full_invoice_no', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id',
'invoice_from_vat_id', 'locale', 'payment_provider_stamp', 'footer_text', 'foreign_currency_display',
'foreign_currency_source', 'transmission_type', 'transmission_provider', 'transmission_status',
)
for i in Invoice.objects.filter(shredded=True):
for f in Invoice._meta.fields:
if f.name in ignore_fields:
continue
val = getattr(i, f.name, None)
if val and isinstance(val, str):
setattr(i, f.name, "█")
elif val and isinstance(val, list): # jsonfield
setattr(i, f.name, [])
elif val and isinstance(val, dict): # jsonfield
setattr(i, f.name, {"_shredded": True})
i.save()
InvoiceLine.objects.filter(
attendee_name__isnull=False,
invoice__shredded=True
).update(attendee_name="█")
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0310_question_valid_string_length_min"),
]
operations = [
migrations.RunPython(
fix_unshredded_invoices,
migrations.RunPython.noop,
),
]
@@ -0,0 +1,234 @@
# Generated by Django 4.2.29 on 2026-03-19 14:24
import json
from collections import namedtuple
from itertools import chain, groupby
import django.db.models.deletion
import i18nfield.fields
from django.db import migrations, models
from i18nfield.strings import LazyI18nString
import pretix.base.models.base
import pretix.base.models.fields
from pretix.base.i18n import language
from pretix.helpers.iter import sort_groupby, sort_groupby_exp
FakeQuestion = namedtuple(
'FakeQuestion', 'id question position required'
)
def get_fake_questions(settings):
def b(s):
return s == 'True'
fq = []
sqo = json.loads(settings.get('system_question_order', '{}'))
_ = LazyI18nString.from_gettext
if b(settings.get('attendee_names_asked', 'True')):
fq.append(FakeQuestion('@attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), b(settings.get('attendee_names_required'))))
if b(settings.get('attendee_emails_asked')):
fq.append(FakeQuestion('@attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), b(settings.get('attendee_emails_required'))))
if b(settings.get('attendee_company_asked')):
fq.append(FakeQuestion('@company', _('Company'), sqo.get('company', 0), b(settings.get('attendee_company_required'))))
if b(settings.get('attendee_addresses_asked')):
fq.append(FakeQuestion('@street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('@zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('@city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('@state', _('State'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
fq.append(FakeQuestion('@country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
return fq
def migrate_questions_forward(apps, schema_editor):
from django.utils.translation import (
get_supported_language_variant, gettext as _,
)
Event = apps.get_model("pretixbase", "Event")
Item = apps.get_model("pretixbase", "Item")
Question = apps.get_model("pretixbase", "Question")
Questionnaire = apps.get_model("pretixbase", "Questionnaire")
QuestionnaireRow = apps.get_model("pretixbase", "QuestionnaireRow")
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
qc_candidate = namedtuple('qc_candidate', ['item_info', 'question_id', 'question'])
def create_grouped_item_questionnaires(event, qc_candidates_iter, label_prefix, questionnaire_type, personalized_items=None):
# group by item, creating a unique questionnaire per item -> iter[tuple[list[Question], item_info]]
item_questionnaires = (
([c.question for c in group], item_info)
for item_info, group in sort_groupby(
qc_candidates_iter,
group_key=lambda c: c.item_info,
inner_sort_key=lambda c: c.question.position,
)
)
# group again, combining all questionnaires containing an identical set of questions (by means of grouping by a list of item-ids)
merged_questionnaires = sort_groupby_exp(item_questionnaires, group_key=lambda questions, item_info: [str(q.id) for q in questions])
for _unused, grouped_data in merged_questionnaires:
questions = grouped_data[0][0]
item_infos = [item_info for _c, item_info in grouped_data]
internal_name = label_prefix + ', '.join(str(ii.internal_name or ii.name) for ii in item_infos)
if all(ii.personalized for ii in item_infos) and len(item_infos) == len(personalized_items):
if questionnaire_type == Questionnaire.QuestionnaireType.ORDER_POSITION_SALE:
# if we have a questionnaire for exactly all personalized items, we just call it "Attendee data"
# (same as the default questionnaire for new events)
internal_name = _('Attendee data')
else:
internal_name = _('personalized products')
# create questionnaires and rows
questionnaire = Questionnaire.objects.create(
event=event, type=questionnaire_type, position=0, all_sales_channels=True,
internal_name=internal_name
)
questionnaire.items.set([ii.id for ii in item_infos])
deps = {}
for position, question in enumerate(questions):
print(position, question)
if isinstance(question, FakeQuestion):
QuestionnaireRow.objects.create(
questionnaire=questionnaire,
position=position + 1,
system_datafield=question.id,
required=question.required,
label=question.question,
)
else:
deps[question.id] = QuestionnaireRow.objects.create(
questionnaire=questionnaire,
position=position + 1,
user_datafield=question,
required=question.required,
label=question.question,
help_text=question.help_text,
dependency_question=deps[question.dependency_question.id] if question.dependency_question else None,
dependency_values=question.dependency_values,
)
for event in Event.objects.iterator():
# get relevant settings
settings = {
setting.key: setting.value for setting in EventSettingsStore.objects.filter(object_id=event.id, key__in=(
'system_question_order', 'attendee_names_asked', 'attendee_names_required', 'attendee_emails_asked', 'attendee_emails_required',
'attendee_company_asked', 'attendee_company_required', 'attendee_addresses_asked', 'attendee_addresses_required', 'locale',
))
}
with language(get_supported_language_variant(settings.get('locale', 'en'))):
# [P] get all ticket-level questions (user-defined and system provided), along with the products for which they're asked
questions = event.questions.filter(container_type='P')
personalized_items = event.items.filter(personalized=True).values_list('id', 'internal_name', 'name', 'personalized', named=True)
# [position_checkout] presale
rows = chain((
qc_candidate(item, 0, q)
for q in get_fake_questions(settings)
for item in personalized_items
), (
qc_candidate(item, q.id, q)
for q in questions.filter(hidden=False, ask_during_checkin=False)
for item in q.items.values_list('id', 'internal_name', 'name', 'personalized', named=True)
))
create_grouped_item_questionnaires(event, rows, '', Questionnaire.QuestionnaireType.ORDER_POSITION_SALE, personalized_items)
# [position_checkin] checkin
rows = chain((
qc_candidate(item, q.id, q)
for q in questions.filter(ask_during_checkin=True)
for item in q.items.values_list('id', 'internal_name', 'name', 'personalized', named=True)
))
create_grouped_item_questionnaires(event, rows, _('Check-in questions for '), Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN, personalized_items)
# [position_hidden] hidden
rows = chain((
qc_candidate(item, q.id, q)
for q in questions.filter(hidden=True, ask_during_checkin=False)
for item in q.items.values_list('id', 'internal_name', 'name', 'personalized', named=True)
))
create_grouped_item_questionnaires(event, rows, _('Hidden questions for '), Questionnaire.QuestionnaireType.ORDER_POSITION_HIDDEN, personalized_items)
# [O] get all order-level questions
questions = list(event.questions.filter(container_type='O', hidden=False).order_by('position'))
if questions:
# create questionnaires and rows
questionnaire = Questionnaire.objects.create(
event=event, type=Questionnaire.QuestionnaireType.ORDER_SALE, position=0, all_sales_channels=True,
internal_name=_('Per-order questions'),
)
deps = {}
for position, question in enumerate(questions):
deps[question.id] = QuestionnaireRow.objects.create(
questionnaire=questionnaire,
position=position + 1,
user_datafield=question,
required=question.required,
label=question.question,
help_text=question.help_text,
dependency_question=deps[question.dependency_question.id] if question.dependency_question else None,
dependency_values=question.dependency_values,
)
def migrate_questions_backward(apps, schema_editor):
pass # as long as we don't delete the old columns, this is a no op. after that, it gets complicated...
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0311_fix_unshredded_invoices'),
]
operations = [
migrations.CreateModel(
name='Questionnaire',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('internal_name', models.CharField(max_length=255)),
('type', models.CharField(max_length=25)),
('position', models.PositiveIntegerField(default=0)),
('all_sales_channels', models.BooleanField(default=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questionnaires', to='pretixbase.event')),
('items', models.ManyToManyField(related_name='questionnaires', to='pretixbase.item')),
('limit_sales_channels', models.ManyToManyField(to='pretixbase.saleschannel')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='QuestionnaireRow',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('position', models.PositiveIntegerField(default=0)),
('system_datafield', models.CharField(max_length=25, null=True)),
('required', models.BooleanField(default=False)),
('label', i18nfield.fields.I18nTextField()),
('help_text', i18nfield.fields.I18nTextField(null=True)),
('dependency_values', pretix.base.models.fields.MultiStringField(default=[])),
('dependency_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.questionnairerow')),
('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rows', to='pretixbase.questionnaire')),
('user_datafield', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='references', to='pretixbase.question')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.RunPython(
migrate_questions_forward,
migrate_questions_backward,
),
# TODO(questionnaires) remove old columns from Question model
]
+2 -2
View File
@@ -37,8 +37,8 @@ from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
itempicture_upload_to,
Questionnaire, QuestionnaireRow, QuestionOption, Quota, SubEventItem,
SubEventItemVariation, itempicture_upload_to,
)
from .log import LogEntry
from .mail import OutgoingMail
+1
View File
@@ -166,6 +166,7 @@ class Device(LoggedModel):
)
security_profile = models.CharField(
max_length=190,
verbose_name=_('Security profile'),
default='full',
null=True,
blank=False
+7
View File
@@ -737,6 +737,9 @@ class Event(EventMixin, LoggedModel):
self.settings.mail_send_order_approved_attendee = True
self.settings.mail_send_order_approved_free_attendee = True
self.settings.mail_send_download_reminder_attendee = True
from . import Questionnaire, QuestionnaireRow
q = self.questionnaires.create(internal_name=str(_('Attendee data')), type=Questionnaire.QuestionnaireType.ORDER_POSITION_SALE, position=0)
q.rows.create(label=str(_('Attendee name')), system_datafield=QuestionnaireRow.SystemDatafield.ATTENDEE_NAME_PARTS, position=0)
@property
def social_image(self):
@@ -1426,6 +1429,10 @@ class Event(EventMixin, LoggedModel):
return issues
@property
def sales_channels(self):
return self.organizer.sales_channels if self.all_sales_channels else self.limit_sales_channels
def get_users_with_any_permission(self):
"""
Returns a queryset of users who have any permission to this event.
+270 -60
View File
@@ -41,6 +41,7 @@ import warnings
from collections import Counter, OrderedDict
from datetime import date, datetime, time, timedelta
from decimal import Decimal, DecimalException
from itertools import groupby
from typing import Optional, Tuple
from zoneinfo import ZoneInfo
@@ -378,8 +379,6 @@ class Item(LoggedModel):
:type tax_rate: decimal.Decimal
:param admission: ``True``, if this item allows persons to enter the event (as opposed to e.g. merchandise)
:type admission: bool
:param personalized: ``True``, if attendee information should be collected for this ticket
:type personalized: bool
:param picture: A product picture to be shown next to the product description
:type picture: File
:param available_from: The date this product goes on sale
@@ -532,12 +531,13 @@ class Item(LoggedModel):
),
default=False
)
personalized = models.BooleanField(
tbd_personalized = models.BooleanField( # TODO(questionnaires): field to be deleted
verbose_name=_("Is a personalized ticket"),
help_text=_(
'Whether or not buying this product allows to enter attendee information'
),
default=False
default=False,
db_column='personalized',
)
generate_tickets = models.BooleanField(
verbose_name=_("Generate tickets"),
@@ -1050,10 +1050,8 @@ class Item(LoggedModel):
replace_year = valid_until.year
replace_month = valid_until.month + self.validity_dynamic_duration_months
while replace_month > 12:
replace_month -= 12
replace_year += 1
replace_year += (replace_month - 1) // 12
replace_month = ((replace_month - 1) % 12) + 1
max_day = calendar.monthrange(replace_year, replace_month)[1]
replace_date = date(
year=replace_year,
@@ -1567,12 +1565,14 @@ class ItemBundle(models.Model):
raise ValidationError(_('The count needs to be equal to or greater than zero.'))
class Question(LoggedModel):
class Question(LoggedModel): # TODO(questionnaires) - rename to Datafield ?
"""
A question is an input field that can be used to extend a ticket by custom information,
e.g. "Attendee age". The answers are found next to the position. The answers may be found
in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of
several input types, currently:
A question is a data field that can be used to extend an order or a ticket by custom
information, e.g. "Attendee age". To be actually useful, questions need to be added to
one or multiple Questionnaires. The answers may be found in QuestionAnswers, attached
to Orders, OrderPositions or CartPositions.
A question can allow one of several input types, currently:
* a number (``TYPE_NUMBER``)
* a one-line string (``TYPE_STRING``)
@@ -1592,7 +1592,7 @@ class Question(LoggedModel):
:param required: Whether answering this question is required for submitting an order including
items associated with this question.
:type required: bool
:param items: A set of ``Items`` objects that this question should be applied to
:param items: TO BE REMOVED
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
:type ask_during_checkin: bool
:param show_during_checkin: Whether to show the answer to this question during check-in.
@@ -1609,6 +1609,22 @@ class Question(LoggedModel):
class ContainerType(models.TextChoices):
ORDER = "O", _("Order")
ORDERPOSITION = "P", _("Order position")
class FieldType(models.TextChoices):
NUMBER = "N", _("Number")
STRING = "S", _("Text (one line)")
TEXT = "T", _("Multiline text")
BOOLEAN = "B", _("Yes/No")
CHOICE = "C", _("Choose one from a list")
CHOICE_MULTIPLE = "M", _("Choose multiple from a list")
FILE = "F", _("File upload")
DATE = "D", _("Date")
TIME = "H", _("Time")
DATETIME = "W", _("Date and time")
COUNTRYCODE = "CC", _("Country code (ISO 3166-1 alpha-2)")
PHONENUMBER = "TEL", _("Phone number")
# compat
TYPE_NUMBER = "N"
TYPE_STRING = "S"
TYPE_TEXT = "T"
@@ -1621,20 +1637,8 @@ class Question(LoggedModel):
TYPE_DATETIME = "W"
TYPE_COUNTRYCODE = "CC"
TYPE_PHONENUMBER = "TEL"
TYPE_CHOICES = (
(TYPE_NUMBER, _("Number")),
(TYPE_STRING, _("Text (one line)")),
(TYPE_TEXT, _("Multiline text")),
(TYPE_BOOLEAN, _("Yes/No")),
(TYPE_CHOICE, _("Choose one from a list")),
(TYPE_CHOICE_MULTIPLE, _("Choose multiple from a list")),
(TYPE_FILE, _("File upload")),
(TYPE_DATE, _("Date")),
(TYPE_TIME, _("Time")),
(TYPE_DATETIME, _("Date and time")),
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
(TYPE_PHONENUMBER, _("Phone number")),
)
TYPE_CHOICES = FieldType.choices
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
ASK_DURING_CHECKIN_UNSUPPORTED = []
SHOW_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE]
@@ -1650,8 +1654,8 @@ class Question(LoggedModel):
verbose_name=_("Asked on"),
default=ContainerType.ORDERPOSITION,
)
question = I18nTextField(
verbose_name=_("Question")
question = I18nTextField( # TODO(questionnaires) : to be renamed to 'internal_name'
verbose_name=_("Internal name"),
)
identifier = models.CharField(
max_length=190,
@@ -1665,42 +1669,46 @@ class Question(LoggedModel):
),
],
)
help_text = I18nTextField(
verbose_name=_("Help text"),
help_text=_("If the question needs to be explained or clarified, do it here!"),
null=True, blank=True,
)
#help_text = I18nTextField(
# # TODO(questionnaires) : to be removed
# verbose_name=_("Help text"),
# help_text=_("If the question needs to be explained or clarified, do it here!"),
# null=True, blank=True,
#)
type = models.CharField(
max_length=5,
choices=TYPE_CHOICES,
choices=FieldType.choices,
verbose_name=_("Question type")
)
required = models.BooleanField(
tbd_required = models.BooleanField( # TODO(questionnaires) : to be removed, -> QuestionnaireRow
default=False,
verbose_name=_("Required question")
verbose_name=_("Required question"),
db_column="required",
)
items = models.ManyToManyField(
Item,
related_name='questions',
verbose_name=_("Products"),
blank=True,
help_text=_('This question will be asked to buyers of the selected products')
)
position = models.PositiveIntegerField(
# items = models.ManyToManyField( # TODO(questionnaires) : to be removed, -> Questionnaire
# Item,
# related_name='questions',
# verbose_name=_("Products"),
# blank=True,
# help_text=_('This question will be asked to buyers of the selected products')
# )
tbd_position = models.PositiveIntegerField( # TODO(questionnaires) : to be removed, -> Questionnaire + QuestionnaireRow
default=0,
verbose_name=_("Position")
verbose_name=_("Position"),
db_column="position",
)
ask_during_checkin = models.BooleanField(
tbd_ask_during_checkin = models.BooleanField( # TODO(questionnaires) : to be removed
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
help_text=_('Not supported by all check-in apps for all question types.'),
default=False
default=False,
db_column="ask_during_checkin",
)
show_during_checkin = models.BooleanField(
verbose_name=_('Show answer during check-in'),
help_text=_('Not supported by all check-in apps for all question types.'),
default=False
)
hidden = models.BooleanField(
hidden = models.BooleanField( # to be removed
verbose_name=_('Hidden question'),
help_text=_('This question will only show up in the backend.'),
default=False
@@ -1709,10 +1717,10 @@ class Question(LoggedModel):
verbose_name=_('Print answer on invoices'),
default=False
)
dependency_question = models.ForeignKey(
dependency_question = models.ForeignKey( # TODO(questionnaires) : to be removed, -> QuestionnaireRow
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
dependency_values = MultiStringField(default=[])
dependency_values = MultiStringField(default=[]) # TODO(questionnaires) : to be removed, -> QuestionnaireRow
valid_number_min = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True,
verbose_name=_('Minimum value'),
help_text=_('Currently not supported in our apps and during check-in'))
@@ -1751,9 +1759,9 @@ class Question(LoggedModel):
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Question")
verbose_name_plural = _("Questions")
ordering = ('position', 'id')
verbose_name = _("Data field")
verbose_name_plural = _("Data fields")
ordering = ('question', 'id')
unique_together = (('event', 'identifier'),)
def __str__(self):
@@ -1791,15 +1799,16 @@ class Question(LoggedModel):
@property
def sortkey(self):
return self.position, self.id
return self.id
def __lt__(self, other) -> bool:
return self.sortkey < other.sortkey
def clean_answer(self, answer):
if self.required:
if not answer or (self.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)):
raise ValidationError(_('An answer to this question is required to proceed.'))
# TODO(questionnaires) - we don't have Question.required any more - do we actually need this check? if yes, pass in required from caller
# if self.required:
# if not answer or (self.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)):
# raise ValidationError(_('An answer to this question is required to proceed.'))
if not answer:
if self.type == Question.TYPE_BOOLEAN:
return False
@@ -1904,7 +1913,7 @@ class Question(LoggedModel):
return answer
@staticmethod
def clean_items(event, items):
def clean_items(event, items): # TODO(questionnaires) : remove method / move to qc
for item in items:
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))
@@ -1985,6 +1994,207 @@ class QuestionOption(models.Model):
ordering = ('position', 'id')
class Questionnaire(LoggedModel):
class QuestionnaireType(models.TextChoices):
ORDER_SALE = "order_purchase", _("Order-wide, before purchase")
ORDER_POSITION_SALE = "position_purchase", _("Per product, before purchase")
ORDER_POSITION_ATTENDEE_ONLY = "position_attendee", _("Per product, via attendee link")
ORDER_POSITION_CHECKIN = "position_checkin", _("Per product, at check-in")
ORDER_POSITION_HIDDEN = "position_hidden", _("Per product, hidden")
event = models.ForeignKey(
Event,
related_name="questionnaires",
on_delete=models.CASCADE
)
internal_name = models.CharField(
verbose_name=_("Internal name"),
max_length=255,
)
type = models.CharField(
max_length=25,
choices=QuestionnaireType.choices,
verbose_name=_("Questionnaire type")
)
items = models.ManyToManyField(
Item,
related_name='questionnaires',
verbose_name=_("Products"),
blank=True,
help_text=_('This questionnaire will be asked to buyers of the selected products')
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
all_sales_channels = models.BooleanField(
verbose_name=_("Sell on all sales channels the product is sold on"),
default=True,
)
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Restrict to specific sales channels"),
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
'selected here but not on product level, the variation will not be available.'),
blank=True,
)
class Meta:
ordering = ('position', 'id')
def __str__(self) -> str:
return self.internal_name
@property
def sortkey(self):
return self.position, self.id
def __lt__(self, other) -> bool:
return self.sortkey < other.sortkey
@property
def ask_before_sale(self):
return self.type == Questionnaire.QuestionnaireType.ORDER_POSITION_SALE
@property
def ask_during_checkin(self):
return self.type == Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN
@property
def hidden(self):
return self.type == Questionnaire.QuestionnaireType.ORDER_POSITION_HIDDEN
@staticmethod
def check_constraints(event):
errors = set()
all_questionnaires = Questionnaire.objects.filter(event=event).prefetch_related('rows', 'items')
all_sales_channels = event.sales_channels.all()
for (sales_channel, type, item_id, sdf, udf), fields in groupby(sorted(
(sales_channel, q.type, item.id, c.system_datafield or '', c.user_datafield_id or 0, q, item)
for q in all_questionnaires
for sales_channel in (q.limit_sales_channels if not q.all_sales_channels else all_sales_channels).values_list('pk', flat=True)
for item in q.items.all()
for c in q.rows.all()
), key=lambda d: (d[0], d[1], d[2], d[3], d[4])):
fields = list(fields)
if len(fields) > 1:
sales_channel, type, item_id, sdf, udf, q, item = fields[0]
questionnaires = set(q for sc, type, item, sdf, udf, q, item in fields)
df_label = _('System data field') + f' "{sdf}"' if sdf else _('User-defined data field') + f' "{str(udf)}"'
if len(questionnaires) == 1:
errors.add(_('{datafield} added twice to questionnaire "{questionnaire}"').format(
datafield=df_label, questionnaire=next(str(q) for q in questionnaires)
))
else:
errors.add(_('{datafield} added twice to the same item "{item}" via questionnaires "{questionnaires}"').format(
datafield=df_label, item=str(item), questionnaires='" and "'.join(str(q) for q in questionnaires)
))
if errors:
raise ValidationError(list(errors))
class QuestionnaireRow(LoggedModel):
class SystemDatafield(models.TextChoices):
ATTENDEE_NAME_PARTS = '@attendee_name_parts', _('Attendee name')
ATTENDEE_EMAIL = '@attendee_email', _('Attendee email')
COMPANY = '@company', _('Company')
STREET = '@street', _('Street')
ZIPCODE = '@zipcode', _('ZIP code')
CITY = '@city', _('City')
STATE = '@state', _('State')
COUNTRY = '@country', _('Country')
# _name_schemes_all_fields = sorted(list(set(k for scheme in PERSON_NAME_SCHEMES.values() for k, v, w, *__ in scheme['fields'])))
_name_schemes_all_fields = ['calling_name', 'degree', 'family_name', 'full_name', 'given_name',
'latin_transcription', 'middle_name', 'salutation', 'title']
SYSTEM_DATAFIELD_NUMBERS = [
SystemDatafield.ATTENDEE_NAME_PARTS.value,
SystemDatafield.ATTENDEE_EMAIL.value,
SystemDatafield.COMPANY.value,
SystemDatafield.STREET.value,
SystemDatafield.ZIPCODE.value,
SystemDatafield.CITY.value,
SystemDatafield.STATE.value,
SystemDatafield.COUNTRY.value,
] + [
'@attendee_name_parts:' + field
for field in _name_schemes_all_fields
]
questionnaire = models.ForeignKey(
Questionnaire,
related_name="rows",
on_delete=models.CASCADE
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
user_datafield = models.ForeignKey(
Question,
related_name="referenced_by",
on_delete=models.CASCADE,
null=True, blank=True,
)
system_datafield = models.CharField(
max_length=25,
choices=SystemDatafield.choices,
null=True, blank=True,
)
required = models.BooleanField(
default=False,
verbose_name=_("Required question")
)
label = I18nTextField(
verbose_name=_("Question")
)
help_text = I18nTextField(
verbose_name=_("Help text"),
help_text=_("If the question needs to be explained or clarified, do it here!"),
null=True, blank=True,
)
dependency_question = models.ForeignKey(
'QuestionnaireRow', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
dependency_values = MultiStringField(default=[])
class Meta:
ordering = ('position', 'id')
@property
def form_field_name(self):
if self.user_datafield_id:
return 'question_%s' % self.user_datafield_id
elif self.system_datafield:
return self.system_datafield[1:]
else:
return 'text_%s' % self.position
@property
def cooked_id(self):
if self.user_datafield_id or self.system_datafield:
return QuestionnaireRow.cook_id_from_parts(self.questionnaire_id, self.user_datafield_id, self.system_datafield)
else:
return None
@staticmethod
def cook_id_from_parts(qid, dfid, sys_df):
df_id = dfid + 100 if dfid else QuestionnaireRow.SYSTEM_DATAFIELD_NUMBERS.index(sys_df)
assert qid < 1000000000
return qid * 1000000000 + df_id
@staticmethod
def uncook_id(id):
id = int(id)
if id > 1000000000:
qid, dfid = divmod(id, 1000000000)
if dfid >= 100:
return qid, dfid - 100, None
else:
return qid, None, QuestionnaireRow.SYSTEM_DATAFIELD_NUMBERS[dfid]
else:
return None, id, None
class Quota(LoggedModel):
"""
A quota is a "pool of tickets". It is there to limit the number of items
+94 -48
View File
@@ -95,7 +95,9 @@ from ._transactions import (
)
from .base import LockModel, LoggedModel
from .event import Event, SubEvent
from .items import Item, ItemVariation, Question, QuestionOption, Quota
from .items import (
Item, ItemVariation, Question, QuestionnaireRow, QuestionOption, Quota,
)
logger = logging.getLogger(__name__)
@@ -918,7 +920,7 @@ class Order(LockModel, LoggedModel):
positions = list(
self.positions.all().annotate(
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True))
).select_related('item').prefetch_related('item__questions')
).select_related('item').prefetch_related('item__questionnaires')
)
if not self.event.settings.allow_modifications_after_checkin:
for cp in positions:
@@ -929,7 +931,7 @@ class Order(LockModel, LoggedModel):
return True
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
for cp in positions:
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
if (cp.item.ask_attendee_data and ask_names) or cp.item.questionnaires.all(): # TODO(questionnaires) : filter to only QuestionnaireType.ORDER_POSITION_SALE
return True
return False # nothing there to modify
@@ -1449,6 +1451,12 @@ class QuestionAnswer(models.Model):
else:
return self.answer
def to_dependency_values(self):
if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
return [o.identifier for o in self.options.all()]
elif self.question.type in (Question.TYPE_BOOLEAN, Question.TYPE_COUNTRYCODE):
return self.answer
def save(self, *args, **kwargs):
if self.orderposition and self.cartposition:
raise ValueError('QuestionAnswer cannot be linked to an order and a cart position at the same time.')
@@ -1591,55 +1599,92 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
def meta_info_data(self, d):
self.meta_info = json.dumps(d)
def cache_answers(self, all=True):
def cache_answers(self, questionnaire_type, sales_channel):
"""
Creates two properties on the object.
(1) answ: a dictionary of question.id → answer string
(2) questions: a list of Question objects, extended by an 'answer' property
Creates a new property on the object:
questions: a list of Question objects, extended by an 'answer' property
"""
self.answ = {}
for a in getattr(self, 'answerlist', self.answers.all()): # use prefetch_related cache from get_cart
self.answ[a.question_id] = a
# We need to clone our question objects, otherwise we will override the cached
# answers of other items in the same cart if the question objects have been
# selected via prefetch_related
if not all:
if hasattr(self.item, 'questions_to_ask'):
questions = list(copy.copy(q) for q in self.item.questions_to_ask)
else:
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
hidden=False))
if hasattr(self.item, 'relevant_questionnaires'):
qrows = list(copy.copy(qc) for qq in self.item.relevant_questionnaires for qc in qq.questionnairerowlist)
else:
questions = list(copy.copy(q) for q in self.item.questions.all())
qrows = QuestionnaireRow.objects.filter(questionnaire__event_id=self.item.event_id, questionnaire__items__in=[self.item.id])
if questionnaire_type is not None:
qrows = qrows.filter(questionnaire__type=questionnaire_type)
if sales_channel is not None:
qrows = qrows.filter(Q(questionnaire__all_sales_channels=True) | Q(questionnaire__limit_sales_channels=sales_channel))
question_cache = {
q.pk: q for q in questions
qrows = list(qrows)
row_cache = {
q.pk: q for q in qrows
}
def question_is_visible(parentid, qvals):
if parentid not in question_cache:
def row_is_visible(parentid, qvals):
if parentid not in row_cache:
return False
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
parentrow = row_cache[parentid]
if parentrow.dependency_question_id and not row_is_visible(parentrow.dependency_question_id, parentrow.dependency_values):
return False
if parentid not in self.answ:
return False
return (
('True' in qvals and self.answ[parentid].answer == 'True')
or ('False' in qvals and self.answ[parentid].answer == 'False')
or (any(qval in [o.identifier for o in self.answ[parentid].options.all()] for qval in qvals))
)
answer_values = self.get_dependency_answer_values(parentrow)
return answer_values and any(qval in answer_values for qval in qvals)
self.questions = []
for q in questions:
if q.id in self.answ:
q.answer = self.answ[q.id]
q.answer.question = q # cache object
else:
q.answer = ""
if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values):
self.questions.append(q)
for qrow in qrows:
try:
qrow.answer = self.answer_cache[qrow.user_datafield_id or qrow.system_datafield]
except KeyError:
continue
if not qrow.dependency_question_id or row_is_visible(qrow.dependency_question_id, qrow.dependency_values):
self.questions.append(qrow)
return self.questions
@cached_property
def answer_cache(self):
return {
"@attendee_name_parts": self.attendee_name,
"@attendee_email": self.attendee_email,
"@company": self.company,
"@street": self.street,
"@zipcode": self.zipcode,
"@city": self.city,
"@state": self.state,
"@country": str(self.country),
**{aw.question_id: aw for aw in getattr(self, 'answerlist', self.answers.all())},
}
def get_dependency_answer_values(self, qc):
if qc.user_datafield_id:
if qc.user_datafield_id not in self.answer_cache:
return None
answer = self.answer_cache[qc.user_datafield_id]
return answer.to_dependency_values()
elif qc.system_datafield:
return [self.get_system_answer(qc.system_datafield)]
else:
raise ValueError('Questionnaire child without datafield has no answer')
def get_system_answer(self, system_datafield_name):
if system_datafield_name == QuestionnaireRow.SystemDatafield.ATTENDEE_NAME_PARTS:
return self.attendee_name
elif system_datafield_name == QuestionnaireRow.SystemDatafield.ATTENDEE_EMAIL:
return self.attendee_email
elif system_datafield_name == QuestionnaireRow.SystemDatafield.COMPANY:
return self.company
elif system_datafield_name == QuestionnaireRow.SystemDatafield.STREET:
return self.street
elif system_datafield_name == QuestionnaireRow.SystemDatafield.ZIPCODE:
return self.zipcode
elif system_datafield_name == QuestionnaireRow.SystemDatafield.CITY:
return self.city
elif system_datafield_name == QuestionnaireRow.SystemDatafield.STATE:
return self.state
elif system_datafield_name == QuestionnaireRow.SystemDatafield.COUNTRY:
return str(self.country)
else:
raise ValueError('Unknown system datafield name')
@property
def net_price(self):
@@ -1652,20 +1697,21 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
else self.variation.quotas.filter(subevent=self.subevent))
def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields', set())
if 'attendee_name_parts' in update_fields:
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields'])
def ensure_updated(fieldname):
if 'update_fields' in kwargs:
kwargs['update_fields'] = {fieldname}.union(kwargs['update_fields'])
if 'attendee_name_parts' in kwargs.get('update_fields', set()):
ensure_updated('attendee_name_cached')
name = self.attendee_name
if name != self.attendee_name_cached:
self.attendee_name_cached = name
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'attendee_name_cached'}.union(kwargs['update_fields'])
ensure_updated('attendee_name_cached')
if self.attendee_name_parts is None:
self.attendee_name_parts = {}
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'attendee_name_parts'}.union(kwargs['update_fields'])
ensure_updated('attendee_name_parts')
super().save(*args, **kwargs)
@property
@@ -2741,7 +2787,7 @@ class OrderPosition(AbstractPosition):
positions = list(
self.order.positions.all().annotate(
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True))
).select_related('item').prefetch_related('item__questions')
).select_related('item').prefetch_related('item__questionnaires')
)
if not self.event.settings.allow_modifications_after_checkin:
for cp in positions:
@@ -2751,7 +2797,7 @@ class OrderPosition(AbstractPosition):
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
for cp in positions:
if cp.pk == self.pk or cp.addon_to_id == self.pk:
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
if (cp.item.ask_attendee_data and ask_names) or cp.item.questionnaires.all(): # TODO(questionnaires) : filter to only QuestionnaireType.ORDER_POSITION_SALE
return True
return False # nothing there to modify
+3 -3
View File
@@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map
from pretix.helpers.money import DecimalTextInput
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.presale.views import get_cart
from pretix.presale.views import get_cart_positions
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
logger = logging.getLogger(__name__)
@@ -1165,7 +1165,7 @@ class FreeOrderProvider(BasePaymentProvider):
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
from .services.cart import get_fees
cart = get_cart(request)
cart = get_cart_positions(request)
try:
fees = get_fees(event=request.event, request=request,
@@ -1433,7 +1433,7 @@ class GiftCardPayment(BasePaymentProvider):
for p in cs.get('payments', [])
if p.get('info_data', {}).get('gift_card')
]
positions = get_cart(request)
positions = get_cart_positions(request)
testmode = self.event.testmode
else:
used_cards = []
+28 -13
View File
@@ -801,6 +801,18 @@ def generate_compressed_addon_list(op, order, event, only_checked_in=False):
return addonlist
def get_sizebox(page: pypdf.PageObject):
mediabox = page.mediabox
cropbox = page.cropbox
return pypdf.generic.RectangleObject((
max(mediabox[0], cropbox[0]),
max(mediabox[1], cropbox[1]),
min(mediabox[2], cropbox[2]),
min(mediabox[3], cropbox[3]),
))
class Renderer:
def __init__(self, event, layout, background_file):
@@ -1079,7 +1091,7 @@ class Renderer:
fontsize = float(o['fontsize'])
height = float(o['height']) * mm
width = float(o['width']) * mm
while True:
for _i in range(25): # try adapting the font size at most 25 times
p, ad, lineheight = self._text_paragraph(op, order, o, override_fontsize=fontsize)
w, h = p.wrapOn(canvas, width, 1000 * mm)
widths = p.getActualLineWidths0()
@@ -1153,11 +1165,10 @@ class Renderer:
elif o['type'] == "poweredby":
self._draw_poweredby(canvas, op, o)
if self.bg_pdf:
page_size = (
self.bg_pdf.pages[0].mediabox[2] - self.bg_pdf.pages[0].mediabox[0],
self.bg_pdf.pages[0].mediabox[3] - self.bg_pdf.pages[0].mediabox[1]
)
if self.bg_pdf.pages[0].get('/Rotate') in (90, 270):
first_page = self.bg_pdf.pages[0]
sizebox = get_sizebox(first_page)
page_size = (sizebox.width, sizebox.height)
if first_page.rotation in (90, 270):
# swap dimensions due to pdf being rotated
page_size = page_size[::-1]
canvas.setPageSize(page_size)
@@ -1312,14 +1323,18 @@ def merge_background(fg_pdf: PdfWriter, bg_pdf: PdfWriter, out_file, compress):
def _merge_with_correct_page_media_box(output: pypdf.PdfWriter, fg_page: pypdf.PageObject, bg_page: pypdf.PageObject):
if bg_page.rotation != 0:
bg_page.transfer_rotation_to_content()
media_box = bg_page.mediabox
"""
Adds fg_page to output, merging bg_page behind it.
If bg_page has a non-zero mergebox/cropbox or is rotated via /Rotate, a transformation is applied to fix this."""
trsf = pypdf.Transformation()
if media_box.bottom != 0:
trsf = trsf.translate(0, -media_box.bottom)
if media_box.left != 0:
trsf = trsf.translate(-media_box.left, 0)
if bg_page.rotation != 0:
trsf = trsf.rotate(-bg_page.rotation)
mb = get_sizebox(bg_page)
pt1 = trsf.apply_on(mb.lower_left)
pt2 = trsf.apply_on(mb.upper_right)
trsf = trsf.translate(-min(pt1[0], pt2[0]), -min(pt1[1], pt2[1]))
fg_page = output.add_page(fg_page)
fg_page.merge_transformed_page(bg_page, trsf, over=False, expand=False)
+1
View File
@@ -1410,6 +1410,7 @@ class CartManager:
for k, v in self._widget_data.items():
if not k.startswith('question-'):
continue
# TODO(questionnaires) - switch to QuestionnaireRow
q = cp.item.questions.filter(ask_during_checkin=False, identifier__iexact=k[9:]).first()
if q:
try:
+17 -15
View File
@@ -55,7 +55,7 @@ from django_scopes import scope, scopes_disabled
from pretix.base.models import (
Checkin, CheckinList, Device, Event, Gate, Item, ItemVariation, Order,
OrderPosition, QuestionOption,
OrderPosition, Questionnaire, QuestionnaireRow, QuestionOption,
)
from pretix.base.signals import checkin_created, periodic_task
from pretix.helpers import OF_SELF
@@ -897,14 +897,14 @@ def _save_answers(op, answers, given_answers):
written = False
for q, a in given_answers.items():
if not a:
if q in answers:
if q.id in answers:
written = True
answers[q].delete()
answers[q.id].delete()
else:
continue
if isinstance(a, QuestionOption):
if q in answers:
qa = answers[q]
if q.id in answers:
qa = answers[q.id]
qa.answer = str(a.answer)
qa.save()
written = True
@@ -913,8 +913,8 @@ def _save_answers(op, answers, given_answers):
qa = _create_answer(question=q, answer=str(a.answer))
qa.options.add(a)
elif isinstance(a, list):
if q in answers:
qa = answers[q]
if q.id in answers:
qa = answers[q.id]
qa.answer = ", ".join([str(o) for o in a])
qa.save()
written = True
@@ -923,8 +923,8 @@ def _save_answers(op, answers, given_answers):
qa = _create_answer(question=q, answer=", ".join([str(o) for o in a]))
qa.options.add(*a)
elif isinstance(a, File):
if q in answers:
qa = answers[q]
if q.id in answers:
qa = answers[q.id]
else:
qa = _create_answer(question=q, answer=str(a))
qa.file.save(os.path.basename(a.name), a, save=False)
@@ -932,8 +932,8 @@ def _save_answers(op, answers, given_answers):
qa.save()
written = True
else:
if q in answers:
qa = answers[q]
if q.id in answers:
qa = answers[q.id]
qa.answer = str(a)
qa.save()
else:
@@ -1033,17 +1033,19 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
# Do this outside of transaction so it is saved even if the checkin fails for some other reason
checkin_questions = list(
clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id])
QuestionnaireRow.objects.filter(
questionnaire__event=clist.event, questionnaire__type=Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN,
questionnaire__items__in=[op.item_id],
).select_related('user_datafield', 'questionnaire')
)
require_answers = []
if type != Checkin.TYPE_EXIT and checkin_questions:
answers = {a.question: a for a in op.answers.all()}
for q in checkin_questions:
if q not in given_answers and q not in answers:
if q.user_datafield not in given_answers and q.user_datafield_id not in op.answer_cache:
require_answers.append(q)
if not simulate:
_save_answers(op, answers, given_answers)
_save_answers(op, op.answer_cache, given_answers)
with conditional_atomic(not simulate):
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
+5 -3
View File
@@ -54,7 +54,7 @@ from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.mail import EmailMultiAlternatives, SafeMIMEMultipart
from django.core.mail.message import SafeMIMEText
from django.core.mail.message import SafeMIMEText, utf8_charset_qp
from django.db import connection, transaction
from django.db.models import Q
from django.dispatch import receiver
@@ -380,6 +380,8 @@ def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString],
class CustomEmail(EmailMultiAlternatives):
encoding = utf8_charset_qp
def _create_mime_attachment(self, content, mimetype):
"""
Convert the content, mimetype pair into a MIME attachment object.
@@ -449,9 +451,9 @@ def mail_send_task(self, **kwargs) -> bool:
# Rewrite all <img> tags from real URLs or data URLs to inline attachments referred to by content ID
if outgoing_mail.body_html is not None:
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
html_message = SafeMIMEMultipart(_subtype='related')
html_with_cid, cid_images = replace_images_with_cid_paths(outgoing_mail.body_html)
html_message.attach(SafeMIMEText(html_with_cid, 'html', settings.DEFAULT_CHARSET))
html_message.attach(SafeMIMEText(html_with_cid, 'html', utf8_charset_qp))
attach_cid_images(html_message, cid_images, verify_ssl=True)
email.attach_alternative(html_message, "multipart/related")
+14 -105
View File
@@ -379,99 +379,12 @@ DEFAULTS = {
)
},
'system_question_order': {
'default': {},
'type': dict,
'serializer_class': serializers.DictField,
'serializer_kwargs': lambda: dict(read_only=True, allow_empty=True),
},
'attendee_names_asked': {
'default': 'True',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for attendee names"),
help_text=_("Ask for a name for all personalized tickets."),
)
},
'attendee_names_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require attendee names"),
help_text=_("Require customers to fill in the names of all attendees."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}),
)
},
'attendee_emails_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for email addresses per ticket"),
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent "
"only to that email address. If you enable this option, the system will additionally ask for "
"individual email addresses for every personalized ticket. This might be useful if you want to "
"obtain individual addresses for every attendee even in case of group orders. However, "
"pretix will send the order confirmation by default only to the one primary email address, not to "
"the per-attendee addresses. You can however enable this in the email settings."),
)
},
'attendee_emails_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require email addresses per ticket"),
help_text=_("Require customers to fill in individual email addresses for all personalized tickets. See the "
"above option for more details. One email address for the order confirmation will always be "
"required regardless of this setting."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
)
},
'attendee_company_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for company per ticket"),
)
},
'attendee_company_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require company per ticket"),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_company_asked'}),
)
},
'attendee_addresses_asked': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for postal addresses per ticket"),
)
},
'attendee_addresses_required': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require postal addresses per ticket"),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_addresses_asked'}),
)
},
# 'system_question_order': { # TODO(questionnaires) - remove this
# 'default': {},
# 'type': dict,
# 'serializer_class': serializers.DictField,
# 'serializer_kwargs': lambda: dict(read_only=True, allow_empty=True),
# },
'order_email_asked_twice': {
'default': 'False',
'type': bool,
@@ -3767,10 +3680,6 @@ Your {organizer} team""")) # noqa: W291
'default': "7B227073704964223A2239373943394538343346343131343044463144313834343232393232313734313034353044314339464446394437384337313531303944334643463542433731222C2276657273696F6E223A312C22637265617465644F6E223A313536363233343735303036312C227369676E6174757265223A22333038303036303932613836343838366637306430313037303261303830333038303032303130313331306633303064303630393630383634383031363530333034303230313035303033303830303630393261383634383836663730643031303730313030303061303830333038323033653333303832303338386130303330323031303230323038346333303431343935313964353433363330306130363038326138363438636533643034303330323330376133313265333032633036303335353034303330633235343137303730366336353230343137303730366336393633363137343639366636653230343936653734363536373732363137343639366636653230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333031653137306433313339333033353331333833303331333333323335333735613137306433323334333033353331333633303331333333323335333735613330356633313235333032333036303335353034303330633163363536333633326437333664373032643632373236663662363537323264373336393637366535663535343333343264353035323466343433313134333031323036303335353034306230633062363934663533323035333739373337343635366437333331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333035393330313330363037326138363438636533643032303130363038326138363438636533643033303130373033343230303034633231353737656465626436633762323231386636386464373039306131323138646337623062643666326332383364383436303935643934616634613534313162383334323065643831316633343037653833333331663163353463336637656233323230643662616435643465666634393238393839336537633066313361333832303231313330383230323064333030633036303335353164313330313031666630343032333030303330316630363033353531643233303431383330313638303134323366323439633434663933653465663237653663346636323836633366613262626664326534623330343530363038326230363031303530353037303130313034333933303337333033353036303832623036303130353035303733303031383632393638373437343730336132663266366636333733373032653631373037303663363532653633366636643266366636333733373033303334326436313730373036633635363136393633363133333330333233303832303131643036303335353164323030343832303131343330383230313130333038323031306330363039326138363438383666373633363430353031333038316665333038316333303630383262303630313035303530373032303233303831623630633831623335323635366336393631366536333635323036663665323037343638363937333230363336353732373436393636363936333631373436353230363237393230363136653739323037303631373237343739323036313733373337353664363537333230363136333633363537303734363136653633363532303666363632303734363836353230373436383635366532303631373037303663363936333631363236633635323037333734363136653634363137323634323037343635373236643733323036313665363432303633366636653634363937343639366636653733323036663636323037353733363532633230363336353732373436393636363936333631373436353230373036663663363936333739323036313665363432303633363537323734363936363639363336313734363936663665323037303732363136333734363936333635323037333734363137343635366436353665373437333265333033363036303832623036303130353035303730323031313632613638373437343730336132663266373737373737326536313730373036633635326536333666366432663633363537323734363936363639363336313734363536313735373436383666373236393734373932663330333430363033353531643166303432643330326233303239613032376130323538363233363837343734373033613266326636333732366332653631373037303663363532653633366636643266363137303730366336353631363936333631333332653633373236633330316430363033353531643065303431363034313439343537646236666435373438313836383938393736326637653537383530376537396235383234333030653036303335353164306630313031666630343034303330323037383033303066303630393261383634383836663736333634303631643034303230353030333030613036303832613836343863653364303430333032303334393030333034363032323130306265303935373166653731653165373335623535653561666163623463373266656234343566333031383532323263373235313030326236316562643666353530323231303064313862333530613564643664643665623137343630333562313165623263653837636661336536616636636264383338303839306463383263646461613633333038323032656533303832303237356130303330323031303230323038343936643266626633613938646139373330306130363038326138363438636533643034303330323330363733313162333031393036303335353034303330633132343137303730366336353230353236663666373432303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313Line truncated
'type': str,
},
'widget_vue2_origins': {
'default': '',
'type': str,
},
}
PERSON_NAME_TITLE_GROUPS = OrderedDict([
('english_common', (_('Most common English titles'), (
@@ -4176,14 +4085,14 @@ def validate_event_settings(event, settings_dict):
raise ValidationError({
'locale': _('Your default locale must also be enabled for your event (see box above).')
})
if settings_dict.get('attendee_names_required') and not settings_dict.get('attendee_names_asked'):
raise ValidationError({
'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.')
})
if settings_dict.get('attendee_emails_required') and not settings_dict.get('attendee_emails_asked'):
raise ValidationError({
'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.')
})
# if settings_dict.get('attendee_names_required') and not settings_dict.get('attendee_names_asked'):
# raise ValidationError({
# 'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.')
# })
# if settings_dict.get('attendee_emails_required') and not settings_dict.get('attendee_emails_asked'):
# raise ValidationError({
# 'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.')
# })
if settings_dict.get('invoice_address_required') and not settings_dict.get('invoice_address_asked'):
raise ValidationError({
'invoice_address_required': _('You have to ask for invoice addresses if you want to make them required.')
+22 -10
View File
@@ -50,8 +50,8 @@ from pretix.api.serializers.order import (
from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.i18n import LazyLocaleException
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
CachedCombinedTicket, CachedTicket, Event, Invoice, InvoiceAddress,
OrderPayment, OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
)
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.signals import register_data_shredders
@@ -598,18 +598,30 @@ class InvoiceShredder(BaseDataShredder):
def shred_data(self, progress_callback=None):
qs_i = self.event.invoices.filter(shredded=False)
total = qs_i.count()
ignore_fields = (
'prefix', 'invoice_no', 'full_invoice_no', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id',
'invoice_from_vat_id', 'locale', 'payment_provider_stamp', 'footer_text', 'foreign_currency_display',
'foreign_currency_source', 'transmission_type', 'transmission_provider', 'transmission_status',
)
for i in _progress_helper(qs_i, progress_callback, 0, total):
if i.file:
i.file.delete()
i.shredded = True
i.introductory_text = "█"
i.additional_text = "█"
i.invoice_to = "█"
i.payment_provider_text = "█"
i.transmission_info = {"_shredded": True}
i.save()
i.lines.update(description="█")
i.shredded = True
for f in Invoice._meta.fields:
if f.name in ignore_fields:
continue
val = getattr(i, f.name, None)
if val and isinstance(val, str):
setattr(i, f.name, "█")
elif val and isinstance(val, list): # jsonfield
setattr(i, f.name, [])
elif val and isinstance(val, dict): # jsonfield
setattr(i, f.name, {"_shredded": True})
i.save()
i.lines.update(description="█", attendee_name="█")
class CachedTicketShredder(BaseDataShredder):
+52 -23
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
from decimal import ROUND_HALF_UP, Decimal
from typing import Optional
from babel import Locale, UnknownLocaleError
from babel.numbers import format_currency
@@ -35,32 +36,32 @@ register = template.Library()
@register.filter("money")
def money_filter(value: Decimal, arg='', hide_currency=False):
if isinstance(value, (float, int)):
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False):
if isinstance(value, (float, int, str)):
if value == '':
return value
value = Decimal(value)
if value is None:
value = Decimal('0.00')
if not isinstance(value, Decimal):
if value == '':
return value
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
if not arg:
raise ValueError("No currency passed.")
arg = arg.upper()
places = settings.CURRENCY_PLACES.get(arg, 2)
rounded = value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
if places < 2 and rounded != value:
# We display decimal places even if we shouldn't for this currency if rounding
# would make the numbers incorrect. If this branch executes, it's likely a bug in
# pretix, but we won't show wrong numbers!
if hide_currency:
return floatformat(value, "2g")
else:
return '{} {}'.format(arg, floatformat(value, "2g"))
if value.normalize().as_tuple().exponent < -9:
# Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we
# do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations
# are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python
# land to this filter (even though it should not happen).
value = value.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize()
currency_places = settings.CURRENCY_PLACES.get(arg, 2)
required_places = -value.normalize().as_tuple().exponent
render_places = max(currency_places, required_places)
if hide_currency:
return floatformat(value, f"{places}g")
return floatformat(value, f"{render_places}g")
try:
locale = Locale(get_babel_locale())
@@ -68,14 +69,29 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
locale = "en"
try:
return format_currency(value, arg, locale=locale)
return format_currency(
value,
arg,
locale=locale,
# We only allow Babel to restrict the digits to the digits defined by the currency if this does not remove any
# precision in case we have sub-currency precision (which we shouldn't have in most places, but it's still
# better than showing wrong data). Note: Weird precision effects can occur after in-database arithmetic
# on SQLite, since SQLite does not have fixed-decimal computation.
currency_digits=currency_places >= required_places,
decimal_quantization=currency_places >= required_places,
)
except:
return '{} {}'.format(arg, floatformat(value, f"{places}g"))
return '{} {}'.format(arg, floatformat(value, f"{render_places}g"))
@register.filter("money_without_currency")
def money_filter_without_currency(value: Optional[Decimal | float | int | str], arg=''):
return money_filter(value, arg, hide_currency=True)
@register.filter("money_numberfield")
def money_numberfield_filter(value: Decimal, arg=''):
if isinstance(value, (float, int)):
def money_numberfield_filter(value: Optional[Decimal | float | int | str], arg=''):
if isinstance(value, (float, int, str)):
value = Decimal(value)
if not isinstance(value, Decimal):
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
@@ -87,15 +103,28 @@ def money_numberfield_filter(value: Decimal, arg=''):
@register.filter(is_safe=True)
def tax_rate_format(number):
def tax_rate_format(number: Optional[Decimal | float | int | str]):
"""
Display a Decimal to its significant decimal places, used for tax rates.
"""
assert isinstance(number, Decimal)
if isinstance(number, (float, int, str)):
if number == '':
return number
number = Decimal(number)
if number is None:
number = Decimal('0.00')
if not isinstance(number, Decimal):
raise TypeError("Invalid data type passed to tax rate format filter: %r" % type(number))
if number.normalize().as_tuple().exponent < -9:
# Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we
# do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations
# are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python
# land to this filter (even though it should not happen).
number = number.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize()
return mark_safe(
formats.number_format(
number.normalize(),
-number.as_tuple().exponent,
number,
-number.normalize().as_tuple().exponent,
use_l10n=True,
force_grouping=False,
)
+2 -2
View File
@@ -21,8 +21,8 @@
#
import contextvars
from contextlib import contextmanager
from datetime import datetime
from dateutil.parser import parse
from django.utils.timezone import now
from pretix.base.auth import has_event_access_permission
@@ -34,7 +34,7 @@ timemachine_now_var = contextvars.ContextVar('timemachine_now', default=None)
def time_machine_now_assigned_from_request(request):
if hasattr(request, 'event') and f'timemachine_now_dt:{request.event.pk}' in request.session and \
request.event.testmode and has_event_access_permission(request):
request.now_dt = parse(request.session[f'timemachine_now_dt:{request.event.pk}'])
request.now_dt = datetime.fromisoformat(request.session[f'timemachine_now_dt:{request.event.pk}'])
request.now_dt_is_fake = True
else:
request.now_dt = now()
+7 -2
View File
@@ -19,6 +19,7 @@
# 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 django.conf import settings
from django.http import (
HttpResponseForbidden, HttpResponseNotFound, HttpResponseServerError,
)
@@ -27,7 +28,6 @@ from django.template import TemplateDoesNotExist, loader
from django.template.loader import get_template
from django.utils.functional import Promise
from django.utils.translation import gettext as _
from sentry_sdk import last_event_id
from pretix.base.i18n import language
from pretix.base.middleware import get_language_from_request
@@ -106,9 +106,14 @@ def server_error(request):
template = loader.get_template('500.html')
except TemplateDoesNotExist:
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
if settings.SENTRY_ENABLED:
from sentry_sdk import last_event_id
sentry_id = last_event_id()
else:
sentry_id = None
r = HttpResponseServerError(template.render({
'request': request,
'sentry_event_id': last_event_id(),
'sentry_event_id': sentry_id,
}))
r.xframe_options_exempt = True
return r
+31 -21
View File
@@ -27,7 +27,7 @@ from decimal import Decimal
from django import forms
from django.core.files.uploadedfile import UploadedFile
from django.db import IntegrityError
from django.db.models import Prefetch, QuerySet
from django.db.models import Prefetch, Q, QuerySet
from django.utils.functional import cached_property
from django.utils.timezone import make_aware
@@ -37,7 +37,7 @@ from pretix.base.forms.questions import (
)
from pretix.base.models import (
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
QuestionOption,
Questionnaire, QuestionnaireRow, QuestionOption,
)
from pretix.base.models.customers import AttendeeProfile
from pretix.base.models.orders import CheckoutSession, Order
@@ -119,8 +119,8 @@ class BaseQuestionsViewMixin:
override_sets = self.get_question_override_sets(cr, idx)
for overrides in override_sets:
for question_name, question_field in form.fields.items():
if hasattr(question_field, 'question'):
src = overrides.get(question_field.question.identifier)
if hasattr(question_field, 'datafield'):
src = overrides.get(question_field.datafield.identifier)
else:
src = overrides.get(question_name)
if not src:
@@ -184,7 +184,7 @@ class BaseQuestionsViewMixin:
field, v,
checkoutsession=checkoutsession,
order=order,
question=field.question,
question=field.datafield,
)
for form in self.forms:
@@ -246,7 +246,7 @@ class BaseQuestionsViewMixin:
field, v,
cartposition=cartposition,
orderposition=orderposition,
question=field.question,
question=field.datafield,
)
answer_dict = self._build_answer_dict(field, answer, k)
@@ -331,8 +331,8 @@ class BaseQuestionsViewMixin:
'field_name': k,
'field_label': str(field.label),
'value': answer_value,
'question_type': field.question.type,
'question_identifier': field.question.identifier,
'question_type': field.datafield.type,
'question_identifier': field.datafield.identifier,
}
@@ -348,27 +348,37 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
@cached_property
def positions(self):
qqs = self.request.event.questions.all()
qqs = self.request.event.questionnaires.all()
if self.only_user_visible:
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
qqs = qqs.filter(type=Questionnaire.QuestionnaireType.ORDER_POSITION_SALE)
else:
qqs = qqs.filter(type__startswith='position_')
qqs = qqs.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=self.order.sales_channel.identifier)
)
return list(self.order.positions.select_related(
'item', 'variation'
).prefetch_related(
Prefetch('answers',
QuestionAnswer.objects.prefetch_related('options'),
to_attr='answerlist'),
Prefetch('item__questions',
Prefetch('item__questionnaires',
qqs.prefetch_related(
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
).select_related('dependency_question'),
to_attr='questions_to_ask')
Prefetch('rows', QuestionnaireRow.objects.prefetch_related(
Prefetch('user_datafield', Question.objects.prefetch_related(
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
))
),
to_attr='questionnairerowlist')
),
to_attr='relevant_questionnaires')
))
@cached_property
+2
View File
@@ -26,6 +26,7 @@ from django.core import signing
from django.http import HttpResponseBadRequest, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.html import format_html
logger = logging.getLogger(__name__)
@@ -60,6 +61,7 @@ def redir_view(request):
u = urllib.parse.urlparse(url)
return render(request, 'pretixbase/redirect.html', {
'hostname': u.hostname,
'bold_hostname': format_html("<strong>{}</strong>", u.hostname),
'url': url,
})
+16
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import logging
import multiprocessing
import os
from celery import Celery, signals
@@ -54,6 +55,21 @@ def on_task_received(sender, request, **kwargs):
logger.info(f"Task {request.id} has trace {trace}")
@receiver(signals.after_setup_task_logger)
def on_after_setup_task_logger(sender, logger, loglevel, logfile, format, colorize, **kwargs):
# This hack seems to be required to get celery to log internal events from eg billiard/pool.py such as
# "worker killed because it used too much memory"
# You can test that it is working by starting a celery worker with a low value like
# --max-memory-per-child 300000
# and then trigger a task. Result should look like this:
# [2026-09-23 10:42:26,234: WARNING/ForkPoolWorker-16]: [???:???] child process exiting after exceeding memory limit (394540KiB / 300000KiB)
# The ???:??? are likely because by copying the handlers, we are also copying the format, but I was unable to find
# a better compatible way.
multi_logger = multiprocessing.get_logger()
multi_logger.setLevel(logging.WARNING)
multi_logger.handlers = logger.handlers
@receiver(signals.task_prerun)
def on_task_prerun(sender, task_id, task, **kwargs):
from pretix.helpers.logs import local
+15 -17
View File
@@ -400,10 +400,10 @@ class EventMetaValueForm(forms.ModelForm):
if self.disabled:
self.fields['value'].widget.attrs['readonly'] = 'readonly'
def clean_slug(self):
def clean_value(self):
if self.disabled:
return self.instance.value if self.instance else None
return self.cleaned_data['slug']
return self.cleaned_data['value']
class Meta:
model = EventMetaValue
@@ -639,14 +639,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'event_calendar_future_only',
'frontpage_text',
'event_info_text',
'attendee_names_asked',
'attendee_names_required',
'attendee_emails_asked',
'attendee_emails_required',
'attendee_company_asked',
'attendee_company_required',
'attendee_addresses_asked',
'attendee_addresses_required',
'attendee_data_explanation_text',
'order_phone_asked',
'order_phone_required',
@@ -838,9 +830,10 @@ class CancelSettingsForm(SettingsForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.obj.settings.giftcard_expiry_years is not None:
self.fields['cancel_allow_user_paid_refund_as_giftcard'].help_text = gettext(
'You have configured gift cards to be valid {} years plus the year the gift card is issued in.'
).format(self.obj.settings.giftcard_expiry_years)
self.fields['cancel_allow_user_paid_refund_as_giftcard'].help_text = format_html(
gettext('You have configured gift cards to be valid {} years plus the year the gift card is issued in.'),
self.obj.settings.giftcard_expiry_years
)
class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
@@ -1635,10 +1628,15 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self._set_field_placeholders(k, v, rich=k.startswith('mail_text_') and k not in self.plain_rendering)
for k, v in list(self.fields.items()):
if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
# If we don't ask for attendee emails, we can't send them anything and we don't need to clutter
# the user interface with it
del self.fields[k]
if k.endswith('_attendee'):
if not event.settings.attendee_emails_asked:
# If we don't ask for attendee emails, we can't send them anything and we don't need to clutter
# the user interface with it
del self.fields[k]
elif 'subject' in k and k.replace("subject", "send") in self.fields:
v.widget.attrs["data-display-dependency"] = f'#id_{k.replace("subject", "send")}'
elif 'text' in k and k.replace("text", "send") in self.fields:
v.widget.attrs["data-display-dependency"] = f'#id_{k.replace("text", "send")}'
class TicketSettingsForm(SettingsForm):
@@ -105,13 +105,6 @@ class GlobalSettingsForm(SettingsForm):
domain=settings.SITE_URL
)
)),
('widget_vue2_origins', forms.CharField(
widget=forms.Textarea(attrs={'rows': '3'}),
required=False,
# Not translated on purpose, this is a temporary feature and contains too many special case words
label="Vue2 widget origins",
help_text="One origin per line (e.g. https://example.com). Requests from these origins will be served the old vue2-based widget.",
))
])
responses = register_global_settings.send(self)
for r, response in sorted(responses, key=lambda r: str(r[0])):
+48 -65
View File
@@ -60,12 +60,13 @@ from pretix.base.models import (
Item, ItemCategory, ItemProgramTime, ItemVariation, Question,
QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.models.items import (
ItemAddOn, ItemBundle, ItemMetaValue, Questionnaire,
)
from pretix.base.signals import item_copy_data
from pretix.control.forms import (
ButtonGroupRadioSelect, ExtFileField, ItemMultipleChoiceField,
SalesChannelCheckboxSelectMultiple, SplitDateTimeField,
SplitDateTimePickerWidget,
ButtonGroupRadioSelect, ExtFileField, SalesChannelCheckboxSelectMultiple,
SplitDateTimeField, SplitDateTimePickerWidget,
)
from pretix.control.forms.widgets import Select2, Select2ItemVarMulti
from pretix.helpers.models import modelcopy
@@ -153,53 +154,10 @@ class QuestionForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.container_type == Question.ContainerType.ORDERPOSITION:
self.fields['items'].queryset = self.instance.event.items.all()
self.fields['items'].required = True
else:
del self.fields['items']
del self.fields['ask_during_checkin']
if self.instance.container_type != Question.ContainerType.ORDERPOSITION:
del self.fields['show_during_checkin']
del self.fields['print_on_invoice']
self.fields['dependency_question'].widget.attrs['data-container-type'] = self.instance.container_type
self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
ask_during_checkin=False,
container_type=self.instance.container_type,
)
if self.instance.pk:
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
pk=self.instance.pk
)
self.fields['identifier'].required = False
self.fields['dependency_values'].required = False
self.fields['help_text'].widget.attrs['rows'] = 3
def clean_dependency_values(self):
val = self.data.getlist('dependency_values')
return val
def clean_dependency_question(self):
dep = val = self.cleaned_data.get('dependency_question')
if dep:
if dep.ask_during_checkin:
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
seen_ids = {self.instance.pk} if self.instance else set()
while dep:
if dep.pk in seen_ids:
raise ValidationError(_('Circular dependency between questions detected.'))
seen_ids.add(dep.pk)
dep = dep.dependency_question
return val
def clean_ask_during_checkin(self):
val = self.cleaned_data.get('ask_during_checkin')
if val and self.cleaned_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
raise ValidationError(_('This type of question cannot be asked during check-in.'))
return val
def clean_show_during_checkin(self):
val = self.cleaned_data.get('show_during_checkin')
@@ -233,16 +191,10 @@ class QuestionForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'question',
'help_text',
'type',
'required',
'ask_during_checkin',
'show_during_checkin',
'hidden',
'identifier',
'items',
'dependency_question',
'dependency_values',
'print_on_invoice',
'valid_number_min',
'valid_number_max',
@@ -259,17 +211,11 @@ class QuestionForm(I18nModelForm):
'valid_datetime_max': SplitDateTimePickerWidget(without_seconds=True),
'valid_date_min': DatePickerWidget(),
'valid_date_max': DatePickerWidget(),
'items': forms.CheckboxSelectMultiple(
attrs={'class': 'scrolling-multiple-choice'}
),
'dependency_values': forms.SelectMultiple,
'help_text': I18nMarkdownTextarea,
}
field_classes = {
'valid_datetime_min': SplitDateTimeField,
'valid_datetime_max': SplitDateTimeField,
'items': ItemMultipleChoiceField,
'dependency_question': SafeModelChoiceField,
}
@@ -447,13 +393,20 @@ class ItemCreateForm(I18nModelForm):
help_text=_('Select this option e.g. for t-shirts that come in multiple sizes. '
'You can select the variations in the next step.'),
required=False)
questionnaires = SafeModelMultipleChoiceField(
queryset=Questionnaire.objects.none(),
required=False,
label=_('Questionnaires'),
widget=forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
)
def __init__(self, *args, **kwargs):
self.event = kwargs['event']
self.user = kwargs.pop('user')
kwargs.setdefault('initial', {})
kwargs['initial'].setdefault('admission', True)
kwargs['initial'].setdefault('personalized', True)
super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all()
@@ -469,6 +422,8 @@ class ItemCreateForm(I18nModelForm):
)
self.fields['category'].widget.choices = self.fields['category'].choices
self.fields['questionnaires'].queryset = self.instance.event.questionnaires.filter(type__startswith='position_')
self.fields['tax_rule'].queryset = self.instance.event.tax_rules.all()
change_decimal_field(self.fields['default_price'], self.instance.event.currency)
self.fields['copy_from'] = forms.ModelChoiceField(
@@ -575,10 +530,15 @@ class ItemCreateForm(I18nModelForm):
self.instance.picture.save(os.path.basename(src.picture.name), src.picture)
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
if not self.instance.admission:
self.instance.personalized = False
instance = super().save(*args, **kwargs)
instance.questionnaires.clear()
instance.questionnaires.add(*self.cleaned_data['questionnaires'])
try:
Questionnaire.check_constraints(self.event)
except ValidationError as exc:
raise ValidationError({'questionnaires': exc})
if not self.event.has_subevents and not self.cleaned_data.get('has_variations'):
if self.cleaned_data.get('quota_option') == self.EXISTING and self.cleaned_data.get('quota_add_existing') is not None:
quota = self.cleaned_data.get('quota_add_existing')
@@ -669,7 +629,6 @@ class ItemCreateForm(I18nModelForm):
'internal_name',
'category',
'admission',
'personalized',
'default_price',
'tax_rule',
]
@@ -702,6 +661,14 @@ class ItemUpdateForm(I18nModelForm):
max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE,
required=False,
)
questionnaires = SafeModelMultipleChoiceField(
queryset=Questionnaire.objects.none(),
required=False,
label=_('Questionnaires'),
widget=forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -797,6 +764,9 @@ class ItemUpdateForm(I18nModelForm):
)
self.fields['category'].widget.choices = self.fields['category'].choices
self.fields['questionnaires'].queryset = self.instance.event.questionnaires.filter(type__startswith='position_')
self.initial['questionnaires'] = self.instance.questionnaires.values_list('pk', flat=True)
self.fields['free_price_suggestion'].widget.attrs['data-display-dependency'] = '#id_free_price'
self.fields['validity_dynamic_start_choice'] = forms.TypedChoiceField(
@@ -891,6 +861,16 @@ class ItemUpdateForm(I18nModelForm):
return d
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
instance.questionnaires.clear()
instance.questionnaires.add(*self.cleaned_data['questionnaires'])
try:
Questionnaire.check_constraints(self.event)
except ValidationError as exc:
raise ValidationError({'questionnaires': exc})
return instance
class Meta:
model = Item
localized_fields = '__all__'
@@ -902,7 +882,6 @@ class ItemUpdateForm(I18nModelForm):
'all_sales_channels',
'limit_sales_channels',
'admission',
'personalized',
'description',
'picture',
'default_price',
@@ -1197,6 +1176,10 @@ class ItemAddOnForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['addon_category'].queryset = self.event.categories.all()
self.fields['addon_category'].help_text = format_html('<a href="{}" data-iframe-dialog="true" data-iframe-dialog-target="<[name$=addon_category]" target="_blank">{}</a>', reverse('control:event.items.categories.add', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}), _("Create a new category"))
self.fields['addon_category'].widget = Select2(
attrs={
'data-model-select2': 'generic',
+2 -2
View File
@@ -22,7 +22,7 @@
from django import forms
from django.core.exceptions import ValidationError
from django.utils.functional import lazy
from django.utils.html import format_html
from django.utils.html import conditional_escape, format_html
from django.utils.translation import gettext_lazy as _
from pretix.base.modelimport_orders import get_order_import_columns
@@ -66,7 +66,7 @@ class ProcessForm(forms.Form):
widget=forms.Select(
attrs={'data-static': 'true'}
),
help_text=c.help_text,
help_text=conditional_escape(c.help_text),
)
def get_columns(self):
+2 -2
View File
@@ -364,7 +364,7 @@ class TeamForm(forms.ModelForm):
for opt in pg.options
],
label=pg.label,
help_text=pg.help_text,
help_text=conditional_escape(pg.help_text),
initial=initial,
widget=forms.RadioSelect,
)
@@ -389,7 +389,7 @@ class TeamForm(forms.ModelForm):
for opt in pg.options
],
label=pg.label,
help_text=pg.help_text,
help_text=conditional_escape(pg.help_text),
initial=initial,
widget=forms.RadioSelect,
)
+2 -2
View File
@@ -435,10 +435,10 @@ class SubEventMetaValueForm(forms.ModelForm):
if self.disabled:
self.fields['value'].widget.attrs['readonly'] = 'readonly'
def clean_slug(self):
def clean_value(self):
if self.disabled:
return self.instance.value if self.instance else None
return self.cleaned_data['slug']
return self.cleaned_data['value']
class Meta:
model = SubEventMetaValue
+2 -1
View File
@@ -44,6 +44,7 @@ from django.db.models import Count, F, Max
from django.db.models.functions import Upper
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.html import escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField
@@ -176,7 +177,7 @@ class VoucherForm(I18nModelForm):
required=False,
widget=forms.TextInput(attrs={'data-seat-guid-field': '1'}),
initial=self.instance.seat.seat_guid if self.instance.seat else '',
help_text=str(self.instance.seat) if self.instance.seat else '',
help_text=escape(str(self.instance.seat) if self.instance.seat else ''),
)
def parse_itemvar(self, data):
+8
View File
@@ -717,6 +717,10 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.organizer.outgoingmails.retried': _('Failed emails have been scheduled to be retried.'),
'pretix.organizer.outgoingmails.aborted': _('Queued emails have been aborted.'),
'pretix.property.created': _('An organizer meta property has been created.'),
'pretix.property.deleted': _('An organizer meta property has been deleted.'),
'pretix.property.changed': _('An organizer meta property has been changed.'),
'pretix.property.reordered': _('An organizer meta property has been reordered.'),
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'),
@@ -774,6 +778,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
'pretix.user.settings.2fa.emergency': _('A two-factor emergency code has been generated.'),
'pretix.user.settings.2fa.resetdrift': _('Drift and throttle values for two-factor devices have been reset.'),
'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to '
'your account.'),
'pretix.user.settings.2fa.device.deleted': _('The two-factor authentication device "{name}" has been removed '
@@ -861,6 +866,9 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
'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.'),
'pretix.event.questionnaire.added': _('A questionnaire has been created.'),
'pretix.event.questionnaire.deleted': _('A questionnaire has been deleted.'),
'pretix.event.questionnaire.changed': _('A questionnaire has been changed.'),
'pretix.event.permissions.added': _('A user has been added to the event team.'),
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
+2 -2
View File
@@ -85,8 +85,8 @@ class PermissionMiddleware:
"user.settings.2fa.enable",
"user.settings.2fa.disable",
"user.settings.2fa.regenemergency",
"user.settings.2fa.confirm.totp",
"user.settings.2fa.confirm.webauthn",
"user.settings.2fa.confirm.otp_totp.totpdevice",
"user.settings.2fa.confirm.pretixbase.webauthndevice",
"user.settings.2fa.delete",
"user.settings.2fa.leaveteams",
"auth.logout",
+3 -3
View File
@@ -182,12 +182,12 @@ def get_event_navigation(request: HttpRequest):
'active': 'event.items.categories' in url.url_name,
},
{
'label': _('Questions'),
'url': reverse('control:event.items.questions', kwargs={
'label': _('Questionnaires'),
'url': reverse('control:event.items.questionnaires', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.items.questions' in url.url_name,
'active': 'event.items.questionnaires' in url.url_name or 'event.items.questions' in url.url_name,
},
{
'label': _('Discounts'),
@@ -45,8 +45,8 @@
</p>
<div class="form-group buttons">
<input type="submit" class="btn btn-large btn-default" value="Cancel"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="Authorize"/>
<input type="submit" class="btn btn-large btn-default" value="{% trans "Cancel" %}"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="{% trans "Authorize" %}"/>
</div>
</form>
{% else %}
@@ -94,13 +94,6 @@
</div>
</div>
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
{% bootstrap_field sform.attendee_names_asked_required layout="control" %}
{% bootstrap_field sform.attendee_emails_asked_required layout="control" %}
{% bootstrap_field sform.attendee_company_asked_required layout="control" %}
{% bootstrap_field sform.attendee_addresses_asked_required layout="control" %}
<div class="form-group">
<label class="control-label col-md-3">
{% trans "Custom fields" %}
@@ -109,11 +102,27 @@
<p>
<a href="{% url "control:event.items.questions" event=request.event.slug organizer=request.organizer.slug %}"
target="_blank">
{% trans "Manage questions" %}
{% trans "Manage questionnaires" %}
</a>
</p>
</div>
</div>
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
<div class="form-group">
<label class="control-label col-md-3">
</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">
{% trans "Manage questionnaires" %}
</a>
</p>
</div>
</div>
{% bootstrap_field sform.attendee_data_explanation_text layout="control" %}
<h4>{% trans "Form settings" %}</h4>
@@ -33,7 +33,7 @@
</div>
<div class="slug-length alert alert-warning helper-display-none-soft">
{% blocktrans trimmed %}
We strongly recommend against using short forms of more then 16 characters.
We strongly recommend against using short forms of more than 16 characters.
{% endblocktrans %}
</div>
</div>
@@ -64,47 +64,7 @@
</div>
</div>
<div class="form-group" data-display-dependency="#admission_on">
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
<div class="col-md-9">
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-id-card-o"></span>
<strong>{% trans "Personalized ticket" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
When this ticket is purchased, the system will ask for a name or other details according
to your event settings.
{% endblocktrans %}
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
<br>
<span class="text-warning">
<span class="fa fa-warning" aria-hidden="true"></span>
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
</span>
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
{% endif %}
</div>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-circle-o"></span>
<strong>{% trans "Non-personalized ticket" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
The system will not ask for a name or other attendee details. This only affects
system-provided fields, you can still add your own questions.
{% endblocktrans %}
</div>
</label>
</div>
</div>
</div>
{% bootstrap_field form.questionnaires layout="control" %}
{% bootstrap_field form.category layout="control" %}
<div class="form-group">
@@ -72,51 +72,7 @@
</div>
</div>
<div class="form-group" data-display-dependency="#admission_on">
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
<div class="col-md-9">
{% for e in form.errors.personalized %}
<div class="alert alert-danger has-error">
{{ e }}
</div>
{% endfor %}
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-id-card-o"></span>
<strong>{% trans "Personalized ticket" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
When this ticket is purchased, the system will ask for a name or other details according
to your event settings.
{% endblocktrans %}
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
<br>
<span class="text-warning">
<span class="fa fa-warning" aria-hidden="true"></span>
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
</span>
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
{% endif %}
</div>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-file-text-o"></span>
<strong>{% trans "Non-personalized ticket" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
The system will not ask for a name or other attendee details. This only affects
system-provided fields, you can still add your own questions.
{% endblocktrans %}
</div>
</label>
</div>
</div>
</div>
{% bootstrap_field form.questionnaires layout="control" %}
{% bootstrap_field form.description layout="control" %}
{% bootstrap_field form.picture layout="control" %}
@@ -3,10 +3,10 @@
{% load bootstrap3 %}
{% load escapejson %}
{% load formset_tags %}
{% block title %}{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}{% endblock %}
{% block title %}{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}{% endblock %}
{% block inside %}
<h1>
{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}
{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" event=request.event.slug organizer=request.event.organizer.slug question=question.pk %}"
class="btn btn-default">
@@ -1,16 +1,16 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete question" %}{% endblock %}
{% block title %}{% trans "data field" %}{% endblock %}
{% block inside %}
<h1>{% trans "Delete question" %}</h1>
<h1>{% trans "Delete data field" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to delete the question <strong>{{ question }}</strong>?{% endblocktrans %}</p>
<p>{% blocktrans %}Are you sure you want to delete the data field <strong>{{ question }}</strong>?{% endblocktrans %}</p>
{% if dependent|length > 0 %}
<div class="alert alert-warning">
<p>{% blocktrans %}All answers to the question given by the buyers of the following products will be <strong>lost</strong>.{% endblocktrans %}
{% blocktrans with url=edit_url|add:"#tab-0-1-open" %}If you want to keep the answers, <a href="{{url}}">edit the question</a> and set it to hidden.{% endblocktrans %}
{% blocktrans with url=edit_url|add:"#tab-0-1-open" %}If you want to keep the answers, <a href="{{url}}">edit the data field</a> and set it to hidden.{% endblocktrans %}
</p>
</div>
<ul>
@@ -24,7 +24,7 @@
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete question and all answers" %}
{% trans "Delete data field and all answers" %}
</button>
</div>
</form>
@@ -5,158 +5,126 @@
{% load escapejson %}
{% block title %}
{% if question %}
{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}
{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}
{% else %}
{% trans "Question" %}
{% trans "Create new data field" %}
{% endif %}
{% endblock %}
{% block inside %}
{% if question %}
<h1>{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}</h1>
<h1>{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}</h1>
{% else %}
<h1>{% trans "Question" %}</h1>
<h1>{% trans "Create new data field" %}</h1>
{% endif %}
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
<div class="tabbed-form">
<fieldset>
<legend>{% trans "General" %}</legend>
{% bootstrap_field form.question layout="control" %}
{% bootstrap_field form.type layout="control" %}
{% if form.items %}
{% bootstrap_field form.items layout="control" %}
{% endif %}
{% bootstrap_field form.required layout="control" %}
<div class="alert alert-info alert-required-boolean">
{% blocktrans trimmed %}
If you mark a Yes/No question as required, it means that the user has to select Yes and No is not
accepted. If you want to allow both options, do not make this field required.
{% endblocktrans %}
</div>
<div id="valid-number">
{% bootstrap_field form.valid_number_min layout="control" %}
{% bootstrap_field form.valid_number_max layout="control" %}
</div>
<div id="valid-date">
{% bootstrap_field form.valid_date_min layout="control" %}
{% bootstrap_field form.valid_date_max layout="control" %}
</div>
<div id="valid-datetime">
{% bootstrap_field form.valid_datetime_min layout="control" %}
{% bootstrap_field form.valid_datetime_max layout="control" %}
</div>
<div id="valid-string">
{% bootstrap_field form.valid_string_length_min layout="control" %}
{% bootstrap_field form.valid_string_length_max layout="control" %}
</div>
<div id="valid-file">
{% bootstrap_field form.valid_file_portrait layout="control" %}
</div>
<div id="answer-options">
<h3>{% trans "Answer options" %}</h3>
<noscript>
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
</noscript>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% blocktrans trimmed with id=form.instance.identifier %}
Answer option {{ id }}
{% endblocktrans %}
</span>
{% bootstrap_form_errors form %}
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% trans "New answer option" %}
</span>
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
</p>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Advanced" %}</legend>
{% bootstrap_field form.help_text layout="control" %}
{% bootstrap_field form.identifier layout="control" %}
{% if form.ask_during_checkin %}
{% bootstrap_field form.ask_during_checkin layout="control" %}
{% endif %}
{% if form.show_during_checkin %}
{% bootstrap_field form.show_during_checkin layout="control" %}
{% endif %}
{% bootstrap_field form.hidden layout="control" %}
{% if form.print_on_invoice %}
{% bootstrap_field form.print_on_invoice layout="control" %}
{% endif %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_dependency_question">
{% trans "Question dependency" %}
<br><span class="optional">{% trans "Optional" context "form" %}</span>
</label>
<div class="col-md-4">
{% bootstrap_field form.dependency_question layout="inline" form_group_class="inner" %}
</div>
<div class="col-md-5">
<script type="text/plain" id="dependency_value_val">{{ form.instance.dependency_values|escapejson_dumps }}</script>
{% bootstrap_field form.dependency_values layout="inline" form_group_class="inner" %}
</div>
</div>
</fieldset>
{% bootstrap_field form.question layout="control" %}
{% bootstrap_field form.type layout="control" %}
<div id="valid-number">
{% bootstrap_field form.valid_number_min layout="control" %}
{% bootstrap_field form.valid_number_max layout="control" %}
</div>
<div id="valid-date">
{% bootstrap_field form.valid_date_min layout="control" %}
{% bootstrap_field form.valid_date_max layout="control" %}
</div>
<div id="valid-datetime">
{% bootstrap_field form.valid_datetime_min layout="control" %}
{% bootstrap_field form.valid_datetime_max layout="control" %}
</div>
<div id="valid-string">
{% bootstrap_field form.valid_string_length_min layout="control" %}
{% bootstrap_field form.valid_string_length_max layout="control" %}
</div>
<div id="valid-file">
{% bootstrap_field form.valid_file_portrait layout="control" %}
</div>
<div id="answer-options">
<h3>{% trans "Answer options" %}</h3>
<noscript>
<p>{% trans "Only applicable if you choose 'Choose one/multiple from a list' above." %}</p>
</noscript>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" data-formset-delete-confirm-text="{% trans "If you delete an answer option, you will no longer be able to see statistical data on customers who previously selected this option, and when such customers edit their answers, they need to select a different option." %}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% blocktrans trimmed with id=form.instance.identifier %}
Answer option {{ id }}
{% endblocktrans %}
</span>
{% bootstrap_form_errors form %}
{% bootstrap_field form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="row question-option-row">
<div class="col-xs-10">
<span class="text-muted">
{% trans "New answer option" %}
</span>
{% bootstrap_field formset.empty_form.answer layout='inline' form_group_class="" %}
</div>
<div class="col-xs-2 text-right flip">
<span>&nbsp;</span><br>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add a new option" %}</button>
</p>
</div>
</div>
{% bootstrap_field form.identifier layout="control" %}
{% if form.show_during_checkin %}
{% bootstrap_field form.show_during_checkin layout="control" %}
{% endif %}
{% if form.hidden %}
{% bootstrap_field form.hidden layout="control" %}
{% endif %}
{% if form.print_on_invoice %}
{% bootstrap_field form.print_on_invoice layout="control" %}
{% endif %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
@@ -0,0 +1,64 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load static %}
{% load icon %}
{% load compress %}
{% load vite %}
{% block title %}
{% trans "Questionnaires" %}
{% endblock %}
{% block inside %}
<h1>
{% trans "Questionnaires" %}
<a href="{% url "control:event.items.questions" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default pull-right">
{% icon "wrench" %} {% trans "Manage data fields" %}
</a>
</h1>
<p>
{% blocktrans trimmed %}
Questionaires allow your attendees to fill in additional data about their ticket. If you provide food, one
example might be to ask your users about preferences or dietary requirements.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
Each questionnaire can be assigned to one or more products for which its questions are asked. It can appear
either during the checkout process, in the ticket details for each attendee individually, or at the check-in
at the venue.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
The answer to each question is stored either in a system-provided attendee data field (such as name, email,
or postal address), or in a user-defined data field attached to the order or the attendee.
{% endblocktrans %}
{% blocktrans trimmed %}
User-defined data fields can be created from the questionnaire editor below, and managed using the button to the right.
{% endblocktrans %}
</p>
<br>
{{ request.event.settings.locales|json_script:"event_locales" }}
{{ questionnaire_type_choices|json_script:"questionnaire_type_choices" }}
{{ question_type_choices|json_script:"question_type_choices" }}
{{ system_question_choices|json_script:"system_question_choices" }}
{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_edit_url %}
{{ datafield_edit_url|json_script:"datafield_edit_url" }}
{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_view_url %}
{{ datafield_view_url|json_script:"datafield_view_url" }}
{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug as datafield_create_url %}
{{ datafield_create_url|json_script:"datafield_create_url" }}
<div id="questionnaires-editor">
<!-- Vue app mount point -->
<center><div class="big-load-indicator"><i class="fa fa-cog big-rotating-icon"></i></div></center>
</div>
{% vite_hmr %}
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/questionnaires/index.ts" %}
{% endblock %}
@@ -1,8 +1,8 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% block title %}{% trans "Questions" %}{% endblock %}
{% block title %}{% trans "Data fields" %}{% endblock %}
{% block inside %}
<h1>{% trans "Questions" %}</h1>
<h1>{% trans "Data fields" %}</h1>
<p>
{% blocktrans trimmed %}
Questions allow your attendees to fill in additional data about their ticket. If you provide food, one
@@ -11,63 +11,40 @@
</p>
{% csrf_token %}
{% if request.event.settings.feature_flag_order_level_questions %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket question" %}
</a>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level question" %}
</a>
</p>
{% endif %}
<h2>{% trans "Per-ticket questions" %}</h2>
<p>{% trans "These questions are asked for every ticket, so possibly multiple times in the same order." %}</p>
{% else %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new question" %}
</a>
</p>
{% endif %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new order-level data field" %}
</a>
<a href="{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new per-ticket data field" %}
</a>
</p>
{% endif %}
<h2>{% trans "Per-order data fields" %}</h2>
<p>{% trans "These data fields can be used on orders." %}</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Question" %}</th>
<th>{% trans "Internal name" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th>{% trans "Products" %}</th>
{% if 'event.items:write' in request.eventpermset %}
<th class="action-col-2"></th>
{% endif %}
<th class="action-col-2"></th>
</tr>
</thead>
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=P">
{% for q in questions %}{% if q.container_type == "P" %}
<tr data-dnd-id="{{ q.id }}">
<tbody>
{% for q in questions %}{% if q.container_type == "O" %}
<tr>
<td>
<strong>
{% if q.pk %}
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
{% endif %}
{{ q.question }}
{% if q.pk %}
</a>
{% endif %}
</strong><br>
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{% if q.pk %}
{{ q.get_type_display }}
{% else %}
{% trans "System question" %}
{% endif %}
{{ q.get_type_display }}
</td>
<td>
{% if q.required %}
@@ -84,26 +61,8 @@
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk %}
<ul>
{% for item in q.items.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>
{% else %}
<small>{% trans "All personalized products" %}</small>
{% endif %}
</td>
{% if 'event.items:write' in request.eventpermset %}
<td class="dnd-container">
</td>
{% endif %}
<td class="text-right flip">
{% if q.pk %}
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
@@ -121,85 +80,61 @@
</table>
</div>
{% if request.event.settings.feature_flag_order_level_questions %}
<h2>
{% trans "Per-order questions" %}
<small><span class="label label-info" title="
{% trans "This functionality is in active development and expected to change significantly over the coming months." %}
{% trans "Per-order questions are currently not supported and will not be displayed in pretixPOS." %}
" data-toggle="tooltip">
<span class="fa fa-flask" aria-hidden="true"></span>
{% trans "Experimental feature" %}
</span></small>
</h2>
<p>{% trans "These questions are asked once per order." %}</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<h2>{% trans "Per-ticket data fields" %}</h2>
<p>{% trans "These data field can be used on individual tickets." %}</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Internal name" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for q in questions %}{% if q.container_type == "P" %}
<tr>
<th>{% trans "Question" %}</th>
<th>{% trans "Type" %}</th>
<th class="iconcol"></th>
<th class="iconcol"></th>
<th class="iconcol"></th>
{% if 'event.items:write' in request.eventpermset %}
<th class="action-col-2"></th>
{% endif %}
<th class="action-col-2"></th>
</tr>
</thead>
<tbody data-dnd-url="{% url "control:event.items.questions.reorder" organizer=request.event.organizer.slug event=request.event.slug %}?container_type=O">
{% for q in questions %}{% if q.container_type == "O" %}
<tr data-dnd-id="{{ q.id }}">
<td>
<strong>
<td>
<strong>
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
{{ q.question }}
</strong><br>
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{% if q.pk %}
{{ q.get_type_display }}
{% else %}
{% trans "System question" %}
{% endif %}
</td>
<td>
{% if q.required %}
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk and q.ask_during_checkin %}
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk and q.hidden %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
{% if 'event.items:write' in request.eventpermset %}
<td class="dnd-container">
</td>
</a>
</strong><br>
<small class="text-muted">{{ q.identifier }}</small>
</td>
<td>
{{ q.get_type_display }}
</td>
<td>
{% if q.required %}
<span class="fa fa-exclamation-circle text-muted" data-toggle="tooltip" title="{% trans "Required question" %}"></span>
{% endif %}
<td class="text-right flip">
{% if q.pk %}
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
{% else %}
{% if 'event.settings.general:write' in request.eventpermset %}
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-sm"><i class="fa fa-wrench"></i></a>
{% endif %}
{% endif %}
</td>
</tr>
{% endif %}{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</td>
<td>
{% if q.pk and q.ask_during_checkin %}
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
{% endif %}
</td>
<td>
{% if q.pk and q.hidden %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
{% endif %}
</td>
<td class="text-right flip">
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-bar-chart"></i></a>
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.questions.delete" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td>
</tr>
{% endif %}{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "error.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load static %}
{% block content %}
<h1>{% trans "Please wait..." %}</h1>
{{ notify_info|json_script:"notify_info" }}
<script type="text/javascript" src="{% static "pretixcontrol/js/notify_parent.js" %}"></script>
{% endblock %}
@@ -20,8 +20,10 @@
{% endif %}
{% elif question.type == "M" %}
{{ answer.to_string_i18n|rich_text_snippet }}
{% elif question.type %}
{{ answer.to_string_i18n|rich_text_snippet }}
{% else %}
{{ answer.to_string_i18n|linebreaksbr }}
{{ answer|linebreaksbr }}
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
@@ -610,60 +610,9 @@
{% endif %}
{% if line.has_questions %}
<dl>
{% if line.item.ask_attendee_data and event.settings.attendee_names_asked %}
<dt>{% trans "Attendee name" %}</dt>
<dd>{% if line.attendee_name %}{{ line.attendee_name_all_components }}{% else %}
<em>{% trans "not answered" %}</em>{% endif %}</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_emails_asked %}
<dt>{% trans "Attendee email" %}</dt>
<dd>
{% if line.attendee_email %}
{{ line.attendee_email }}
{% if not line.addon_to %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
{% csrf_token %}
<a href="{% url "control:event.order.position.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}"
class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_company_asked %}
<dt>
{% trans "Attendee company" %}
</dt>
<dd>
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_addresses_asked %}
<dt>
{% trans "Attendee address" %}
</dt>
<dd>
{% if line.street or line.zipcode or line.city or line.country %}
{{ line.street|default_if_none:""|linebreaksbr }}<br>
{{ line.zipcode|default_if_none:"" }} {{ line.city|default_if_none:"" }}<br>
{% if line.state %}{{ line.state_for_address }}<br>{% endif %}
{{ line.country.name|default_if_none:"" }}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endif %}
{% for q in line.questions %}
<dt>
{{ q.question }}
{{ q.label }}
{% if q.ask_during_checkin %}
<span class="fa fa-qrcode text-muted"
data-toggle="tooltip"
@@ -672,7 +621,49 @@
{% endif %}
</dt>
<dd>
{# {% if q.answer %}#}
{# {% if q.answer.file %}#}
{# <span class="fa fa-file"></span>#}
{# <a href="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}">#}
{# {{ q.answer.file_name }}#}
{# </a>#}
{# <span class="label label-danger" data-toggle="tooltip"#}
{# title="{% trans "This file has been uploaded by a user and could contain viruses or other malicious content." %}">#}
{# {% trans "UNSAFE" %}#}
{# </span>#}
{# {% if q.answer.is_image %}#}
{# <br>#}
{# <a href="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}" data-lightbox="order"#}
{# class="answer-thumb">#}
{# <img src="{{ q.answer.backend_file_url }}?token={% answer_token request q.answer %}">#}
{# </a>#}
{# {% endif %}#}
{# {% elif q.type == "M" %}#}
{# {{ q.answer.to_string_i18n|rich_text_snippet }}#}
{# {% elif q.type %}#}
{# {{ q.answer.to_string_i18n|linebreaksbr }}#}
{# {% else %}<!-- TODO(questionnaires): proper separation of QuestionAnswer objects and system answers...... -->#}
{# {{ q.answer|linebreaksbr }}#}
{# {% endif %}#}
{# {% else %}#}
{# <em>{% trans "not answered" %}</em>#}
{# {% endif %}#}
{% include "pretixcontrol/order/fragment_question_answer.html" with request=request question=q answer=q.answer %}
{% if q.system_datafield == "attendee_email" and not line.addon_to %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
{% csrf_token %}
<a href="{% url "control:event.order.position.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}"
class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
</dd>
{% endfor %}
{% for q in line.additional_fields %}
@@ -15,7 +15,7 @@
<div>
<div class="big-radio radio">
<label>
<input type="radio" required value="totp" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "totp" %}checked{% endif %}>
<input type="radio" required value="otp_totp.totpdevice" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "otp_totp.totpdevice" %}checked{% endif %}>
<strong>{% trans "Smartphone with Authenticator app" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
@@ -26,7 +26,7 @@
</div>
<div class="big-radio radio">
<label>
<input type="radio" required value="webauthn" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "webauthn" %}checked{% endif %}>
<input type="radio" required value="pretixbase.webauthndevice" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "pretixbase.webauthndevice" %}checked{% endif %}>
<strong>{% trans "WebAuthn-compatible hardware token" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
@@ -116,14 +116,14 @@
{% for d in devices %}
<li class="list-group-item">
<a class="btn btn-danger btn-xs pull-right flip"
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
href="{% url "control:user.settings.2fa.delete" devicetype=d.model_label device=d.pk %}">
Delete
</a>
{% if d.devicetype == "totp" %}
{% if d.model_label == "otp_totp.totpdevice" %}
<span class="fa fa-mobile"></span>
{% elif d.devicetype == "webauthn" %}
{% elif d.model_label == "pretixbase.webauthndevice" %}
<span class="fa fa-usb"></span>
{% elif d.devicetype == "u2f" %}
{% elif d.model_label == "pretixbase.u2fdevice" %}
<span class="fa fa-usb"></span>
{% endif %}
{{ d.name }}
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load icon %}
{% block title %}{% trans "User" %}{% endblock %}
{% block content %}
<h1>{% trans "User" %} {{ user.email }}</h1>
@@ -59,8 +60,83 @@
{% bootstrap_field form.is_verified layout='control' %}
{% endif %}
{% bootstrap_field form.last_login layout='control' %}
{% bootstrap_field form.require_2fa layout='control' %}
{% bootstrap_field form.needs_password_change layout='control' %}
{% bootstrap_field form.require_2fa layout='control' %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="panel panel-default">
<div class="panel-heading">
<button class="btn btn-default btn-xs pull-right" type="submit" form="resetdriftthrottle">
{% trans "Reset drift and throttle" %}
</button>
<h3 class="panel-title">
{% trans "Available two-factor authentication methods" %}
</h3>
</div>
<table class="panel-body table table-hover">
{% for d in devices %}
<tr>
<td>
{% if d.model_label == 'otp_totp.totpdevice' %}
TOTP
{% elif d.model_label == 'pretixbase.u2fdevice' %}
U2F
{% elif d.model_label == 'pretixbase.webauthndevice' %}
WebAuthn
{% elif d.model_label == 'otp_static.staticdevice' %}
{% trans "Emergency tokens" %}
{% endif %}
{% if d.confirmed %}
{% icon "check" %}
{% else %}
{% icon "warning" %}
{% endif %}
</td>
<td>
{{ d.name }}
</td>
<td>
{% if d.throttling_failure_timestamp %}
{% blocktrans trimmed with date=d.throttling_failure_timestamp|date:"SHORT_DATETIME_FORMAT" count cnt=d.throttling_failure_count %}
1 failed attempt since {{ date }}
{% plural %}
{{ cnt }} failed attempts since {{ date }}
{% endblocktrans %}
<br>
{% endif %}
{% if d.throttling_enabled and not d.verify_is_allowed.0 %}
<strong>
{% blocktrans trimmed with date=d.verify_is_allowed.1.locked_until|date:"SHORT_DATETIME_FORMAT" %}
Currently locked until {{ date }}
{% endblocktrans %}
</strong>
<br>
{% endif %}
{% if d.model_label == 'otp_totp.totpdevice' %}
<small>
<code>step = {{ d.step }},
t0 = {{ d.t0 }},
digits = {{ d.digits }},
tolerance = {{ d.tolerance }},
drift = {{ d.drift }},
last_t = {{ d.last_t }}</code>
</small>
{% elif d.model_label == 'pretixbase.u2fdevice' %}
<small>
<code>sign_count = {{ d.sign_count }}</code>
</small>
{% elif d.model_label == 'otp_static.staticdevice' %}
<small>
<code>token_count = {{ d.token_set.count }}</code>
</small>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Team memberships" %}</legend>
@@ -102,4 +178,8 @@
</div>
</div>
</div>
<form action="{% url "control:users.resetdriftthrottle" id=user.pk %}" id="resetdriftthrottle" method="post">
{% csrf_token %}
</form>
{% endblock %}
+4 -3
View File
@@ -78,6 +78,7 @@ urlpatterns = [
re_path(r'^users/(?P<id>\d+)/impersonate$', users.UserImpersonateView.as_view(), name='users.impersonate'),
re_path(r'^users/(?P<id>\d+)/anonymize$', users.UserAnonymizeView.as_view(), name='users.anonymize'),
re_path(r'^users/(?P<id>\d+)/emergencytoken$', users.UserEmergencyTokenView.as_view(), name='users.emergencytoken'),
re_path(r'^users/(?P<id>\d+)/resetdriftthrottle$', users.Reset2FADriftThrottleView.as_view(), name='users.resetdriftthrottle'),
re_path(r'^pdf/editor/webfonts.css', pdf.FontsCSSView.as_view(), name='pdf.css'),
re_path(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
re_path(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
@@ -106,9 +107,9 @@ urlpatterns = [
re_path(r'^settings/2fa/regenemergency', user.User2FARegenerateEmergencyView.as_view(),
name='user.settings.2fa.regenemergency'),
re_path(r'^settings/2fa/totp/(?P<device>[0-9]+)/confirm', user.User2FADeviceConfirmTOTPView.as_view(),
name='user.settings.2fa.confirm.totp'),
name='user.settings.2fa.confirm.otp_totp.totpdevice'),
re_path(r'^settings/2fa/webauthn/(?P<device>[0-9]+)/confirm', user.User2FADeviceConfirmWebAuthnView.as_view(),
name='user.settings.2fa.confirm.webauthn'),
name='user.settings.2fa.confirm.pretixbase.webauthndevice'),
re_path(r'^settings/2fa/(?P<devicetype>[^/]+)/(?P<device>[0-9]+)/delete', user.User2FADeviceDeleteView.as_view(),
name='user.settings.2fa.delete'),
re_path(r'^settings/email/confirm$', user.UserEmailConfirmView.as_view(), name='user.settings.email.confirm'),
@@ -342,7 +343,6 @@ urlpatterns = [
name='event.items.categories.edit'),
re_path(r'^categories/add$', item.CategoryCreate.as_view(), name='event.items.categories.add'),
re_path(r'^questions/$', item.QuestionList.as_view(), name='event.items.questions'),
re_path(r'^questions/reorder$', item.reorder_questions, name='event.items.questions.reorder'),
re_path(r'^questions/(?P<question>\d+)/delete$', item.QuestionDelete.as_view(),
name='event.items.questions.delete'),
re_path(r'^questions/(?P<question>\d+)/$', item.QuestionView.as_view(),
@@ -350,6 +350,7 @@ urlpatterns = [
re_path(r'^questions/(?P<question>\d+)/change$', item.QuestionUpdate.as_view(),
name='event.items.questions.edit'),
re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
re_path(r'^questionnaires/$', item.QuestionnairesEditor.as_view(), name='event.items.questionnaires'),
re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
re_path(r'^quotas/bulk_action$', item.QuotaBulkAction.as_view(), name='event.items.quotas.bulkaction'),
re_path(r'^quotas/bulk_edit$', item.QuotaBulkUpdateView.as_view(), name='event.items.quotas.bulkedit'),
+19 -2
View File
@@ -22,9 +22,12 @@
import collections.abc
import warnings
from django.contrib import messages
from django.core.paginator import (
EmptyPage, PageNotAnInteger, UnorderedObjectListWarning,
)
from django.http.response import HttpResponseRedirect
from django.shortcuts import render
from django.utils.translation import gettext_lazy as _
from django.views.generic import edit
@@ -38,7 +41,21 @@ class EventBasedFormMixin:
return kwargs
class CreateView(EventBasedFormMixin, edit.CreateView):
class TellParentFormMixin:
def form_valid(self, form):
result = super().form_valid(form)
if self.request.GET.get('notify_parent') and isinstance(result, HttpResponseRedirect):
message_store = messages.get_messages(self.request)
msgs = [{'level': msg.level_tag, 'message': msg.message} for msg in message_store]
return render(self.request, 'pretixcontrol/notify_parent.html', {
'notify_info': {'object': self.object.pk, 'object_str': str(self.object), 'redirect_url': result.url, 'messages': msgs},
})
else:
return result
class CreateView(EventBasedFormMixin, TellParentFormMixin, edit.CreateView):
"""
Like Django's default CreateView, but passes the optional event
argument to the form. This is necessary for I18nModelForms to work
@@ -47,7 +64,7 @@ class CreateView(EventBasedFormMixin, edit.CreateView):
pass
class UpdateView(EventBasedFormMixin, edit.UpdateView):
class UpdateView(EventBasedFormMixin, TellParentFormMixin, edit.UpdateView):
"""
Like Django's default UpdateView, but passes the optional event
argument to the form. This is necessary for I18nModelForms to work
+41 -6
View File
@@ -35,6 +35,7 @@
import base64
import json
import logging
import math
import time
from urllib.parse import quote, urljoin, urlparse
@@ -50,11 +51,12 @@ from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, ngettext
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView
from django_otp import match_token
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from webauthn.helpers import generate_challenge
@@ -463,6 +465,7 @@ class Login2FAView(TemplateView):
token = request.POST.get('token', '').strip().replace(' ', '')
valid = False
retry_after = None
if 'webauthn_challenge' in self.request.session and token.startswith('{'):
challenge = self.request.session['webauthn_challenge']
@@ -518,12 +521,28 @@ class Login2FAView(TemplateView):
valid = True
break
else:
valid = match_token(self.user, token)
if isinstance(valid, StaticDevice):
with transaction.atomic():
for device in devices_for_user(self.user, for_verify=True):
if isinstance(device, StaticDevice) and len(token) < 12:
# If we enter a wrong TOTP token (which is 6 characters), do not even try if it is a valid
# emergency token, which will only "lock up" the StaticDevice due to the throttling plugin
# and just locks people out without security gain.
continue
if device.verify_token(token):
valid = True
break
elif hasattr(device, 'verify_is_allowed'):
verify_allowed, reason_dict = device.verify_is_allowed()
if not verify_allowed:
if not retry_after or reason_dict['locked_until'] > retry_after:
retry_after = reason_dict['locked_until']
else:
device = None
if isinstance(device, StaticDevice):
self.user.send_security_notice([
_("A recovery code for two-factor authentification was used to log in.")
])
if valid:
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
pretix_successful_logins.inc(1)
@@ -536,7 +555,23 @@ class Login2FAView(TemplateView):
return redirect('control:index')
else:
pretix_failed_logins.inc(1, reason="2fa")
messages.error(request, _('Invalid code, please try again.'))
msg = _('Invalid code, please try again.')
if retry_after:
seconds = (retry_after - now()).total_seconds()
minutes = seconds / 60
if minutes >= 1:
msg = ngettext(
'Invalid code. Please try again after waiting {value} minute.',
'Invalid code. Please try again after waiting {value} minutes.',
minutes,
).format(value=math.ceil(minutes))
elif seconds >= 1:
msg = ngettext(
'Invalid code. Please try again after waiting {value} second.',
'Invalid code. Please try again after waiting {value} seconds.',
seconds,
).format(value=math.ceil(seconds))
messages.error(request, msg)
return redirect('control:auth.login.2fa')
def get_context_data(self, **kwargs):
+5 -2
View File
@@ -402,9 +402,12 @@ def event_index_widgets_lazy(request, organizer, event):
except SubEvent.DoesNotExist:
pass
can_view_orders = request.user.has_event_permission(request.organizer, request.event, 'event.orders:read',
request=request)
widgets = []
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=False):
widgets.extend(result)
if can_view_orders:
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent, lazy=False):
widgets.extend(result)
return build_json_response(widgets)
+3
View File
@@ -1670,6 +1670,7 @@ class QuickSetupView(EventPermissionRequiredMixin, FormView):
user=self.request.user)
subevent = self.request.event.subevents.first()
questionnaire = self.request.event.questionnaires.first()
for i, f in enumerate(self.formset):
if f in self.formset.deleted_forms or not f.has_changed():
continue
@@ -1685,6 +1686,8 @@ class QuickSetupView(EventPermissionRequiredMixin, FormView):
position=i,
all_sales_channels=True,
)
if questionnaire:
item.questionnaires.add(questionnaire)
item.log_action('pretix.event.item.added', user=self.request.user, data=dict(f.cleaned_data))
if f.cleaned_data['quota'] or not form.cleaned_data['total_quota']:
quota = self.request.event.quotas.create(
+45 -80
View File
@@ -39,7 +39,7 @@ from itertools import groupby
from json.decoder import JSONDecodeError
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File
from django.db import models, transaction
from django.db.models import (
@@ -56,7 +56,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods
from django.views.generic import FormView, ListView, View
from django.views.generic import FormView, ListView, TemplateView, View
from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country
@@ -65,14 +65,15 @@ from pretix.api.serializers.item import (
ItemVariationSerializer,
)
from pretix.base.forms import I18nFormSet
from pretix.base.forms.questions import get_fake_attendee_questions
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
SeatCategoryMapping, Voucher,
OrderPosition, Question, QuestionAnswer, QuestionnaireRow,
QuestionOption, Quota, SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.models.items import (
ItemAddOn, ItemBundle, ItemMetaValue, Questionnaire,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability
@@ -94,6 +95,7 @@ from pretix.helpers.models import modelcopy
from ...helpers import GroupConcat
from ...helpers.compat import CompatDeleteView
from ...helpers.i18n import i18n_all_from_gettext
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
@@ -437,66 +439,7 @@ class QuestionList(ListView):
template_name = 'pretixcontrol/items/questions.html'
def get_queryset(self):
return self.request.event.questions.prefetch_related('items')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
questions = get_fake_attendee_questions(self.request.event.settings)
questions += list(ctx['questions'])
questions.sort(key=lambda q: q.position)
ctx['questions'] = questions
return ctx
@transaction.atomic
@event_permission_required("event.items:write")
@require_http_methods(["POST"])
def reorder_questions(request, organizer, event):
try:
ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
qs = request.event.questions.filter(container_type=request.GET['container_type'])
# filter system_questions - normal questions are int/digit, system_questions strings
custom_question_ids = [i for i in ids if i.isdigit()]
input_questions = list(qs.filter(id__in=custom_question_ids))
if len(input_questions) != len(custom_question_ids):
raise Http404(_("Some of the provided object ids are invalid."))
if len(input_questions) != qs.count():
raise Http404(_("Not all objects have been selected."))
for q in input_questions:
pos = ids.index(str(q.pk))
if pos != q.position: # Save unneccessary UPDATE queries
q.position = pos
q.save(update_fields=['position'])
q.log_action(
'pretix.event.question.reordered', user=request.user, data={
'position': pos,
}
)
if request.GET['container_type'] == Question.ContainerType.ORDERPOSITION:
system_question_order = {}
for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'):
if s in ids:
system_question_order[s] = ids.index(s)
else:
system_question_order[s] = -1
request.event.settings.system_question_order = system_question_order
request.event.log_action(
'pretix.event.settings', user=request.user, data={
'system_question_order': system_question_order,
}
)
return HttpResponse()
return self.request.event.questions.all()
class QuestionDelete(EventPermissionRequiredMixin, CompatDeleteView):
@@ -515,7 +458,7 @@ class QuestionDelete(EventPermissionRequiredMixin, CompatDeleteView):
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
context['dependent'] = list(self.get_object().items.all())
# TODO(questionnaires) context['dependent'] = list(self.get_object().items.all())
context['edit_url'] = reverse('control:event.items.questions.edit', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
@@ -614,7 +557,7 @@ class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView
question=self.object, orderposition__isnull=False,
)
qs = qs.filter(orderposition__in=opqs)
op_cnt = opqs.filter(item__in=self.object.items.all()).count()
op_cnt = 0 # TODO opqs.filter(item__in=self.object.items.all()).count()
if self.object.type == Question.TYPE_FILE:
qs = [
@@ -659,7 +602,7 @@ class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['items'] = self.object.items.exists()
# TODO(questionnaires) ctx['items'] = self.object.items.exists()
ctx['has_subevents'] = self.request.event.has_subevents
stats = self.get_answer_statistics()
ctx['stats'], ctx['total'] = stats
@@ -766,6 +709,22 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret
def textchoices_to_json(choices, event):
return [(c.name, c.value, i18n_all_from_gettext(c.label, event.settings.locales)) for c in choices]
class QuestionnairesEditor(EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
template_name = 'pretixcontrol/items/questionnaires.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['questionnaire_type_choices'] = textchoices_to_json(Questionnaire.QuestionnaireType, self.request.event)
ctx['system_question_choices'] = textchoices_to_json(QuestionnaireRow.SystemDatafield, self.request.event)
ctx['question_type_choices'] = textchoices_to_json(Question.FieldType, self.request.event)
return ctx
class QuotaQueryMixin:
@cached_property
@@ -1371,9 +1330,10 @@ class ItemCreate(EventPermissionRequiredMixin, MetaDataEditorMixin, CreateView):
initial['tax_rule'] = trs[0]
if self.copy_from:
fields = ('name', 'internal_name', 'category', 'admission', 'personalized', 'default_price', 'tax_rule')
fields = ('name', 'internal_name', 'category', 'admission', 'default_price', 'tax_rule')
for f in fields:
initial[f] = getattr(self.copy_from, f)
initial['questionnaires'] = self.copy_from.questionnaires.values_list('pk', flat=True)
initial['copy_from'] = self.copy_from
initial['has_variations'] = self.copy_from.variations.exists()
@@ -1407,7 +1367,7 @@ class ItemCreate(EventPermissionRequiredMixin, MetaDataEditorMixin, CreateView):
return super().form_invalid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx = super().get_context_data(**kwargs)
ctx['meta_forms'] = self.meta_forms
return ctx
@@ -1415,9 +1375,11 @@ class ItemCreate(EventPermissionRequiredMixin, MetaDataEditorMixin, CreateView):
self.object = None
form = self.get_form()
if form.is_valid() and all([f.is_valid() for f in self.meta_forms]):
return self.form_valid(form)
else:
return self.form_invalid(form)
try:
return self.form_valid(form)
except ValidationError as e:
form.add_error(None, e)
return self.form_invalid(form)
class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataEditorMixin, UpdateView):
@@ -1481,9 +1443,11 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
self.get_object()
form = self.get_form()
if self.is_valid(form) and all([f.is_valid() for f in self.meta_forms]):
return self.form_valid(form)
else:
return self.form_invalid(form)
try:
return self.form_valid(form)
except ValidationError as exc:
form.add_error(None, exc)
return self.form_invalid(form)
def save_formset(self, key, log_base, attr='item', order=True, serializer=None,
rm_verb='removed'):
@@ -1532,7 +1496,6 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
@transaction.atomic
def form_valid(self, form):
self.save_meta()
messages.success(self.request, _('Your changes have been saved.'))
change_data = {
k: form.cleaned_data.get(k)
@@ -1592,7 +1555,9 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
else:
v.save()
return super().form_valid(form)
response = super().form_valid(form)
messages.success(self.request, _('Your changes have been saved.'))
return response
def form_invalid(self, form):
messages.error(self.request, _('We could not save your changes. See below for details.'))
@@ -1605,7 +1570,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
return o
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx = super().get_context_data(**kwargs)
ctx['plugin_forms'] = self.plugin_forms
ctx['meta_forms'] = self.meta_forms
ctx['formsets'] = self.formsets
+23 -7
View File
@@ -600,7 +600,7 @@ class OrderDetail(OrderView):
'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type',
'discount',
).prefetch_related(
'item__questions', 'issued_gift_cards', 'owned_gift_cards', 'linked_media',
'item__questionnaires', 'issued_gift_cards', 'owned_gift_cards', 'linked_media', # TODO(questionnaires) : item__questions gibts nicht mehr
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
Prefetch('all_checkins', queryset=Checkin.all.select_related('list', 'gate').order_by('datetime')),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device').order_by('datetime')),
@@ -624,9 +624,9 @@ class OrderDetail(OrderView):
p.additional_fields or
(p.item.ask_attendee_data and self.request.event.settings.attendee_names_asked) or
(p.item.ask_attendee_data and self.request.event.settings.attendee_emails_asked) or
p.item.questions.all()
p.item.questionnaires.all()
)
p.cache_answers()
p.cache_answers(questionnaire_type=None, sales_channel=None)
p.order = self.order
positions.append(p)
@@ -1177,6 +1177,8 @@ class OrderRefundView(OrderView):
manual_value = formats.sanitize_separators(manual_value)
try:
manual_value = Decimal(manual_value)
if manual_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1206,6 +1208,8 @@ class OrderRefundView(OrderView):
giftcard_value = formats.sanitize_separators(giftcard_value)
try:
giftcard_value = Decimal(giftcard_value)
if giftcard_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1255,6 +1259,8 @@ class OrderRefundView(OrderView):
offsetting_value = formats.sanitize_separators(offsetting_value)
try:
offsetting_value = Decimal(offsetting_value)
if offsetting_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1271,6 +1277,9 @@ class OrderRefundView(OrderView):
if offset_order.event.currency != self.request.event.currency:
messages.error(self.request, _('You entered an order in an event with a different currency.'))
is_valid = False
if not self.request.user.has_event_permission(self.request.organizer, offset_order.event, 'event.orders:write', request=self.request):
messages.error(self.request, _('You entered an order in an event that you do not have access to.'))
is_valid = False
refunds.append(OrderRefund(
order=order,
payment=None,
@@ -1286,10 +1295,13 @@ class OrderRefundView(OrderView):
))
for identifier, prov in self.request.event.get_payment_providers().items():
# prof = process form, not a typo for prov(ider)
prof_value = self.request.POST.get(f'newrefund-{identifier}', '0') or '0'
prof_value = formats.sanitize_separators(prof_value)
try:
prof_value = Decimal(prof_value)
if prof_value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1313,6 +1325,8 @@ class OrderRefundView(OrderView):
value = formats.sanitize_separators(value)
try:
value = Decimal(value)
if value < Decimal("0.00"):
raise TypeError("Please do not use negative numbers")
except (DecimalException, TypeError):
messages.error(self.request, _('You entered an invalid number.'))
is_valid = False
@@ -1342,7 +1356,12 @@ class OrderRefundView(OrderView):
))
any_success = False
if refund_selected == full_refund and is_valid:
if refund_selected != full_refund:
messages.error(self.request, _('The refunds you selected do not match the selected total refund '
'amount.'))
is_valid = False
if is_valid:
for r in refunds:
r.save()
order.log_action('pretix.event.order.refund.created', {
@@ -1414,9 +1433,6 @@ class OrderRefundView(OrderView):
)
}))
return redirect(self.get_order_url())
else:
messages.error(self.request, _('The refunds you selected do not match the selected total refund '
'amount.'))
def post(self, *args, **kwargs):
if self.start_form.is_valid():
+4 -4
View File
@@ -778,9 +778,9 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["events"] = self.request.user.get_events_with_permission(
"event.settings.general:write", request=self.request
).filter(organizer=self.request.organizer)
# Assumption: Who has access to modify organizer settings may see all events and disable/enable plugins
# for them. Otherwise, inconsistent situations occur.
kwargs["events"] = self.request.organizer.events.all()
kwargs["initial"] = {
"events": self.request.organizer.events.filter(plugins__regex='(^|,)' + self.plugin.module + '(,|$)')
}
@@ -2202,7 +2202,7 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
owner=self.request.user,
timezone=str(get_current_timezone()),
)
if not self.scheduled:
if not self.scheduled and not self.scheduled_copy_from:
initial = {
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
"mail_template": gettext(
+5
View File
@@ -1276,6 +1276,11 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie
self._default_meta = self.request.event.meta_data
for p in self.request.organizer.meta_properties.all():
if p.protected and not self.request.user.has_organizer_permission(
self.request.organizer, 'organizer.settings.general:write', request=self.request
):
continue
inst = SubEventMetaValue(property=p)
if len(matches[p.id]) == 1 and matches[p.id][0]['c'] == total:
inst.value = matches[p.id][0]['value']
+15 -25
View File
@@ -59,6 +59,7 @@ from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.generic import FormView, ListView, TemplateView, UpdateView
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_scopes import scopes_disabled
@@ -85,7 +86,6 @@ from pretix.helpers.ratelimit import rate_limit, rate_limit_reset
from pretix.helpers.security import session_reauth
from pretix.helpers.u2f import websafe_encode
REAL_DEVICE_TYPES = (TOTPDevice, WebAuthnDevice, U2FDevice)
logger = logging.getLogger(__name__)
@@ -313,17 +313,7 @@ class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
except StaticDevice.DoesNotExist:
ctx['static_tokens_device'] = None
ctx['devices'] = []
for dt in REAL_DEVICE_TYPES:
objs = list(dt.objects.filter(user=self.request.user, confirmed=True))
for obj in objs:
if dt == TOTPDevice:
obj.devicetype = 'totp'
elif dt == U2FDevice:
obj.devicetype = 'u2f'
elif dt == WebAuthnDevice:
obj.devicetype = 'webauthn'
ctx['devices'] += objs
ctx['devices'] = [d for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)]
ctx['obligatory'] = None
if settings.PRETIX_OBLIGATORY_2FA is True:
@@ -342,9 +332,9 @@ class User2FADeviceAddView(RecentAuthenticationRequiredMixin, FormView):
template_name = 'pretixcontrol/user/2fa_add.html'
def form_valid(self, form):
if form.cleaned_data['devicetype'] == 'totp':
if form.cleaned_data['devicetype'] == 'otp_totp.totpdevice':
dev = TOTPDevice.objects.create(user=self.request.user, confirmed=False, name=form.cleaned_data['name'])
elif form.cleaned_data['devicetype'] == 'webauthn':
elif form.cleaned_data['devicetype'] == 'pretixbase.webauthndevice':
if not self.request.is_secure():
messages.error(self.request,
_('Security devices are only available if pretix is served via HTTPS.'))
@@ -364,11 +354,11 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
@cached_property
def device(self):
if self.kwargs['devicetype'] == 'totp':
if self.kwargs['devicetype'] == 'otp_totp.totpdevice':
return get_object_or_404(TOTPDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
elif self.kwargs['devicetype'] == 'webauthn':
elif self.kwargs['devicetype'] == 'pretixbase.webauthndevice':
return get_object_or_404(WebAuthnDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
elif self.kwargs['devicetype'] == 'u2f':
elif self.kwargs['devicetype'] == 'pretixbase.u2fdevice':
return get_object_or_404(U2FDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
def get_context_data(self, **kwargs):
@@ -386,7 +376,7 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
msgs = [
_('A two-factor authentication device has been removed from your account.')
]
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
if not any(d.confirmed for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)):
self.request.user.require_2fa = False
self.request.user.save()
self.request.user.log_action('pretix.user.settings.2fa.disabled', user=self.request.user)
@@ -461,7 +451,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
).first()
if credential_id_exists:
messages.error(request, _('This security device is already registered.'))
return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.pretixbase.webauthndevice', kwargs={
'device': self.device.pk
}))
@@ -475,7 +465,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
self.device.save()
self.request.user.log_action('pretix.user.settings.2fa.device.added', user=self.request.user, data={
'id': self.device.pk,
'devicetype': 'u2f',
'devicetype': 'pretixbase.webauthndevice',
'name': self.device.name,
})
notices = [
@@ -503,7 +493,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
except Exception:
messages.error(request, _('The registration could not be completed. Please try again.'))
logger.exception('WebAuthn registration failed')
return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.pretixbase.webauthndevice', kwargs={
'device': self.device.pk
}))
@@ -537,7 +527,7 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
self.request.user.log_action('pretix.user.settings.2fa.device.added', user=self.request.user, data={
'id': self.device.pk,
'name': self.device.name,
'devicetype': 'totp'
'devicetype': 'otp_totp.totpdevice'
})
notices = [
_('A new two-factor authentication device has been added to your account.')
@@ -563,7 +553,7 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
else:
messages.error(request, _('The code you entered was not valid. If this problem persists, please check '
'that the date and time of your phone are configured correctly.'))
return redirect(reverse('control:user.settings.2fa.confirm.totp', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.otp_totp.totpdevice', kwargs={
'device': self.device.pk
}))
@@ -594,7 +584,7 @@ class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_enable.html'
def dispatch(self, request, *args, **kwargs):
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
if not any(d.confirmed for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)):
messages.error(request, _('Please configure at least one device before enabling two-factor '
'authentication.'))
return redirect(reverse('control:user.settings.2fa'))
@@ -956,7 +946,7 @@ class UserEmailConfirmView(FormView):
@transaction.atomic()
def form_valid(self, form):
reason = self.request.GET['reason']
reason = self.request.GET.get('reason')
if reason not in ('email_change', 'email_verify'):
raise PermissionDenied
try:
+23
View File
@@ -40,6 +40,7 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import ListView, TemplateView
from django_otp import devices_for_user
from django_otp.plugins.otp_static.models import StaticDevice
from hijack import signals
@@ -107,6 +108,9 @@ class UserEditView(AdministratorPermissionRequiredMixin, RecentAuthenticationReq
ctx['backend'] = (
b[self.object.auth_backend].verbose_name if self.object.auth_backend in b else self.object.auth_backend
)
ctx['devices'] = devices_for_user(self.object)
return ctx
def get_success_url(self):
@@ -183,6 +187,25 @@ class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthent
return reverse('control:users.edit', kwargs=self.kwargs)
class Reset2FADriftThrottleView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, View):
def get(self, request, *args, **kwargs):
return redirect(reverse('control:users.edit', kwargs=self.kwargs))
def post(self, request, *args, **kwargs):
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
self.object.totpdevice_set.update(drift=0, throttling_failure_timestamp=None, throttling_failure_count=0)
self.object.staticdevice_set.update(throttling_failure_timestamp=None, throttling_failure_count=0)
self.object.log_action('pretix.user.settings.2fa.resetdrift', user=self.request.user)
messages.success(request, _(
'The drift values for TOTP devices have been reset.'
))
return redirect(self.get_success_url())
def get_success_url(self):
return reverse('control:users.edit', kwargs=self.kwargs)
class UserAnonymizeView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, TemplateView):
template_name = "pretixcontrol/users/anonymize.html"
+12
View File
@@ -32,6 +32,7 @@ from django.conf import settings
from django.utils import translation
from django.utils.formats import get_format
from django.utils.translation import to_locale
from i18nfield.strings import LazyI18nString
date_conversion_to_moment = {
'%a': 'ddd',
@@ -244,3 +245,14 @@ def parse_date_localized(date_str) -> Optional[datetime]:
except (ValueError, TypeError):
continue
return dt
def i18n_all_from_gettext(gettext_str, locales):
return i18n_all(LazyI18nString.from_gettext(gettext_str).data, locales)
def i18n_all(data, locales):
out = {}
for locale in locales:
out[locale] = data[locale]
return out
+17
View File
@@ -29,3 +29,20 @@ def chunked_iterable(iterable, size):
if not chunk:
break
yield chunk
def sort_groupby(data, group_key, inner_sort_key=None):
"""
Groups data by group_key, returns a list of (group_key, list). The outer list is
sorted by group_key, all inner lists are sorted by inner_sort_key.
"""
sort_key = group_key if inner_sort_key is None else lambda d: (group_key(d), inner_sort_key(d))
return [(group, list(items)) for group, items in itertools.groupby(sorted(data, key=sort_key), group_key)]
def sort_groupby_exp(data, group_key, inner_sort_key=None):
"""
Same as sort_groupby, but items are passed into the key functions as *item, so tuples can be destructed easily.
"""
sort_key = (lambda d: group_key(*d)) if inner_sort_key is None else lambda d: (group_key(*d), inner_sort_key(*d))
return [(group, list(items)) for group, items in itertools.groupby(sorted(data, key=sort_key), lambda d: group_key(*d))]
+18
View File
@@ -27,6 +27,7 @@ from http import cookies
from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation
from django.middleware.csrf import CsrfViewMiddleware as BaseCsrfMiddleware
from PIL import Image
from requests.adapters import HTTPAdapter
from urllib3.connection import HTTPConnection, HTTPSConnection
@@ -42,6 +43,7 @@ from urllib3.util.timeout import _DEFAULT_TIMEOUT
from pretix.helpers.reportlab import ThumbnailingImageReader
from pretix.helpers.ssrf import should_block_access
from pretix.multidomain.middlewares import CsrfViewMiddleware
def monkeypatch_vobject_performance():
@@ -242,6 +244,20 @@ def monkeypatch_reportlab_imagereader():
utils.ImageReader.__init__ = new_init
def monkeypatch_csrf_middleware():
# Some views from django or plugins use the middleware or the derived decorators directly,
# so we have to also patch the stock middleware
BaseCsrfMiddleware._get_secret = CsrfViewMiddleware._get_secret
BaseCsrfMiddleware._set_csrf_cookie = CsrfViewMiddleware._set_csrf_cookie
def monkeypatch_json_constants():
from json.decoder import _CONSTANTS # noqa
del _CONSTANTS['-Infinity']
del _CONSTANTS['Infinity']
del _CONSTANTS['NaN']
def monkeypatch_all_at_ready():
monkeypatch_vobject_performance()
monkeypatch_pillow_safer()
@@ -249,3 +265,5 @@ def monkeypatch_all_at_ready():
monkeypatch_urllib3_ssrf_protection()
monkeypatch_cookie_morsel()
monkeypatch_reportlab_imagereader()
monkeypatch_csrf_middleware()
monkeypatch_json_constants()
+346 -274
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-07-15 14:20+0000\n"
"Last-Translator: Llewelyn Barkhuizen <llewelyn@akademia.ac.za>\n"
"Language-Team: Afrikaans <https://translate.pretix.eu/projects/pretix/pretix/"
@@ -198,7 +198,7 @@ msgid "Allowed Post Logout URIs list, space separated"
msgstr ""
#: pretix/api/models.py pretix/base/models/customers.py
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
#: pretix/plugins/paypal2/payment.py
msgid "Client ID"
msgstr ""
@@ -1718,6 +1718,18 @@ msgstr ""
msgid "Payment providers"
msgstr ""
#: pretix/base/exporters/invoices.py pretix/base/models/orders.py
msgid "Transmission type"
msgstr ""
#: pretix/base/exporters/invoices.py
msgid "Transmission status"
msgstr ""
#: pretix/base/exporters/invoices.py
msgid "Transmission date"
msgstr ""
#: pretix/base/exporters/invoices.py
msgid "Cancellation"
msgstr ""
@@ -2361,7 +2373,6 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/organizers/giftcard.html
#: pretix/plugins/banktransfer/refund_export.py
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html
#: pretix/plugins/paypal/templates/pretixplugins/paypal/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html
#: pretix/plugins/stripe/templates/pretixplugins/stripe/control.html
@@ -2385,7 +2396,6 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
msgid "Quantity"
msgstr ""
@@ -2467,7 +2477,6 @@ msgstr ""
#: pretix/base/exporters/orderlist.py
#: pretix/control/templates/pretixcontrol/search/payments.html
#: pretix/plugins/paypal/templates/pretixplugins/paypal/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html
msgid "Payment ID"
msgstr ""
@@ -4225,6 +4234,10 @@ msgstr ""
msgid "Initialization date"
msgstr ""
#: pretix/base/models/devices.py
msgid "Security profile"
msgstr ""
#: pretix/base/models/discount.py
msgctxt "subevent"
msgid "Dates can be mixed without limitation"
@@ -5440,6 +5453,10 @@ msgstr ""
msgid "Maximum value"
msgstr ""
#: pretix/base/models/items.py
msgid "Minimum length"
msgstr ""
#: pretix/base/models/items.py
msgid "Maximum length"
msgstr ""
@@ -5506,6 +5523,10 @@ msgstr ""
msgid "The maximum value must not be lower than the minimum value."
msgstr ""
#: pretix/base/models/items.py
msgid "The maximum length must not be shorter than the minimum length."
msgstr ""
#: pretix/base/models/items.py
msgid ""
"The system already contains answers to this question that are not compatible "
@@ -5999,10 +6020,6 @@ msgstr ""
msgid "This reference will be printed on your invoice for your convenience."
msgstr ""
#: pretix/base/models/orders.py
msgid "Transmission type"
msgstr ""
#: pretix/base/models/orders.py
#: pretix/plugins/badges/templates/pretixplugins/badges/control_order_position_buttons.html
msgid "Badge"
@@ -9476,6 +9493,17 @@ msgid ""
"'Last date of payments' configured above."
msgstr ""
#: pretix/base/settings.py
msgid "Allow postponed payment choice for sales channels"
msgstr ""
#: pretix/base/settings.py
msgid ""
"If postponed payment is allowed on a sales channel, customers can complete "
"their order without selecting a payment method. This is useful whenever "
"orders are not created by the same person who is making the payment."
msgstr ""
#: pretix/base/settings.py
msgid "Show start date"
msgstr ""
@@ -9997,12 +10025,6 @@ msgstr ""
msgid "Hide all unavailable dates from calendar or list views"
msgstr ""
#: pretix/base/settings.py
msgid ""
"This option currently only affects the calendar of this event series, not "
"the organizer-wide calendar."
msgstr ""
#: pretix/base/settings.py
msgid "Hide all past dates from calendar"
msgstr ""
@@ -14691,6 +14713,11 @@ msgctxt "rrule"
msgid "third"
msgstr ""
#: pretix/control/forms/rrule.py
msgctxt "rrule"
msgid "fourth"
msgstr ""
#: pretix/control/forms/rrule.py
msgctxt "rrule"
msgid "last"
@@ -15674,6 +15701,22 @@ msgstr ""
msgid "Queued emails have been aborted."
msgstr ""
#: pretix/control/logdisplay.py
msgid "An organizer meta property has been created."
msgstr ""
#: pretix/control/logdisplay.py
msgid "An organizer meta property has been deleted."
msgstr ""
#: pretix/control/logdisplay.py
msgid "An organizer meta property has been changed."
msgstr ""
#: pretix/control/logdisplay.py
msgid "An organizer meta property has been reordered."
msgstr ""
#: pretix/control/logdisplay.py
msgid "Gift card acceptance for another organizer has been added."
msgstr ""
@@ -15876,6 +15919,10 @@ msgstr ""
msgid "A two-factor emergency code has been generated."
msgstr ""
#: pretix/control/logdisplay.py
msgid "Drift and throttle values for two-factor devices have been reset."
msgstr ""
#: pretix/control/logdisplay.py
#, python-brace-format
msgid ""
@@ -16708,6 +16755,87 @@ msgid ""
"Granting access to your pretix account happens at your own risk."
msgstr ""
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
#: pretix/control/templates/pretixcontrol/checkin/bulk_revert_confirm.html
#: pretix/control/templates/pretixcontrol/checkin/list_delete.html
#: pretix/control/templates/pretixcontrol/checkin/reset.html
#: pretix/control/templates/pretixcontrol/datasync/control_order_info.html
#: pretix/control/templates/pretixcontrol/email_setup_simple.html
#: pretix/control/templates/pretixcontrol/event/tax_delete.html
#: pretix/control/templates/pretixcontrol/item/delete.html
#: pretix/control/templates/pretixcontrol/items/category_delete.html
#: pretix/control/templates/pretixcontrol/items/discount_delete.html
#: pretix/control/templates/pretixcontrol/items/question_delete.html
#: pretix/control/templates/pretixcontrol/items/quota_delete.html
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html
#: pretix/control/templates/pretixcontrol/oauth/app_delete.html
#: pretix/control/templates/pretixcontrol/oauth/app_rollkeys.html
#: pretix/control/templates/pretixcontrol/oauth/auth_revoke.html
#: pretix/control/templates/pretixcontrol/order/change.html
#: pretix/control/templates/pretixcontrol/order/change_contact.html
#: pretix/control/templates/pretixcontrol/order/change_locale.html
#: pretix/control/templates/pretixcontrol/order/change_questions.html
#: pretix/control/templates/pretixcontrol/order/extend.html
#: pretix/control/templates/pretixcontrol/order/pay.html
#: pretix/control/templates/pretixcontrol/order/pay_complete.html
#: pretix/control/templates/pretixcontrol/order/reactivate.html
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
#: pretix/control/templates/pretixcontrol/order/refund_done.html
#: pretix/control/templates/pretixcontrol/order/refund_process.html
#: pretix/control/templates/pretixcontrol/order/refund_start.html
#: pretix/control/templates/pretixcontrol/orders/bulk_action.html
#: pretix/control/templates/pretixcontrol/orders/export_delete.html
#: pretix/control/templates/pretixcontrol/orders/refunds.html
#: pretix/control/templates/pretixcontrol/organizers/channel_delete.html
#: pretix/control/templates/pretixcontrol/organizers/customer_anonymize.html
#: pretix/control/templates/pretixcontrol/organizers/customer_membership_delete.html
#: pretix/control/templates/pretixcontrol/organizers/device_revoke.html
#: pretix/control/templates/pretixcontrol/organizers/export_delete.html
#: pretix/control/templates/pretixcontrol/organizers/gate_delete.html
#: pretix/control/templates/pretixcontrol/organizers/membershiptype_delete.html
#: pretix/control/templates/pretixcontrol/organizers/property_delete.html
#: pretix/control/templates/pretixcontrol/organizers/ssoclient_delete.html
#: pretix/control/templates/pretixcontrol/organizers/ssoprovider_delete.html
#: pretix/control/templates/pretixcontrol/organizers/team_delete.html
#: pretix/control/templates/pretixcontrol/pdf/index.html
#: pretix/control/templates/pretixcontrol/subevents/bulk_edit.html
#: pretix/control/templates/pretixcontrol/subevents/delete.html
#: pretix/control/templates/pretixcontrol/subevents/delete_bulk.html
#: pretix/control/templates/pretixcontrol/subevents/edit.html
#: pretix/control/templates/pretixcontrol/user/2fa_delete.html
#: pretix/control/templates/pretixcontrol/user/2fa_disable.html
#: pretix/control/templates/pretixcontrol/user/2fa_enable.html
#: pretix/control/templates/pretixcontrol/user/2fa_leaveteams.html
#: pretix/control/templates/pretixcontrol/user/2fa_regenemergency.html
#: pretix/control/templates/pretixcontrol/user/change_email.html
#: pretix/control/templates/pretixcontrol/user/change_password.html
#: pretix/control/templates/pretixcontrol/user/confirmation_code_dialog.html
#: pretix/control/templates/pretixcontrol/vouchers/delete.html
#: pretix/control/templates/pretixcontrol/vouchers/delete_bulk.html
#: pretix/control/templates/pretixcontrol/vouchers/delete_carts.html
#: pretix/control/templates/pretixcontrol/waitinglist/delete.html
#: pretix/control/templates/pretixcontrol/waitinglist/delete_bulk.html
#: pretix/control/templates/pretixcontrol/waitinglist/edit.html
#: pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/delete.html
#: pretix/plugins/badges/templates/pretixplugins/badges/delete.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/pay.html
#: pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_delete.html
#: pretix/plugins/stripe/templates/pretixplugins/stripe/sca.html
#: pretix/plugins/ticketoutputpdf/templates/pretixplugins/ticketoutputpdf/delete.html
#: pretix/presale/templates/pretixpresale/event/order_change.html
#: pretix/presale/templates/pretixpresale/event/order_modify.html
#: pretix/presale/templates/pretixpresale/event/order_pay.html
#: pretix/presale/templates/pretixpresale/event/order_pay_change.html
#: pretix/presale/templates/pretixpresale/event/order_pay_confirm.html
#: pretix/presale/templates/pretixpresale/event/position_change.html
#: pretix/presale/templates/pretixpresale/event/position_modify.html
msgid "Cancel"
msgstr ""
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
msgid "Authorize"
msgstr ""
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
msgid "Error:"
msgstr ""
@@ -17069,82 +17197,6 @@ msgid_plural ""
msgstr[0] ""
msgstr[1] ""
#: pretix/control/templates/pretixcontrol/checkin/bulk_revert_confirm.html
#: pretix/control/templates/pretixcontrol/checkin/list_delete.html
#: pretix/control/templates/pretixcontrol/checkin/reset.html
#: pretix/control/templates/pretixcontrol/datasync/control_order_info.html
#: pretix/control/templates/pretixcontrol/email_setup_simple.html
#: pretix/control/templates/pretixcontrol/event/tax_delete.html
#: pretix/control/templates/pretixcontrol/item/delete.html
#: pretix/control/templates/pretixcontrol/items/category_delete.html
#: pretix/control/templates/pretixcontrol/items/discount_delete.html
#: pretix/control/templates/pretixcontrol/items/question_delete.html
#: pretix/control/templates/pretixcontrol/items/quota_delete.html
#: pretix/control/templates/pretixcontrol/items/quota_delete_bulk.html
#: pretix/control/templates/pretixcontrol/oauth/app_delete.html
#: pretix/control/templates/pretixcontrol/oauth/app_rollkeys.html
#: pretix/control/templates/pretixcontrol/oauth/auth_revoke.html
#: pretix/control/templates/pretixcontrol/order/change.html
#: pretix/control/templates/pretixcontrol/order/change_contact.html
#: pretix/control/templates/pretixcontrol/order/change_locale.html
#: pretix/control/templates/pretixcontrol/order/change_questions.html
#: pretix/control/templates/pretixcontrol/order/extend.html
#: pretix/control/templates/pretixcontrol/order/pay.html
#: pretix/control/templates/pretixcontrol/order/pay_complete.html
#: pretix/control/templates/pretixcontrol/order/reactivate.html
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
#: pretix/control/templates/pretixcontrol/order/refund_done.html
#: pretix/control/templates/pretixcontrol/order/refund_process.html
#: pretix/control/templates/pretixcontrol/order/refund_start.html
#: pretix/control/templates/pretixcontrol/orders/bulk_action.html
#: pretix/control/templates/pretixcontrol/orders/export_delete.html
#: pretix/control/templates/pretixcontrol/orders/refunds.html
#: pretix/control/templates/pretixcontrol/organizers/channel_delete.html
#: pretix/control/templates/pretixcontrol/organizers/customer_anonymize.html
#: pretix/control/templates/pretixcontrol/organizers/customer_membership_delete.html
#: pretix/control/templates/pretixcontrol/organizers/device_revoke.html
#: pretix/control/templates/pretixcontrol/organizers/export_delete.html
#: pretix/control/templates/pretixcontrol/organizers/gate_delete.html
#: pretix/control/templates/pretixcontrol/organizers/membershiptype_delete.html
#: pretix/control/templates/pretixcontrol/organizers/property_delete.html
#: pretix/control/templates/pretixcontrol/organizers/ssoclient_delete.html
#: pretix/control/templates/pretixcontrol/organizers/ssoprovider_delete.html
#: pretix/control/templates/pretixcontrol/organizers/team_delete.html
#: pretix/control/templates/pretixcontrol/pdf/index.html
#: pretix/control/templates/pretixcontrol/subevents/bulk_edit.html
#: pretix/control/templates/pretixcontrol/subevents/delete.html
#: pretix/control/templates/pretixcontrol/subevents/delete_bulk.html
#: pretix/control/templates/pretixcontrol/subevents/edit.html
#: pretix/control/templates/pretixcontrol/user/2fa_delete.html
#: pretix/control/templates/pretixcontrol/user/2fa_disable.html
#: pretix/control/templates/pretixcontrol/user/2fa_enable.html
#: pretix/control/templates/pretixcontrol/user/2fa_leaveteams.html
#: pretix/control/templates/pretixcontrol/user/2fa_regenemergency.html
#: pretix/control/templates/pretixcontrol/user/change_email.html
#: pretix/control/templates/pretixcontrol/user/change_password.html
#: pretix/control/templates/pretixcontrol/user/confirmation_code_dialog.html
#: pretix/control/templates/pretixcontrol/vouchers/delete.html
#: pretix/control/templates/pretixcontrol/vouchers/delete_bulk.html
#: pretix/control/templates/pretixcontrol/vouchers/delete_carts.html
#: pretix/control/templates/pretixcontrol/waitinglist/delete.html
#: pretix/control/templates/pretixcontrol/waitinglist/delete_bulk.html
#: pretix/control/templates/pretixcontrol/waitinglist/edit.html
#: pretix/plugins/autocheckin/templates/pretixplugins/autocheckin/delete.html
#: pretix/plugins/badges/templates/pretixplugins/badges/delete.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/pay.html
#: pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_delete.html
#: pretix/plugins/stripe/templates/pretixplugins/stripe/sca.html
#: pretix/plugins/ticketoutputpdf/templates/pretixplugins/ticketoutputpdf/delete.html
#: pretix/presale/templates/pretixpresale/event/order_change.html
#: pretix/presale/templates/pretixpresale/event/order_modify.html
#: pretix/presale/templates/pretixpresale/event/order_pay.html
#: pretix/presale/templates/pretixpresale/event/order_pay_change.html
#: pretix/presale/templates/pretixpresale/event/order_pay_confirm.html
#: pretix/presale/templates/pretixpresale/event/position_change.html
#: pretix/presale/templates/pretixpresale/event/position_modify.html
msgid "Cancel"
msgstr ""
#: pretix/control/templates/pretixcontrol/checkin/bulk_revert_confirm.html
#: pretix/control/templates/pretixcontrol/checkin/list_delete.html
#: pretix/control/templates/pretixcontrol/event/delete.html
@@ -19256,7 +19308,7 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/events/create_basics.html
msgid ""
"We strongly recommend against using short forms of more then 16 characters."
"We strongly recommend against using short forms of more than 16 characters."
msgstr ""
#: pretix/control/templates/pretixcontrol/events/create_basics.html
@@ -24196,6 +24248,7 @@ msgid "Add a new device"
msgstr ""
#: pretix/control/templates/pretixcontrol/user/2fa_main.html
#: pretix/control/templates/pretixcontrol/users/form.html
msgid "Emergency tokens"
msgstr ""
@@ -24440,6 +24493,26 @@ msgstr ""
msgid "Authentication backend"
msgstr ""
#: pretix/control/templates/pretixcontrol/users/form.html
msgid "Reset drift and throttle"
msgstr ""
#: pretix/control/templates/pretixcontrol/users/form.html
msgid "Available two-factor authentication methods"
msgstr ""
#: pretix/control/templates/pretixcontrol/users/form.html
#, python-format
msgid "1 failed attempt since %(date)s"
msgid_plural "%(cnt)s failed attempts since %(date)s"
msgstr[0] ""
msgstr[1] ""
#: pretix/control/templates/pretixcontrol/users/form.html
#, python-format
msgid "Currently locked until %(date)s"
msgstr ""
#: pretix/control/templates/pretixcontrol/users/form.html
msgid "Team memberships"
msgstr ""
@@ -24893,6 +24966,20 @@ msgstr ""
msgid "Invalid code, please try again."
msgstr ""
#: pretix/control/views/auth.py
#, python-brace-format
msgid "Invalid code. Please try again after waiting {value} minute."
msgid_plural "Invalid code. Please try again after waiting {value} minutes."
msgstr[0] ""
msgstr[1] ""
#: pretix/control/views/auth.py
#, python-brace-format
msgid "Invalid code. Please try again after waiting {value} second."
msgid_plural "Invalid code. Please try again after waiting {value} seconds."
msgstr[0] ""
msgstr[1] ""
#: pretix/control/views/checkin.py
msgid "The selected check-ins have been reverted."
msgstr ""
@@ -25645,6 +25732,10 @@ msgstr ""
msgid "You entered an order in an event with a different currency."
msgstr ""
#: pretix/control/views/orders.py
msgid "You entered an order in an event that you do not have access to."
msgstr ""
#: pretix/control/views/orders.py
msgid ""
"You can not refund more than the amount of a payment that is not yet "
@@ -25657,6 +25748,10 @@ msgid ""
"refunds."
msgstr ""
#: pretix/control/views/orders.py
msgid "The refunds you selected do not match the selected total refund amount."
msgstr ""
#: pretix/control/views/orders.py
#, python-brace-format
msgid ""
@@ -25698,10 +25793,6 @@ msgid ""
"Your {event} team"
msgstr ""
#: pretix/control/views/orders.py
msgid "The refunds you selected do not match the selected total refund amount."
msgstr ""
#: pretix/control/views/orders.py
msgid "The payment has been created successfully."
msgstr ""
@@ -26413,6 +26504,10 @@ msgid ""
"remain active."
msgstr ""
#: pretix/control/views/users.py
msgid "The drift values for TOTP devices have been reset."
msgstr ""
#: pretix/control/views/users.py
msgid "The new user has been created."
msgstr ""
@@ -26425,6 +26520,14 @@ msgstr ""
msgid "Bypass quota"
msgstr ""
#: pretix/control/views/vouchers.py
msgid "Budget"
msgstr ""
#: pretix/control/views/vouchers.py
msgid "Budget used"
msgstr ""
#: pretix/control/views/vouchers.py
msgid "The requested voucher does not exist."
msgstr ""
@@ -26577,12 +26680,11 @@ msgstr ""
#: pretix/plugins/autocheckin/apps.py pretix/plugins/badges/apps.py
#: pretix/plugins/banktransfer/apps.py pretix/plugins/checkinlists/apps.py
#: pretix/plugins/manualpayment/apps.py pretix/plugins/paypal/apps.py
#: pretix/plugins/paypal2/apps.py pretix/plugins/pretixdroid/apps.py
#: pretix/plugins/reports/apps.py pretix/plugins/returnurl/apps.py
#: pretix/plugins/sendmail/apps.py pretix/plugins/statistics/apps.py
#: pretix/plugins/stripe/apps.py pretix/plugins/ticketoutputpdf/apps.py
#: pretix/plugins/webcheckin/apps.py
#: pretix/plugins/manualpayment/apps.py pretix/plugins/paypal2/apps.py
#: pretix/plugins/pretixdroid/apps.py pretix/plugins/reports/apps.py
#: pretix/plugins/returnurl/apps.py pretix/plugins/sendmail/apps.py
#: pretix/plugins/statistics/apps.py pretix/plugins/stripe/apps.py
#: pretix/plugins/ticketoutputpdf/apps.py pretix/plugins/webcheckin/apps.py
msgid "the pretix team"
msgstr ""
@@ -27133,7 +27235,6 @@ msgstr ""
#: pretix/plugins/banktransfer/refund_export.py
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/control.html
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_assign.html
#: pretix/plugins/paypal/templates/pretixplugins/paypal/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html
msgid "Payer"
@@ -27627,8 +27728,7 @@ msgstr ""
msgid "Automatically checked in"
msgstr ""
#: pretix/plugins/checkinlists/exporters.py pretix/plugins/paypal/payment.py
#: pretix/plugins/paypal2/payment.py
#: pretix/plugins/checkinlists/exporters.py pretix/plugins/paypal2/payment.py
msgid "Secret"
msgstr ""
@@ -27687,152 +27787,6 @@ msgstr ""
msgid "A fully customizable payment method for manual processing."
msgstr ""
#: pretix/plugins/paypal/apps.py pretix/plugins/paypal/payment.py
#: pretix/plugins/paypal2/apps.py pretix/plugins/paypal2/payment.py
#: pretix/plugins/stripe/payment.py
msgid "PayPal"
msgstr ""
#: pretix/plugins/paypal/apps.py
msgid ""
"Accept payments with your PayPal account. PayPal is one of the most popular "
"payment methods world-wide."
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid ""
"The PayPal sandbox is being used, you can test without actually sending "
"money but you will need a PayPal sandbox user to log in."
msgstr ""
#: pretix/plugins/paypal/payment.py
msgid "PayPal account"
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid "Click here for a tutorial on how to obtain the required keys"
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
#: pretix/plugins/stripe/payment.py
msgid "Endpoint"
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid "Reference prefix"
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid ""
"Any value entered here will be added in front of the regular booking "
"reference containing the order number."
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid "Reference postfix"
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid ""
"Any value entered here will be added behind the regular booking reference "
"containing the order number."
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid "Disconnect from PayPal"
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid "We had trouble communicating with PayPal"
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid ""
"We were unable to process your payment. See below for details on how to "
"proceed."
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid ""
"PayPal has not yet approved the payment. We will inform you as soon as the "
"payment completed."
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
#, python-brace-format
msgid "Refunding the amount via PayPal failed: {}"
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid "The payment for this invoice has already been received."
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid "PayPal payment ID"
msgstr ""
#: pretix/plugins/paypal/payment.py pretix/plugins/paypal2/payment.py
msgid "PayPal sale ID"
msgstr ""
#: pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_confirm.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/checkout_payment_confirm.html
msgid ""
"The total amount listed above will be withdrawn from your PayPal account "
"after the confirmation of your purchase."
msgstr ""
#: pretix/plugins/paypal/templates/pretixplugins/paypal/checkout_payment_form.html
msgid ""
"After you clicked continue, we will redirect you to PayPal to fill in your "
"payment details. You will then be redirected back here to review and confirm "
"your order."
msgstr ""
#: pretix/plugins/paypal/templates/pretixplugins/paypal/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html
msgid "Sale ID"
msgstr ""
#: pretix/plugins/paypal/templates/pretixplugins/paypal/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html
msgid "Last update"
msgstr ""
#: pretix/plugins/paypal/templates/pretixplugins/paypal/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html
#: pretix/plugins/stripe/templates/pretixplugins/stripe/control.html
msgid "Total value"
msgstr ""
#: pretix/plugins/paypal/templates/pretixplugins/paypal/pending.html
msgid ""
"Our attempt to execute your Payment via PayPal has failed. Please try again "
"or contact us."
msgstr ""
#: pretix/plugins/paypal/templates/pretixplugins/paypal/pending.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/pending.html
msgid ""
"We're waiting for an answer from PayPal regarding your payment. Please "
"contact us, if this takes more than a few hours."
msgstr ""
#: pretix/plugins/paypal/views.py pretix/plugins/paypal2/views.py
msgid "Invalid response from PayPal received."
msgstr ""
#: pretix/plugins/paypal/views.py pretix/plugins/paypal2/views.py
msgid "It looks like you canceled the PayPal payment"
msgstr ""
#: pretix/plugins/paypal/views.py pretix/plugins/paypal2/views.py
msgid "Your PayPal account has been disconnected."
msgstr ""
#: pretix/plugins/paypal2/apps.py
msgid ""
"Accept payments with your PayPal account. In addition to regular PayPal "
@@ -27842,10 +27796,23 @@ msgid ""
"methods world-wide."
msgstr ""
#: pretix/plugins/paypal2/apps.py pretix/plugins/paypal2/payment.py
#: pretix/plugins/stripe/payment.py
msgid "PayPal"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "PayPal Merchant ID"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "Click here for a tutorial on how to obtain the required keys"
msgstr ""
#: pretix/plugins/paypal2/payment.py pretix/plugins/stripe/payment.py
msgid "Endpoint"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid ""
"Even if a customer chooses an Alternative Payment Method, they will always "
@@ -27914,6 +27881,26 @@ msgid ""
"while the last payment is still under investigation."
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "Reference prefix"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid ""
"Any value entered here will be added in front of the regular booking "
"reference containing the order number."
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "Reference postfix"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid ""
"Any value entered here will be added behind the regular booking reference "
"containing the order number."
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "-- Automatic --"
msgstr ""
@@ -27934,6 +27921,10 @@ msgstr ""
msgid "Connect with {icon} PayPal"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "Disconnect from PayPal"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid ""
"Please configure a PayPal Webhook to the following endpoint in order to "
@@ -27962,6 +27953,12 @@ msgstr ""
msgid "An error occurred during connecting with PayPal, please try again."
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid ""
"The PayPal sandbox is being used, you can test without actually sending "
"money but you will need a PayPal sandbox user to log in."
msgstr ""
#: pretix/plugins/paypal2/payment.py
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/pending.html
msgid ""
@@ -27970,6 +27967,10 @@ msgid ""
"attempt. You can also try other payment methods, if available."
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "We had trouble communicating with PayPal"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid ""
"Something went wrong when requesting the payment status. Please try again."
@@ -27979,12 +27980,41 @@ msgstr ""
msgid "You may need to enable JavaScript for PayPal payments."
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid ""
"We were unable to process your payment. See below for details on how to "
"proceed."
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid ""
"PayPal has not yet approved the payment. We will inform you as soon as the "
"payment completed."
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid ""
"Refunding the amount via PayPal failed: The original payment does not "
"contain the required information to issue an automated refund."
msgstr ""
#: pretix/plugins/paypal2/payment.py
#, python-brace-format
msgid "Refunding the amount via PayPal failed: {}"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "The payment for this invoice has already been received."
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "PayPal payment ID"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "PayPal sale ID"
msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "PayPal APM"
msgstr ""
@@ -28072,6 +28102,12 @@ msgstr ""
msgid "We will then charge your PayPal account and finalize the order."
msgstr ""
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/checkout_payment_confirm.html
msgid ""
"The total amount listed above will be withdrawn from your PayPal account "
"after the confirmation of your purchase."
msgstr ""
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/checkout_payment_confirm.html
msgid ""
"After placing your order, you will be able to select your desired payment "
@@ -28103,6 +28139,11 @@ msgid ""
"different payment method, if you prefer."
msgstr ""
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html
msgid "Sale ID"
msgstr ""
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html
msgid "Capture status"
msgstr ""
@@ -28113,6 +28154,17 @@ msgid ""
"money will not be disbursed and the order remain in its pending state."
msgstr ""
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html
msgid "Last update"
msgstr ""
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control.html
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/control_legacy.html
#: pretix/plugins/stripe/templates/pretixplugins/stripe/control.html
msgid "Total value"
msgstr ""
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/pay.html
#: pretix/plugins/stripe/templates/pretixplugins/stripe/sca.html
#: pretix/plugins/stripe/templates/pretixplugins/stripe/sca_return.html
@@ -28146,6 +28198,12 @@ msgid ""
"to resolve this case."
msgstr ""
#: pretix/plugins/paypal2/templates/pretixplugins/paypal2/pending.html
msgid ""
"We're waiting for an answer from PayPal regarding your payment. Please "
"contact us, if this takes more than a few hours."
msgstr ""
#: pretix/plugins/paypal2/views.py
msgid ""
"An error occurred returning from PayPal: request parameters missing. Please "
@@ -28176,6 +28234,18 @@ msgid ""
"in detail below."
msgstr ""
#: pretix/plugins/paypal2/views.py
msgid "Invalid response from PayPal received."
msgstr ""
#: pretix/plugins/paypal2/views.py
msgid "It looks like you canceled the PayPal payment"
msgstr ""
#: pretix/plugins/paypal2/views.py
msgid "Your PayPal account has been disconnected."
msgstr ""
#: pretix/plugins/pretixdroid/apps.py
msgid "Old check-in device API"
msgstr ""
@@ -30195,7 +30265,6 @@ msgstr ""
#: pretix/presale/forms/order.py
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
msgid "plus taxes"
msgstr ""
@@ -30616,6 +30685,31 @@ msgid ""
"Please go to the payment settings and activate one or more payment providers."
msgstr ""
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
msgid ""
"Not sure yet? You can complete your order first and then select a payment "
"method later."
msgstr ""
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
msgid ""
"To do so, please first remove the payment methods you already selected above."
msgstr ""
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
#, python-format
msgid "Your payment needs to be completed within %(minutes)s minutes."
msgstr ""
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
#, python-format
msgid "Your payment needs to be completed by %(deadline)s."
msgstr ""
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
msgid "Proceed without selection"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
msgid "Before we continue, we need you to answer some questions."
msgstr ""
@@ -30682,14 +30776,12 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
#, python-format
msgid "minimum amount to order: %(num)s"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
msgctxt "price"
msgid "free"
msgstr ""
@@ -30718,66 +30810,56 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
msgid "Original price:"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
msgid "New price:"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
#, python-format
msgid "Modify price for %(item)s, at least %(price)s"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
#, python-format
msgid "Modify price for %(item)s"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
msgid "incl. taxes"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
#, python-format
msgid "<strong>plus</strong> %(rate)s%% %(name)s"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
#, python-format
msgid "incl. %(rate)s%% %(name)s"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
msgctxt "checkbox"
msgid "Select"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
msgid "Decrease quantity"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
msgid "Increase quantity"
msgstr ""
@@ -31238,12 +31320,10 @@ msgid "Your order qualifies for a discount"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
msgid "Uncategorized items"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
#, python-format
msgid "Show full-size image of %(item)s"
msgstr ""
@@ -31256,19 +31336,16 @@ msgstr[0] ""
msgstr[1] ""
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
#, python-format
msgid "%(value)s incl. taxes"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
#, python-format
msgid "%(value)s without taxes"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html
#: pretix/presale/templates/pretixpresale/event/voucher.html
#, python-format
msgid "Set price in %(currency)s for %(item)s"
msgstr ""
@@ -31927,11 +32004,6 @@ msgid ""
"products at the specified price:"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/voucher.html
#, python-format
msgid "from %(minprice)s"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/voucher.html
#, python-format
msgid "You need to select at least %(number)s products."
+1 -84
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-07 09:33+0000\n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
@@ -765,194 +765,163 @@ msgstr ""
msgid "Google Pay"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Quantity"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Decrease quantity"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Increase quantity"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Filter events by"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Filter"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Price"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, javascript-format
msgctxt "widget"
msgid "Original price: %s"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, javascript-format
msgctxt "widget"
msgid "New price: %s"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Select"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, javascript-format
msgctxt "widget"
msgid "Select %s"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, javascript-format
msgctxt "widget"
msgid "Select variant %s"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Sold out"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Buy"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Register"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Reserved"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "FREE"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "from %(currency)s %(price)s"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, javascript-format
msgctxt "widget"
msgid "Image of %s"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "incl. %(rate)s% %(taxname)s"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "plus %(rate)s% %(taxname)s"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "incl. taxes"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "plus taxes"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, javascript-format
msgctxt "widget"
msgid "currently available: %s"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Only available with a voucher"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Not yet available"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Not available anymore"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Currently not available"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, javascript-format
msgctxt "widget"
msgid "minimum amount to order: %s"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Close ticket shop"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "The ticket shop could not be loaded."
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid ""
@@ -960,25 +929,21 @@ msgid ""
"in a new tab to continue."
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Open ticket shop"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Checkout"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "The cart could not be created. Please try again later"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid ""
@@ -986,13 +951,11 @@ msgid ""
"this ticket shop. Please click \"Continue\" to retry in a new tab."
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Waiting list"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid ""
@@ -1000,115 +963,96 @@ msgid ""
"products, they will be added to your existing cart."
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Resume checkout"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Redeem a voucher"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Redeem"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Voucher code"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Close"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Close checkout"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "You cannot cancel this operation. Please wait for loading to finish."
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Continue"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Show variants"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Hide variants"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Choose a different event"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Choose a different date"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Back"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Next month"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Previous month"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Next week"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Previous week"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Open seat selection"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid ""
@@ -1117,138 +1061,111 @@ msgid ""
"again."
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Load more"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Mo"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Tu"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "We"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Th"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Fr"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Sa"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Su"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Monday"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Tuesday"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Wednesday"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Thursday"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Friday"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Saturday"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Sunday"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "January"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "February"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "March"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "April"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "May"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "June"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "July"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "August"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "September"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "October"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "November"
msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "December"
msgstr ""
Loaded 100 of 322 files, more files were not shown because too many files have changed in this diff. Show more