Compare commits

..
Author SHA1 Message Date
Mira Weller 54726d6fcd TODO markings 2026-09-15 16:37:23 +02:00
Mira Weller 8e3cc12357 rebase migration and migrate order-level questions as well 2026-09-15 16:37:23 +02:00
Mira Weller 53a8a7954d QuestionnaireChild: system_question und user_question in system_datafield und user_datafield umbenennen 2026-09-15 16:37:22 +02:00
Mira Weller 59cebf3ee8 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-15 16:37:22 +02:00
121 changed files with 4761 additions and 3648 deletions
+2
View File
@@ -16,6 +16,8 @@ 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 *
-241
View File
@@ -1,241 +0,0 @@
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.paypal2",
"pretix.plugins.paypal",
"pretix.plugins.ticketoutputpdf"
],
"all_sales_channels": false,
@@ -199,7 +199,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal2",
"pretix.plugins.paypal",
"pretix.plugins.ticketoutputpdf"
],
"valid_keys": {
@@ -262,7 +262,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal2"
"pretix.plugins.paypal"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -299,7 +299,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal2"
"pretix.plugins.paypal"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -364,7 +364,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal2"
"pretix.plugins.paypal"
],
"all_sales_channels": true,
"limit_sales_channels": []
@@ -401,7 +401,7 @@ Endpoints
"item_meta_properties": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal2"
"pretix.plugins.paypal"
],
"all_sales_channels": true,
"limit_sales_channels": [],
@@ -438,7 +438,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal2",
"pretix.plugins.paypal",
"pretix.plugins.pretixdroid"
]
}
@@ -475,7 +475,7 @@ Endpoints
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal2",
"pretix.plugins.paypal",
"pretix.plugins.pretixdroid"
],
"all_sales_channels": true,
-1
View File
@@ -12,7 +12,6 @@ at :ref:`plugin-docs`.
organizers
events
subevents
event_meta_properties
taxrules
categories
items
-1
View File
@@ -116,7 +116,6 @@ 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
+21 -25
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",
@@ -294,43 +295,29 @@
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanfs/core": "^0.19.1",
"@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",
@@ -3394,9 +3381,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
"integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4649,6 +4636,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",
+7 -7
View File
@@ -33,14 +33,14 @@ dependencies = [
"bleach==6.4.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=50.0.1",
"cryptography>=50.0.0",
"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.1.*",
"django-countries==9.0.*",
"django-filter==26.1",
"django-formset-js-improved==0.5.0.5",
"django-formtools==2.7",
@@ -75,7 +75,7 @@ dependencies = [
"packaging",
"paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.14.*",
"PyJWT==2.13.*",
"phonenumberslite==9.0.*",
"Pillow==12.3.*",
"pretix-plugin-build",
@@ -84,7 +84,7 @@ dependencies = [
"pycountry",
"pycparser==3.0",
"pycryptodome==3.23.*",
"pypdf==6.19.*",
"pypdf==6.11.*",
"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.69.*",
"sentry-sdk==2.68.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
@@ -112,10 +112,10 @@ dev = [
"aiohttp==3.14.*",
"coverage",
"coveralls",
"fakeredis==2.38.*",
"fakeredis==2.37.*",
"flake8==7.3.*",
"freezegun",
"isort==9.0.*",
"isort==8.0.*",
"pep8-naming==0.15.*",
"potypo",
"pytest-asyncio>=1.4.0",
+1
View File
@@ -26,6 +26,7 @@ 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/*
+1 -1
View File
@@ -979,7 +979,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'reusable_media_type_nfc_mf0aes',
'reusable_media_type_nfc_mf0aes_random_uid',
'reusable_media_usage_enforced',
'system_question_order',
'system_question_order', # TODO(questionnaires) - remove or replace
'tax_rule_payment',
'tax_rule_cancellation',
]
+157 -1
View File
@@ -53,6 +53,7 @@ from pretix.base.models import (
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SalesChannel,
)
from pretix.base.models.items import Questionnaire, QuestionnaireChild
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
@@ -542,6 +543,7 @@ class LegacyDependencyValueField(serializers.CharField):
class QuestionSerializer(I18nAwareModelSerializer):
options = InlineQuestionOptionSerializer(many=True, required=False)
identifier = serializers.CharField(allow_null=True)
internal_name = serializers.CharField(allow_null=True, source='question', read_only=True)
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
class Meta:
@@ -550,7 +552,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
'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')
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait', 'internal_name',)
def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance)
@@ -626,6 +628,160 @@ class QuestionSerializer(I18nAwareModelSerializer):
return question
class QuestionRefField(serializers.PrimaryKeyRelatedField):
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_id
else:
return None
def to_internal_value(self, data):
if type(data) == int:
return {'user_datafield': super().to_internal_value(data), 'system_datafield': None}
elif type(data) == str or data is None:
return {'user_datafield': None, 'system_datafield': data}
else:
self.fail('incorrect_type', data_type=type(data).__name__)
def use_pk_only_optimization(self):
return self.source == '*'
class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer):
question = QuestionRefField(source='*', queryset=Question.objects.none())
dependency_question = QuestionRefField(allow_null=True, required=False, queryset=Question.objects.none())
class Meta:
model = QuestionnaireChild
fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["question"].queryset = self.context["event"].questions.all()
self.fields["dependency_question"].queryset = self.context["event"].questions.all()
def validate(self, data):
data = super().validate(data)
event = self.context['event']
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
return data
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
class QuestionnaireSerializer(I18nAwareModelSerializer):
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', 'children')
def __init__(self, *args, **kwargs):
self.fields['children'] = InlineQuestionnaireChildSerializer(many=True, required=True, context=kwargs['context'], partial=False)
super().__init__(*args, **kwargs)
def validate(self, data):
data = super().validate(data)
event = self.context['event']
#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.')
#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 [])
return data
def validate_children(self, value):
prev_questions = {}
for child in value:
if child.get('dependency_question'):
if (child['dependency_question']['user_datafield'] or child['dependency_question']['system_datafield']) not in prev_questions:
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
if child['user_datafield']:
prev_questions[child['user_datafield']] = child
if child['system_datafield']:
prev_questions[child['system_datafield']] = child
return value
@transaction.atomic
def create(self, validated_data):
children_data = validated_data.pop('children') if 'children' in validated_data else []
questionnaire = super().create(validated_data)
self.set_children(questionnaire, children_data)
return questionnaire
@transaction.atomic
def update(self, instance, validated_data):
children_data = validated_data.pop('children', None)
questionnaire = super().update(instance, validated_data)
if children_data is not None:
self.set_children(questionnaire, children_data)
return questionnaire
def set_children(self, questionnaire, new_data):
result = []
child_serializer = self.fields['children'].child
existing = questionnaire.children.all()
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)
updatable = min(len(existing), len(new_data))
for i in range(0, updatable):
result.append(child_serializer.update(existing[i], new_data[i]))
for i in range(updatable, len(new_data)):
result.append(child_serializer.create(new_data[i]))
for i in range(updatable, len(existing)):
existing[i].delete()
return result
class QuotaSerializer(I18nAwareModelSerializer):
available = serializers.BooleanField(read_only=True)
available_number = serializers.IntegerField(read_only=True)
+3 -90
View File
@@ -28,7 +28,6 @@ from django.db import transaction
from django.db.models import Q
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
@@ -41,10 +40,9 @@ 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, EventMetaProperty, GiftCard, GiftCardAcceptance,
GiftCardTransaction, Membership, MembershipType, OrderPosition, Organizer,
ReusableMedium, SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite,
User,
Customer, Device, 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 (
@@ -642,88 +640,3 @@ 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
+2 -2
View File
@@ -68,7 +68,6 @@ 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)
@@ -80,7 +79,8 @@ 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'datafields', item.QuestionViewSet)
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)
+48 -1
View File
@@ -48,13 +48,15 @@ from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
QuestionnaireSerializer, QuestionOptionSerializer, QuestionSerializer,
QuotaSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import Questionnaire
from pretix.base.services.quotas import QuotaAvailability
from pretix.helpers.dicts import merge_dicts
from pretix.helpers.i18n import i18ncomp
@@ -566,6 +568,51 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionnaireSerializer
queryset = Questionnaire.objects.none()
#filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
#filterset_class = QuestionFilter
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('children').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 NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
pass
+4 -51
View File
@@ -44,16 +44,15 @@ from pretix.api.models import OAuthAccessToken
from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.organizer import (
CustomerCreateSerializer, CustomerSerializer, DeviceSerializer,
EventMetaPropertiesSerializer, GiftCardSerializer,
GiftCardTransactionSerializer, MembershipSerializer,
GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer,
MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer,
SalesChannelSerializer, SeatingPlanSerializer, TeamAPITokenSerializer,
TeamInviteSerializer, TeamMemberSerializer, TeamSerializer,
)
from pretix.base.models import (
Customer, Device, Event, EventMetaProperty, GiftCard, GiftCardTransaction,
LogEntry, Membership, MembershipType, Organizer, SalesChannel, SeatingPlan,
Team, TeamAPIToken, TeamInvite, User,
Customer, Device, Event, 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,
@@ -847,49 +846,3 @@ 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
-4
View File
@@ -40,7 +40,6 @@ 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
@@ -55,9 +54,6 @@ 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
+3 -7
View File
@@ -27,7 +27,7 @@ from datetime import timedelta
from functools import cached_property
from typing import List, Optional, Protocol
from django.conf import settings
import sentry_sdk
from django.db import DatabaseError, transaction
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -236,9 +236,7 @@ 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)')
if settings.SENTRY_ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
sentry_sdk.capture_exception(e)
sq.set_sync_error("exceeded", e.messages, e.full_message)
else:
logger.info(
@@ -249,9 +247,7 @@ class OutboundSyncProvider:
sq.clear_in_flight()
except Exception as e:
logger.exception('Failed to sync order (unhandled exception)')
if settings.SENTRY_ENABLED:
import sentry_sdk
sentry_sdk.capture_exception(e)
sentry_sdk.capture_exception(e)
sq.set_sync_error("internal", [], str(e))
@cached_property
+4 -27
View File
@@ -54,7 +54,6 @@ 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 (
@@ -198,7 +197,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
def iterate_sheet(self, form_data, sheet):
_ = gettext
if sheet == 'invoices':
headers = [
yield [
_('Invoice number'),
_('Date'),
_('Order code'),
@@ -231,18 +230,8 @@ 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,
@@ -253,7 +242,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'
@@ -291,7 +280,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
if mid:
pmis.append(mid)
pmi = '\n'.join(pmis)
line = [
yield [
i.full_invoice_no,
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
@@ -326,20 +315,8 @@ 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'),
+111 -145
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
@@ -643,49 +642,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 +671,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 +779,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 +797,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 +831,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,26 +857,27 @@ 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
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_id
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 check_user_questions(self, d):
@@ -970,6 +941,7 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
super().__init__(*args, **kwargs)
# TODO(questionnaires) - switch olq's to questionnaires !
questions = Question.objects.filter(
event=event, container_type=Question.ContainerType.ORDER,
ask_during_checkin=False, hidden=False,
@@ -1004,6 +976,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 +986,13 @@ 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 child in getattr(questionnaire, 'childlist', questionnaire.children.all()):
if child.user_datafield:
df = child.user_datafield
self.fields['question_%s' % df.id] = self.build_user_question_field(request, event, pos.answerlist, child, df)
elif child.system_datafield:
self.fields[child.system_datafield] = self.build_system_question_field(request, event, pos, child)
responses = question_form_fields.send(sender=event, position=pos)
data = pos.meta_info_data
@@ -1089,21 +1057,21 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
)
def build_system_question_field(self, request, event, pos, qc):
field_name = qc.id
field_name = qc.system_datafield
if field_name == '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':
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(
@@ -1115,7 +1083,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == '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,
@@ -1124,7 +1092,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == '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,
@@ -1137,7 +1105,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
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={
@@ -1147,7 +1115,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
if field_name == '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,
@@ -1161,7 +1129,7 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
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={
@@ -1189,7 +1157,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,9 +1170,8 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
return field
def clean(self):
from pretix.base.addressvalidation import ( # local import to prevent impact on startup time
validate_address,
)
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
d = super().clean()
@@ -1445,9 +1412,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['transmission_type'].widget.attrs['data-trigger-address-info'] = 'on'
def clean(self):
from pretix.base.addressvalidation import ( # local import to prevent impact on startup time
validate_address,
)
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
data = self.cleaned_data
@@ -1501,7 +1467,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
"vat_id": _("This field is required.")
})
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')):
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
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=(
('otp_totp.totpdevice', _('Smartphone with the Authenticator application')),
('pretixbase.webauthndevice', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
('totp', _('Smartphone with the Authenticator application')),
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
))
@@ -107,9 +107,6 @@ 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,196 @@
# Generated by Django 4.2.29 on 2026-03-19 14:24
import json
from collections import namedtuple
from itertools import chain, groupby
from django.db import migrations, models
import django.db.models.deletion
import i18nfield.fields
from i18nfield.strings import LazyI18nString
import pretix.base.models.base
import pretix.base.models.fields
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('country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
return fq
def migrate_questions_forward(apps, schema_editor):
Event = apps.get_model("pretixbase", "Event")
Item = apps.get_model("pretixbase", "Item")
Question = apps.get_model("pretixbase", "Question")
Questionnaire = apps.get_model("pretixbase", "Questionnaire")
QuestionnaireChild = apps.get_model("pretixbase", "QuestionnaireChild")
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
def create_grouped_item_questionnaires(event, children, label_prefix, questionnaire_type):
# group by item, creating a unique questionnaire per item
item_questionnaires = (([t[3] for t in children], item_id) for item_id, children in
groupby(children, key=lambda t: t[0]))
# group again, merging all questionnaires with identical children
merged_questionnaires = groupby(sorted(item_questionnaires, key=lambda t: [q.id for q in t[0]]),
key=lambda t: t[0])
for children, iterator in merged_questionnaires:
items = [item for _c, item in iterator]
# create questionnaires and children
questionnaire = Questionnaire.objects.create(
event=event, type=questionnaire_type, position=0, all_sales_channels=True,
internal_name=label_prefix + ', '.join(str(iname or name) for (id, iname, name) in items)
)
questionnaire.items.set([id for (id, iname, name) in items])
deps = {}
for position, child in enumerate(children):
if isinstance(child, FakeQuestion):
QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
system_datafield=child.id,
required=child.required,
label=child.question,
)
else:
deps[child.id] = QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
user_datafield=child,
required=child.required,
label=child.question,
help_text=child.help_text,
dependency_question=deps[child.dependency_question.id] if child.dependency_question else None,
dependency_values=child.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',
))
}
# 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')
children = sorted(chain((
(item, q.position, 0, q)
for q in get_fake_questions(settings)
for item in event.items.filter(personalized=True).values_list('id', 'internal_name', 'name')
), (
(item, q.position, q.id, q)
for q in questions.filter(hidden=False)
for item in q.items.values_list('id', 'internal_name', 'name')
)), key=lambda t: (t[0], t[1]))
create_grouped_item_questionnaires(event, children, '', 'PS')
children = sorted(chain((
(item, q.position, q.id, q)
for q in questions.filter(hidden=True)
for item in q.items.values_list('id', 'internal_name', 'name')
)), key=lambda t: (t[0], t[1]))
create_grouped_item_questionnaires(event, children, 'Hidden questions for ', 'PH')
# get all order-level questions
questions = list(event.questions.filter(container_type='O', hidden=False).order_by('position'))
if questions:
# create questionnaires and children
questionnaire = Questionnaire.objects.create(
event=event, type='OS', position=0, all_sales_channels=True,
internal_name='Per-order questions',
)
deps = {}
for position, child in enumerate(questions):
deps[child.id] = QuestionnaireChild.objects.create(
questionnaire=questionnaire,
position=position + 1,
user_datafield=child,
required=child.required,
label=child.question,
help_text=child.help_text,
dependency_question=deps[child.dependency_question.id] if child.dependency_question else None,
dependency_values=child.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', '0309_alter_questionanswer_unique_together_and_more'),
]
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=5)),
('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='QuestionnaireChild',
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.questionnairechild')),
('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', 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
]
@@ -1,49 +0,0 @@
# 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,
),
]
+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, QuestionnaireChild, QuestionOption, Quota, SubEventItem,
SubEventItemVariation, itempicture_upload_to,
)
from .log import LogEntry
from .mail import OutgoingMail
-1
View File
@@ -166,7 +166,6 @@ class Device(LoggedModel):
)
security_profile = models.CharField(
max_length=190,
verbose_name=_('Security profile'),
default='full',
null=True,
blank=False
+119 -16
View File
@@ -1569,10 +1569,12 @@ class ItemBundle(models.Model):
class Question(LoggedModel):
"""
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 +1594,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.
@@ -1651,6 +1653,7 @@ class Question(LoggedModel):
default=ContainerType.ORDERPOSITION,
)
question = I18nTextField(
# TODO(questionnaires) : to be renamed to 'internal_name'
verbose_name=_("Question")
)
identifier = models.CharField(
@@ -1666,6 +1669,7 @@ class Question(LoggedModel):
],
)
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,
@@ -1675,22 +1679,22 @@ class Question(LoggedModel):
choices=TYPE_CHOICES,
verbose_name=_("Question type")
)
required = models.BooleanField(
required = models.BooleanField( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
default=False,
verbose_name=_("Required question")
)
items = models.ManyToManyField(
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')
)
position = models.PositiveIntegerField(
position = models.PositiveIntegerField( # TODO(questionnaires) : to be removed, -> Questionnaire + QuestionnaireChild
default=0,
verbose_name=_("Position")
)
ask_during_checkin = models.BooleanField(
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
@@ -1700,7 +1704,7 @@ class Question(LoggedModel):
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 +1713,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, -> QuestionnaireChild
'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, -> QuestionnaireChild
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 +1755,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):
@@ -1904,7 +1908,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 +1989,105 @@ class QuestionOption(models.Model):
ordering = ('position', 'id')
class Questionnaire(LoggedModel):
TYPE_ORDER_SALE = "OS"
TYPE_ORDER_POSITION_SALE = "PS"
TYPE_ORDER_POSITION_ATTENDEE_ONLY = "PA"
TYPE_ORDER_POSITION_CHECKIN = "PC"
TYPE_ORDER_POSITION_HIDDEN = "PH"
TYPE_CHOICES = (
(TYPE_ORDER_SALE, _("Order-wide, before purchase")),
(TYPE_ORDER_POSITION_SALE, _("Per product, before purchase")),
(TYPE_ORDER_POSITION_ATTENDEE_ONLY, _("Per product, via attendee link")),
(TYPE_ORDER_POSITION_CHECKIN, _("Per product, at check-in")),
(TYPE_ORDER_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=5,
choices=TYPE_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 QuestionnaireChild(LoggedModel):
SYSTEM_QUESTION_CHOICES = (
('attendee_name_parts', _('Attendee name')),
('attendee_email', _('Attendee email')),
('company', _('Company')),
('street', _('Street')),
('zipcode', _('ZIP code')),
('city', _('City')),
('country', _('Country')),
)
questionnaire = models.ForeignKey(
Questionnaire,
related_name="children",
on_delete=models.CASCADE
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
user_datafield = models.ForeignKey(
Question,
related_name="references",
on_delete=models.CASCADE,
null=True, blank=True,
)
system_datafield = models.CharField(
max_length=25,
choices=SYSTEM_QUESTION_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(
'QuestionnaireChild', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
dependency_values = MultiStringField(default=[])
class Quota(LoggedModel):
"""
A quota is a "pool of tickets". It is there to limit the number of items
+65 -32
View File
@@ -1449,6 +1449,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.')
@@ -1593,53 +1599,80 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
def cache_answers(self, all=True):
"""
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)
if hasattr(self.item, 'relevant_questionnaires'):
children = list(copy.copy(qc) for qq in self.item.relevant_questionnaires for qc in qq.childlist)
else:
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
hidden=False))
children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type='PS') for qc in qq.children.all())
else:
questions = list(copy.copy(q) for q in self.item.questions.all())
children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type__startswith='P') for qc in qq.children.all())
question_cache = {
q.pk: q for q in questions
qc_cache = {
q.pk: q for q in children
}
def question_is_visible(parentid, qvals):
if parentid not in question_cache:
def qc_is_visible(parentid, qvals):
if parentid not in qc_cache:
return False
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
parentqc = qc_cache[parentid]
if parentqc.dependency_question_id and not qc_is_visible(parentqc.dependency_question_id, parentqc.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(parentqc)
return 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
for qc in children:
if qc.user_datafield_id and qc.user_datafield_id in self.answer_cache:
qc.answer = self.answer_cache[qc.user_datafield_id]
#qc.answer.question = qc # cache object
elif qc.system_datafield:
qc.answer = self.get_system_answer(qc.system_datafield)
#qc.answer.question = qc # 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)
qc.answer = ""
if not qc.dependency_question_id or qc_is_visible(qc.dependency_question_id, qc.dependency_values):
self.questions.append(qc)
@cached_property
def answer_cache(self):
return {
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 == 'attendee_name_parts':
return self.attendee_name_parts
elif system_datafield_name == 'attendee_email':
return self.attendee_email
elif system_datafield_name == 'street':
return self.street
elif system_datafield_name == 'zipcode':
return self.zipcode
elif system_datafield_name == 'city':
return self.city
elif system_datafield_name == 'state':
return self.state
elif system_datafield_name == 'country':
return self.country
else:
raise ValueError('Unknown system question name')
@property
def net_price(self):
+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 = []
+12 -27
View File
@@ -801,18 +801,6 @@ 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):
@@ -1165,10 +1153,11 @@ class Renderer:
elif o['type'] == "poweredby":
self._draw_poweredby(canvas, op, o)
if self.bg_pdf:
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):
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):
# swap dimensions due to pdf being rotated
page_size = page_size[::-1]
canvas.setPageSize(page_size)
@@ -1323,18 +1312,14 @@ 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):
"""
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 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]))
bg_page.transfer_rotation_to_content()
media_box = bg_page.mediabox
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)
fg_page = output.add_page(fg_page)
fg_page.merge_transformed_page(bg_page, trsf, over=False, expand=False)
+1 -1
View File
@@ -379,7 +379,7 @@ DEFAULTS = {
)
},
'system_question_order': {
'system_question_order': { # TODO(questionnaires) - remove this
'default': {},
'type': dict,
'serializer_class': serializers.DictField,
+10 -22
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, Invoice, InvoiceAddress,
OrderPayment, OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
)
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.signals import register_data_shredders
@@ -598,30 +598,18 @@ 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
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="█")
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="█")
class CachedTicketShredder(BaseDataShredder):
+23 -52
View File
@@ -20,7 +20,6 @@
# <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
@@ -36,32 +35,32 @@ register = template.Library()
@register.filter("money")
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False):
if isinstance(value, (float, int, str)):
if value == '':
return value
def money_filter(value: Decimal, arg='', hide_currency=False):
if isinstance(value, (float, int)):
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()
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)
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 hide_currency:
return floatformat(value, f"{render_places}g")
return floatformat(value, f"{places}g")
try:
locale = Locale(get_babel_locale())
@@ -69,29 +68,14 @@ def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_curr
locale = "en"
try:
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,
)
return format_currency(value, arg, locale=locale)
except:
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)
return '{} {}'.format(arg, floatformat(value, f"{places}g"))
@register.filter("money_numberfield")
def money_numberfield_filter(value: Optional[Decimal | float | int | str], arg=''):
if isinstance(value, (float, int, str)):
def money_numberfield_filter(value: Decimal, arg=''):
if isinstance(value, (float, int)):
value = Decimal(value)
if not isinstance(value, Decimal):
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
@@ -103,28 +87,15 @@ def money_numberfield_filter(value: Optional[Decimal | float | int | str], arg='
@register.filter(is_safe=True)
def tax_rate_format(number: Optional[Decimal | float | int | str]):
def tax_rate_format(number):
"""
Display a Decimal to its significant decimal places, used for tax rates.
"""
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()
assert isinstance(number, Decimal)
return mark_safe(
formats.number_format(
number,
-number.normalize().as_tuple().exponent,
number.normalize(),
-number.as_tuple().exponent,
use_l10n=True,
force_grouping=False,
)
+2 -7
View File
@@ -19,7 +19,6 @@
# 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,
)
@@ -28,6 +27,7 @@ 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,14 +106,9 @@ 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': sentry_id,
'sentry_event_id': last_event_id(),
}))
r.xframe_options_exempt = True
return r
+25 -15
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,
QuestionnaireChild, QuestionOption,
)
from pretix.base.models.customers import AttendeeProfile
from pretix.base.models.orders import CheckoutSession, Order
@@ -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='PS')
else:
qqs = qqs.filter(type__startswith='P')
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('children', QuestionnaireChild.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='childlist')
),
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,
})
+6 -11
View File
@@ -400,10 +400,10 @@ class EventMetaValueForm(forms.ModelForm):
if self.disabled:
self.fields['value'].widget.attrs['readonly'] = 'readonly'
def clean_value(self):
def clean_slug(self):
if self.disabled:
return self.instance.value if self.instance else None
return self.cleaned_data['value']
return self.cleaned_data['slug']
class Meta:
model = EventMetaValue
@@ -1635,15 +1635,10 @@ 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'):
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")}'
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]
class TicketSettingsForm(SettingsForm):
+2 -2
View File
@@ -435,10 +435,10 @@ class SubEventMetaValueForm(forms.ModelForm):
if self.disabled:
self.fields['value'].widget.attrs['readonly'] = 'readonly'
def clean_value(self):
def clean_slug(self):
if self.disabled:
return self.instance.value if self.instance else None
return self.cleaned_data['value']
return self.cleaned_data['slug']
class Meta:
model = SubEventMetaValue
+3 -5
View File
@@ -717,10 +717,6 @@ 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.'),
@@ -778,7 +774,6 @@ 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 '
@@ -866,6 +861,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.otp_totp.totpdevice",
"user.settings.2fa.confirm.pretixbase.webauthndevice",
"user.settings.2fa.confirm.totp",
"user.settings.2fa.confirm.webauthn",
"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="{% trans "Cancel" %}"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="{% trans "Authorize" %}"/>
<input type="submit" class="btn btn-large btn-default" value="Cancel"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="Authorize"/>
</div>
</form>
{% else %}
@@ -12,151 +12,120 @@
{% 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 "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.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 submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
@@ -0,0 +1,41 @@
{% 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 dietary requirements.
{% endblocktrans %}
</p>
<p>
TODO(questionnaires) : add more specific explanation of the questionnaire concept.
</p>
{{ request.event.settings.locales|json_script:"event_locales" }}
{{ questionnaire_type_choices|json_script:"questionnaire_type_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" }}
<div id="questionnaires-editor">
<!-- Vue app mount point -->
</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
@@ -14,19 +14,19 @@
{% 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 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>
<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 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>
</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>
<h2>{% trans "Per-ticket data fields" %}</h2>
<p>{% trans "These data field can be used on individual tickets." %}</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 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 data field" %}
</a>
</p>
{% endif %}
@@ -35,39 +35,27 @@
<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">
<tbody>
{% for q in questions %}{% if q.container_type == "P" %}
<tr data-dnd-id="{{ q.id }}">
<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 %}
<a href="{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">
{{ q.question }}
</a>
</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,35 +72,11 @@
<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>
{% 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 %}
<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>
@@ -123,34 +87,31 @@
{% if request.event.settings.feature_flag_order_level_questions %}
<h2>
{% trans "Per-order questions" %}
{% trans "Per-order data fields" %}
<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." %}
{% trans "In pretixPOS, per-order data fields are currently not supported and will not be displayed." %}
" 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>
<p>{% trans "These data fields are asked once per order." %}</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>
{% 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">
<tbody>
{% for q in questions %}{% if q.container_type == "O" %}
<tr data-dnd-id="{{ q.id }}">
<tr>
<td>
<strong>
{{ q.question }}
@@ -158,11 +119,7 @@
<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 %}
@@ -179,10 +136,6 @@
<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>
{% endif %}
<td class="text-right flip">
{% if q.pk %}
{% if 'event.items:write' in request.eventpermset %}
@@ -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="otp_totp.totpdevice" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "otp_totp.totpdevice" %}checked{% endif %}>
<input type="radio" required value="totp" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "totp" %}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="pretixbase.webauthndevice" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "pretixbase.webauthndevice" %}checked{% endif %}>
<input type="radio" required value="webauthn" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "webauthn" %}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.model_label device=d.pk %}">
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
Delete
</a>
{% if d.model_label == "otp_totp.totpdevice" %}
{% if d.devicetype == "totp" %}
<span class="fa fa-mobile"></span>
{% elif d.model_label == "pretixbase.webauthndevice" %}
{% elif d.devicetype == "webauthn" %}
<span class="fa fa-usb"></span>
{% elif d.model_label == "pretixbase.u2fdevice" %}
{% elif d.devicetype == "u2f" %}
<span class="fa fa-usb"></span>
{% endif %}
{{ d.name }}
@@ -1,7 +1,6 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load icon %}
{% block title %}{% trans "User" %}{% endblock %}
{% block content %}
<h1>{% trans "User" %} {{ user.email }}</h1>
@@ -60,83 +59,8 @@
{% bootstrap_field form.is_verified layout='control' %}
{% endif %}
{% bootstrap_field form.last_login 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>
{% bootstrap_field form.needs_password_change layout='control' %}
</fieldset>
<fieldset>
<legend>{% trans "Team memberships" %}</legend>
@@ -178,8 +102,4 @@
</div>
</div>
</div>
<form action="{% url "control:users.resetdriftthrottle" id=user.pk %}" id="resetdriftthrottle" method="post">
{% csrf_token %}
</form>
{% endblock %}
+3 -3
View File
@@ -78,7 +78,6 @@ 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'),
@@ -107,9 +106,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.otp_totp.totpdevice'),
name='user.settings.2fa.confirm.totp'),
re_path(r'^settings/2fa/webauthn/(?P<device>[0-9]+)/confirm', user.User2FADeviceConfirmWebAuthnView.as_view(),
name='user.settings.2fa.confirm.pretixbase.webauthndevice'),
name='user.settings.2fa.confirm.webauthn'),
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'),
@@ -351,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'),
+6 -41
View File
@@ -35,7 +35,6 @@
import base64
import json
import logging
import math
import time
from urllib.parse import quote, urljoin, urlparse
@@ -51,12 +50,11 @@ 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.timezone import now
from django.utils.translation import gettext_lazy as _, ngettext
from django.utils.translation import gettext_lazy as _
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 devices_for_user
from django_otp import match_token
from django_otp.plugins.otp_static.models import StaticDevice
from webauthn.helpers import generate_challenge
@@ -465,7 +463,6 @@ 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']
@@ -521,28 +518,12 @@ class Login2FAView(TemplateView):
valid = True
break
else:
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):
valid = match_token(self.user, token)
if isinstance(valid, 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)
@@ -555,23 +536,7 @@ class Login2FAView(TemplateView):
return redirect('control:index')
else:
pretix_failed_logins.inc(1, reason="2fa")
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)
messages.error(request, _('Invalid code, please try again.'))
return redirect('control:auth.login.2fa')
def get_context_data(self, **kwargs):
+7 -62
View File
@@ -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,7 +65,6 @@ 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,
@@ -437,66 +436,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
class QuestionDelete(EventPermissionRequiredMixin, CompatDeleteView):
@@ -766,6 +706,11 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret
class QuestionnairesEditor(EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_items'
template_name = 'pretixcontrol/items/questionnaires.html'
class QuotaQueryMixin:
@cached_property
+4 -20
View File
@@ -1177,8 +1177,6 @@ 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
@@ -1208,8 +1206,6 @@ 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
@@ -1259,8 +1255,6 @@ 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
@@ -1277,9 +1271,6 @@ 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,
@@ -1295,13 +1286,10 @@ 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
@@ -1325,8 +1313,6 @@ 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
@@ -1356,12 +1342,7 @@ class OrderRefundView(OrderView):
))
any_success = False
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:
if refund_selected == full_refund and is_valid:
for r in refunds:
r.save()
order.log_action('pretix.event.order.refund.created', {
@@ -1433,6 +1414,9 @@ 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():
+3 -3
View File
@@ -778,9 +778,9 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
# 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["events"] = self.request.user.get_events_with_permission(
"event.settings.general:write", request=self.request
).filter(organizer=self.request.organizer)
kwargs["initial"] = {
"events": self.request.organizer.events.filter(plugins__regex='(^|,)' + self.plugin.module + '(,|$)')
}
-5
View File
@@ -1276,11 +1276,6 @@ 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']
+24 -14
View File
@@ -59,7 +59,6 @@ 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
@@ -86,6 +85,7 @@ 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,7 +313,17 @@ class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
except StaticDevice.DoesNotExist:
ctx['static_tokens_device'] = None
ctx['devices'] = [d for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)]
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['obligatory'] = None
if settings.PRETIX_OBLIGATORY_2FA is True:
@@ -332,9 +342,9 @@ class User2FADeviceAddView(RecentAuthenticationRequiredMixin, FormView):
template_name = 'pretixcontrol/user/2fa_add.html'
def form_valid(self, form):
if form.cleaned_data['devicetype'] == 'otp_totp.totpdevice':
if form.cleaned_data['devicetype'] == 'totp':
dev = TOTPDevice.objects.create(user=self.request.user, confirmed=False, name=form.cleaned_data['name'])
elif form.cleaned_data['devicetype'] == 'pretixbase.webauthndevice':
elif form.cleaned_data['devicetype'] == 'webauthn':
if not self.request.is_secure():
messages.error(self.request,
_('Security devices are only available if pretix is served via HTTPS.'))
@@ -354,11 +364,11 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
@cached_property
def device(self):
if self.kwargs['devicetype'] == 'otp_totp.totpdevice':
if self.kwargs['devicetype'] == 'totp':
return get_object_or_404(TOTPDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
elif self.kwargs['devicetype'] == 'pretixbase.webauthndevice':
elif self.kwargs['devicetype'] == 'webauthn':
return get_object_or_404(WebAuthnDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
elif self.kwargs['devicetype'] == 'pretixbase.u2fdevice':
elif self.kwargs['devicetype'] == 'u2f':
return get_object_or_404(U2FDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True)
def get_context_data(self, **kwargs):
@@ -376,7 +386,7 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
msgs = [
_('A two-factor authentication device has been removed from your account.')
]
if not any(d.confirmed for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)):
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
self.request.user.require_2fa = False
self.request.user.save()
self.request.user.log_action('pretix.user.settings.2fa.disabled', user=self.request.user)
@@ -451,7 +461,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.pretixbase.webauthndevice', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={
'device': self.device.pk
}))
@@ -465,7 +475,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': 'pretixbase.webauthndevice',
'devicetype': 'u2f',
'name': self.device.name,
})
notices = [
@@ -493,7 +503,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.pretixbase.webauthndevice', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={
'device': self.device.pk
}))
@@ -527,7 +537,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': 'otp_totp.totpdevice'
'devicetype': 'totp'
})
notices = [
_('A new two-factor authentication device has been added to your account.')
@@ -553,7 +563,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.otp_totp.totpdevice', kwargs={
return redirect(reverse('control:user.settings.2fa.confirm.totp', kwargs={
'device': self.device.pk
}))
@@ -584,7 +594,7 @@ class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_enable.html'
def dispatch(self, request, *args, **kwargs):
if not any(d.confirmed for d in devices_for_user(self.request.user) if not isinstance(d, StaticDevice)):
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
messages.error(request, _('Please configure at least one device before enabling two-factor '
'authentication.'))
return redirect(reverse('control:user.settings.2fa'))
-23
View File
@@ -40,7 +40,6 @@ 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
@@ -108,9 +107,6 @@ 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):
@@ -187,25 +183,6 @@ 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"
+23 -30
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-09-20 14:00+0000\n"
"Last-Translator: Rita Gimenez <barcelonamusictech@gmail.com>\n"
"PO-Revision-Date: 2026-06-25 14:00+0000\n"
"Last-Translator: Kim Lozano <joaquim.lozano@upc.edu>\n"
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix/"
"ca/>\n"
"Language: ca\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.9.1\n"
"X-Generator: Weblate 2026.6.1\n"
#: pretix/_base_settings.py
msgid "English"
@@ -37,11 +37,11 @@ msgstr "Àrab"
#: pretix/_base_settings.py
msgid "Basque"
msgstr "Basc"
msgstr ""
#: pretix/_base_settings.py
msgid "Catalan"
msgstr "Català"
msgstr ""
#: pretix/_base_settings.py
msgid "Chinese (simplified)"
@@ -49,11 +49,11 @@ msgstr "Xinès (simplificat)"
#: pretix/_base_settings.py
msgid "Chinese (traditional)"
msgstr "Xinès (tradicional)"
msgstr ""
#: pretix/_base_settings.py
msgid "Czech"
msgstr "Txec"
msgstr ""
#: pretix/_base_settings.py
msgid "Croatian"
@@ -81,7 +81,7 @@ msgstr "Finlandès"
#: pretix/_base_settings.py
msgid "Galician"
msgstr "Gallec"
msgstr ""
#: pretix/_base_settings.py
msgid "Greek"
@@ -89,15 +89,15 @@ msgstr "Grec"
#: pretix/_base_settings.py
msgid "Hebrew"
msgstr "Hebreu"
msgstr ""
#: pretix/_base_settings.py
msgid "Hungarian"
msgstr "Hongarès"
msgstr ""
#: pretix/_base_settings.py
msgid "Indonesian"
msgstr "Indonesi"
msgstr ""
#: pretix/_base_settings.py
msgid "Italian"
@@ -105,7 +105,7 @@ msgstr "Italià"
#: pretix/_base_settings.py
msgid "Japanese"
msgstr "Japonès"
msgstr ""
#: pretix/_base_settings.py
msgid "Latvian"
@@ -113,7 +113,7 @@ msgstr "Letó"
#: pretix/_base_settings.py
msgid "Norwegian Bokmål"
msgstr "Noruec"
msgstr ""
#: pretix/_base_settings.py
msgid "Polish"
@@ -129,7 +129,7 @@ msgstr "Portuguès (Brasil)"
#: pretix/_base_settings.py
msgid "Romanian"
msgstr "Romanès"
msgstr ""
#: pretix/_base_settings.py
msgid "Russian"
@@ -137,11 +137,11 @@ msgstr "Rus"
#: pretix/_base_settings.py
msgid "Slovak"
msgstr "Eslovac"
msgstr ""
#: pretix/_base_settings.py
msgid "Swedish"
msgstr "Suec"
msgstr ""
#: pretix/_base_settings.py
msgid "Spanish"
@@ -149,11 +149,11 @@ msgstr "Espanyol"
#: pretix/_base_settings.py
msgid "Spanish (Latin America)"
msgstr "Espanyol (Llatinoamèrica)"
msgstr ""
#: pretix/_base_settings.py
msgid "Thai"
msgstr "Tailandès"
msgstr ""
#: pretix/_base_settings.py
msgid "Turkish"
@@ -161,15 +161,13 @@ msgstr "Turc"
#: pretix/_base_settings.py
msgid "Ukrainian"
msgstr "Ucraïnès"
msgstr ""
#: pretix/api/auth/devicesecurity.py
msgid ""
"Full device access (reading and changing orders and gift cards, reading of "
"products and settings)"
msgstr ""
"Accés total al dispositiu (lectura i modificació de comandes i targetes "
"regal, lectura de productes i configuració)"
#: pretix/api/auth/devicesecurity.py
msgid "pretixSCAN"
@@ -311,7 +309,7 @@ msgstr "La cistella és buida."
#: pretix/api/serializers/item.py
msgid "The program end must not be empty."
msgstr "El final del programa no ha de ser buit."
msgstr ""
#: pretix/api/serializers/item.py pretix/base/models/items.py
#, fuzzy
@@ -341,7 +339,7 @@ msgstr ""
#: pretix/api/serializers/item.py
msgid "Only admission products can currently be personalized."
msgstr "Actualment, només es poden personalitzar els productes d'accés."
msgstr ""
#: pretix/api/serializers/item.py
msgid ""
@@ -436,7 +434,7 @@ msgstr ""
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
#, python-format
msgid "You've been invited to join %(organizer)s"
msgstr "Has estat convidat/da a unir-te a %(organizer)s"
msgstr ""
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
msgid "This user already has been invited for this team."
@@ -458,7 +456,7 @@ msgstr ""
#: pretix/api/views/checkin.py
msgid "Medium connected to other event"
msgstr "Mitjà connectat a un altre esdeveniment"
msgstr ""
#: pretix/api/views/checkin.py
#, fuzzy
@@ -644,8 +642,6 @@ msgid ""
"This includes product added or deleted and changes to nested objects like "
"variations or bundles."
msgstr ""
"Això inclou productes afegits o eliminats i canvis en objectes imbricats, "
"com ara variacions o lots."
#: pretix/api/webhooks.py
#, fuzzy
@@ -658,9 +654,6 @@ msgid ""
"This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability."
msgstr ""
"Això inclou esdeveniments relacionats, com ara la creació, l'eliminació, "
"l'obertura o el tancament de quotes. No s'envia cap webhook per als canvis "
"en la disponibilitat resultant."
#: pretix/api/webhooks.py
#, fuzzy
+5 -5
View File
@@ -5,7 +5,7 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
"PO-Revision-Date: 2026-09-14 23:00+0000\n"
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/"
"de/>\n"
@@ -14,7 +14,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 2026.9.1\n"
"X-Generator: Weblate 2026.8.1\n"
"X-Poedit-Bookmarks: -1,-1,904,-1,-1,-1,-1,-1,-1,-1\n"
#: pretix/_base_settings.py
@@ -11708,7 +11708,7 @@ msgid ""
msgstr ""
"Hallo,\n"
"\n"
"Sie erhalten diese Nachricht, weil Sie einen neuen Link zu Ihrer Bestellung "
"Sie erhalten diese Nachricht weil Sie einen neuen Link zu Ihrer Bestellung "
"für\n"
"{event} angefordert haben.\n"
"\n"
@@ -11738,8 +11738,8 @@ msgid ""
msgstr ""
"Hallo,\n"
"\n"
"Sie erhalten diese Nachricht, weil Sie einen neuen Link zu Ihren "
"Bestellungen für\n"
"Sie erhalten diese Nachricht weil Sie einen neuen Link zu Ihren Bestellungen "
"für\n"
"{event} angefordert haben. Sie finden Ihre Bestellungen unter folgenden "
"Links:\n"
"\n"
File diff suppressed because it is too large Load Diff
+12
View File
@@ -19,3 +19,15 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: 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.
+53
View File
@@ -0,0 +1,53 @@
#
# 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/>.
#
import hashlib
import json
from django.core.cache import cache
from paypalrestsdk.api import Api as VendorApi
class Api(VendorApi):
def get_token_hash(self, authorization_code=None, refresh_token=None, headers=None):
if not authorization_code and not refresh_token:
checksum = hashlib.sha256(self.basic_auth().encode()).hexdigest()
cache_key_hash = f'pretix_paypal_token_hash_{checksum}'
cache_key_request_at = f'pretix_paypal_token_request_at_{checksum}'
token_hash = cache.get(cache_key_hash)
if token_hash:
token_request_at = cache.get(cache_key_request_at)
if token_request_at:
self.token_hash = json.loads(token_hash)
self.token_request_at = token_request_at
self.validate_token_hash()
if self.token_hash is not None:
return self.token_hash
t = super().get_token_hash(authorization_code, refresh_token, headers)
if self.token_hash:
cache.set(cache_key_hash, json.dumps(self.token_hash), 3600 * 4)
cache.set(cache_key_request_at, self.token_request_at, 3600 * 4)
return t
return super().get_token_hash(authorization_code, refresh_token, headers)
+69
View File
@@ -0,0 +1,69 @@
#
# 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/>.
#
# 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.
from django.apps import AppConfig
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from pretix import __version__ as version
class PaypalApp(AppConfig):
name = 'pretix.plugins.paypal'
verbose_name = _("PayPal")
class PretixPluginMeta:
name = _("PayPal")
author = _("the pretix team")
version = version
category = 'PAYMENT'
featured = True
picture = 'pretixplugins/paypal/paypal_logo.svg'
description = _("Accept payments with your PayPal account. PayPal is one of the most popular payment methods "
"world-wide.")
def ready(self):
from . import signals # NOQA
def is_available(self, event):
return 'pretix.plugins.paypal' in event.plugins.split(',')
@cached_property
def compatibility_errors(self):
errs = []
try:
import paypalrestsdk # NOQA
except ImportError:
errs.append("Python package 'paypalrestsdk' is not installed.")
return errs
@@ -1,21 +0,0 @@
# Generated by Django 5.2.17 on 2026-09-08 08:05
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("paypal", "0004_bigint"),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.DeleteModel(
name="ReferencedPayPalObject",
),
],
database_operations=[]
)
]
@@ -26,6 +26,3 @@ class ReferencedPayPalObject(models.Model):
reference = models.CharField(max_length=190, db_index=True, unique=True)
order = models.ForeignKey('pretixbase.Order', on_delete=models.CASCADE)
payment = models.ForeignKey('pretixbase.OrderPayment', null=True, blank=True, on_delete=models.CASCADE)
class Meta:
db_table = 'paypal_referencedpaypalobject'
+715
View File
@@ -0,0 +1,715 @@
#
# 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/>.
#
# 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: Jakob Schnell, Tobias Kunze
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import json
import logging
from collections import OrderedDict
from decimal import Decimal
import paypalrestsdk
import paypalrestsdk.exceptions
from django import forms
from django.contrib import messages
from django.http import HttpRequest
from django.template.loader import get_template
from django.urls import reverse
from django.utils.html import format_html
from django.utils.timezone import now
from django.utils.translation import gettext as __, gettext_lazy as _
from i18nfield.strings import LazyI18nString
from paypalrestsdk.exceptions import BadRequest, UnauthorizedAccess
from paypalrestsdk.openid_connect import Tokeninfo
from requests import RequestException
from pretix.base.decimal import round_decimal
from pretix.base.forms import SecretKeySettingsField
from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.settings import SettingsSandbox
from pretix.base.views.redirect import safelink
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.plugins.paypal.api import Api
from pretix.plugins.paypal.models import ReferencedPayPalObject
logger = logging.getLogger('pretix.plugins.paypal')
SUPPORTED_CURRENCIES = ['AUD', 'BRL', 'CAD', 'CZK', 'DKK', 'EUR', 'HKD', 'HUF', 'INR', 'ILS', 'JPY', 'MYR', 'MXN',
'TWD', 'NZD', 'NOK', 'PHP', 'PLN', 'GBP', 'RUB', 'SGD', 'SEK', 'CHF', 'THB', 'USD']
LOCAL_ONLY_CURRENCIES = ['INR']
class Paypal(BasePaymentProvider):
identifier = 'paypal'
verbose_name = _('PayPal')
payment_form_fields = OrderedDict([
])
def __init__(self, event: Event):
super().__init__(event)
self.settings = SettingsSandbox('payment', 'paypal', event)
@property
def test_mode_message(self):
if self.settings.connect_client_id and not self.settings.secret:
# in OAuth mode, sandbox mode needs to be set global
is_sandbox = self.settings.connect_endpoint == 'sandbox'
else:
is_sandbox = self.settings.get('endpoint') == 'sandbox'
if is_sandbox:
return _('The PayPal sandbox is being used, you can test without actually sending money but you will need a '
'PayPal sandbox user to log in.')
return None
@property
def settings_form_fields(self):
if self.settings.connect_client_id and not self.settings.secret:
# PayPal connect
if self.settings.connect_user_id:
fields = [
('connect_user_id',
forms.CharField(
label=_('PayPal account'),
disabled=True
)),
]
else:
return {}
else:
fields = [
('client_id',
forms.CharField(
label=_('Client ID'),
min_length=80,
help_text=format_html(
'<a target="_blank" rel="noopener" href="{docs_url}">{text}</a>',
text=_('Click here for a tutorial on how to obtain the required keys'),
docs_url='https://docs.pretix.eu/en/latest/user/payments/paypal.html',
)
)),
('secret',
SecretKeySettingsField(
label=_('Secret'),
max_length=80,
min_length=80,
)),
('endpoint',
forms.ChoiceField(
label=_('Endpoint'),
initial='live',
choices=(
('live', 'Live'),
('sandbox', 'Sandbox'),
),
)),
]
extra_fields = [
('prefix',
forms.CharField(
label=_('Reference prefix'),
help_text=_('Any value entered here will be added in front of the regular booking reference '
'containing the order number.'),
required=False,
)),
('postfix',
forms.CharField(
label=_('Reference postfix'),
help_text=_('Any value entered here will be added behind the regular booking reference '
'containing the order number.'),
required=False,
)),
]
d = OrderedDict(
fields + extra_fields + list(super().settings_form_fields.items())
)
d.move_to_end('prefix')
d.move_to_end('postfix')
d.move_to_end('_enabled', False)
return d
def get_connect_url(self, request):
request.session['payment_paypal_oauth_event'] = request.event.pk
self.init_api()
return Tokeninfo.authorize_url({'scope': 'openid profile email'})
def settings_content_render(self, request):
settings_content = ""
if self.settings.connect_client_id and not self.settings.secret:
# Use PayPal connect
if not self.settings.connect_user_id:
# Migrate User to PayPal v2
self.event.disable_plugin("pretix.plugins.paypal")
self.event.enable_plugin("pretix.plugins.paypal2")
self.event.save()
else:
settings_content = (
"<button formaction='{}' class='btn btn-danger'>{}</button>"
).format(
reverse('plugins:paypal:oauth.disconnect', kwargs={
'organizer': self.event.organizer.slug,
'event': self.event.slug,
}),
_('Disconnect from PayPal')
)
else:
# Migrate User to PayPal v2
self.event.disable_plugin("pretix.plugins.paypal")
self.event.enable_plugin("pretix.plugins.paypal2")
self.event.save()
return settings_content
def is_allowed(self, request: HttpRequest, total: Decimal = None) -> bool:
return super().is_allowed(request, total) and self.event.currency in SUPPORTED_CURRENCIES
def init_api(self):
if self.settings.connect_client_id and not self.settings.secret:
paypalrestsdk.api.__api__ = Api(
mode="sandbox" if "sandbox" in self.settings.connect_endpoint else 'live',
client_id=self.settings.connect_client_id,
client_secret=self.settings.connect_secret_key,
openid_client_id=self.settings.connect_client_id,
openid_client_secret=self.settings.connect_secret_key
)
else:
paypalrestsdk.api.__api__ = Api(
mode="sandbox" if "sandbox" in self.settings.get('endpoint') else 'live',
client_id=self.settings.get('client_id'),
client_secret=self.settings.get('secret')
)
def payment_is_valid_session(self, request):
return (request.session.get('payment_paypal_id', '') != ''
and request.session.get('payment_paypal_payer', '') != '')
def payment_form_render(self, request) -> str:
template = get_template('pretixplugins/paypal/checkout_payment_form.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
def checkout_prepare(self, request, cart):
self.init_api()
kwargs = {}
if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs:
kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace']
try:
if self.settings.connect_client_id and not self.settings.secret:
if not request.event.settings.payment_paypal_connect_user_id:
raise PaymentException('Payment method misconfigured')
try:
tokeninfo = Tokeninfo.create_with_refresh_token(request.event.settings.payment_paypal_connect_refresh_token)
except BadRequest as ex:
ex = json.loads(ex.content)
messages.error(request, '{}: {} ({})'.format(
_('We had trouble communicating with PayPal'),
ex['error_description'],
ex['correlation_id'])
)
return
# Even if the token has been refreshed, calling userinfo() can fail. In this case we just don't
# get the userinfo again and use the payment_paypal_connect_user_id that we already have on file
try:
userinfo = tokeninfo.userinfo()
request.event.settings.payment_paypal_connect_user_id = userinfo.email
except UnauthorizedAccess:
pass
payee = {
"email": request.event.settings.payment_paypal_connect_user_id,
# If PayPal ever offers a good way to get the MerchantID via the Identifity API,
# we should use it instead of the merchant's eMail-address
# "merchant_id": request.event.settings.payment_paypal_connect_user_id,
}
else:
payee = {}
payment = paypalrestsdk.Payment({
'header': {'PayPal-Partner-Attribution-Id': 'ramiioSoftwareentwicklung_SP'},
'intent': 'sale',
'payer': {
"payment_method": "paypal",
},
"redirect_urls": {
"return_url": eventreverse_absolute(request.event, 'plugins:paypal:return', kwargs=kwargs),
"cancel_url": eventreverse_absolute(request.event, 'plugins:paypal:abort', kwargs=kwargs),
},
"transactions": [
{
"item_list": {
"items": [
{
"name": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order for %s') % str(request.event),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"quantity": 1,
"price": self.format_price(cart['total']),
"currency": request.event.currency
}
]
},
"amount": {
"currency": request.event.currency,
"total": self.format_price(cart['total'])
},
"description": __('Event tickets for {event}').format(event=request.event.name),
"payee": payee,
"custom": '{prefix}{slug}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
slug=request.event.slug.upper(),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
)
}
]
})
request.session['payment_paypal_payment'] = None
return self._create_payment(request, payment)
except paypalrestsdk.exceptions.ConnectionError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
def format_price(self, value):
return str(round_decimal(value, self.event.currency, {
# PayPal behaves differently than Stripe in deciding what currencies have decimal places
# Source https://developer.paypal.com/docs/classic/api/currency_codes/
'HUF': 0,
'JPY': 0,
'MYR': 0,
'TWD': 0,
# However, CLPs are not listed there while PayPal requires us not to send decimal places there. WTF.
'CLP': 0,
# Let's just guess that the ones listed here are 0-based as well
# https://developers.braintreepayments.com/reference/general/currencies
'BIF': 0,
'DJF': 0,
'GNF': 0,
'KMF': 0,
'KRW': 0,
'LAK': 0,
'PYG': 0,
'RWF': 0,
'UGX': 0,
'VND': 0,
'VUV': 0,
'XAF': 0,
'XOF': 0,
'XPF': 0,
}))
@property
def abort_pending_allowed(self):
return False
def _create_payment(self, request, payment):
if payment.create():
if payment.state not in ('created', 'approved', 'pending'):
messages.error(request, _('We had trouble communicating with PayPal'))
logger.error('Invalid payment state: ' + str(payment))
return
request.session['payment_paypal_id'] = payment.id
for link in payment.links:
if link.method == "REDIRECT" and link.rel == "approval_url":
if request.session.get('iframe_session', False):
return safelink(link.href, framebreak=True)
else:
return str(link.href)
else:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.error('Error on creating payment: ' + str(payment.error))
def checkout_confirm_render(self, request) -> str:
"""
Returns the HTML that should be displayed when the user selected this provider
on the 'confirm order' page.
"""
template = get_template('pretixplugins/paypal/checkout_payment_confirm.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
if (request.session.get('payment_paypal_id', '') == '' or request.session.get('payment_paypal_payer', '') == ''):
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
self.init_api()
pp_payment = paypalrestsdk.Payment.find(request.session.get('payment_paypal_id'))
ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=pp_payment.id)
if str(pp_payment.transactions[0].amount.total) != str(payment.amount) or pp_payment.transactions[0].amount.currency \
!= self.event.currency:
logger.error('Value mismatch: Payment %s vs paypal trans %s' % (payment.id, str(pp_payment)))
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
return self._execute_payment(pp_payment, request, payment)
def _execute_payment(self, payment, request, payment_obj):
if payment.state == 'created':
payment.replace([
{
"op": "replace",
"path": "/transactions/0/item_list",
"value": {
"items": [
{
"name": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {slug}-{code}').format(
slug=self.event.slug.upper(),
code=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"quantity": 1,
"price": self.format_price(payment_obj.amount),
"currency": payment_obj.order.event.currency
}
]
}
},
{
"op": "replace",
"path": "/transactions/0/description",
"value": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {order} for {event}').format(
event=request.event.name,
order=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
}
])
try:
payment.execute({"payer_id": request.session.get('payment_paypal_payer')})
except paypalrestsdk.exceptions.ConnectionError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
except RequestException as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
for trans in payment.transactions:
for rr in trans.related_resources:
if hasattr(rr, 'sale') and rr.sale:
if rr.sale.state == 'pending':
messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as '
'soon as the payment completed.'))
payment_obj.info = json.dumps(payment.to_dict())
payment_obj.state = OrderPayment.PAYMENT_STATE_PENDING
payment_obj.save()
return
payment_obj.refresh_from_db()
if payment.state == 'pending':
messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as soon as the '
'payment completed.'))
payment_obj.info = json.dumps(payment.to_dict())
payment_obj.state = OrderPayment.PAYMENT_STATE_PENDING
payment_obj.save()
return
if payment.state != 'approved':
payment_obj.fail(info=payment.to_dict())
logger.error('Invalid state: %s' % str(payment))
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
if payment_obj.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
logger.warning('PayPal success event even though order is already marked as paid')
return
try:
payment_obj.info = json.dumps(payment.to_dict())
payment_obj.save(update_fields=['info'])
payment_obj.confirm()
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
return None
def payment_pending_render(self, request, payment) -> str:
retry = True
try:
if (
payment.info
and payment.info_data['transactions'][0]['related_resources'][0]['sale']['state'] == 'pending'
):
retry = False
except (KeyError, IndexError):
pass
template = get_template('pretixplugins/paypal/pending.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'retry': retry, 'order': payment.order}
return template.render(ctx)
def matching_id(self, payment: OrderPayment):
sale_id = None
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
return sale_id or payment.info_data.get('id', None)
def api_payment_details(self, payment: OrderPayment):
sale_id = None
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
return {
"payer_email": payment.info_data.get('payer', {}).get('payer_info', {}).get('email'),
"payer_id": payment.info_data.get('payer', {}).get('payer_info', {}).get('payer_id'),
"cart_id": payment.info_data.get('cart', None),
"payment_id": payment.info_data.get('id', None),
"sale_id": sale_id,
}
def payment_control_render(self, request: HttpRequest, payment: OrderPayment):
template = get_template('pretixplugins/paypal/control.html')
sale_id = None
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'payment_info': payment.info_data, 'order': payment.order, 'sale_id': sale_id}
return template.render(ctx)
def payment_control_render_short(self, payment: OrderPayment) -> str:
return payment.info_data.get('payer', {}).get('payer_info', {}).get('email', '')
def payment_partial_refund_supported(self, payment: OrderPayment):
# Paypal refunds are possible for 180 days after purchase:
# https://www.paypal.com/lc/smarthelp/article/how-do-i-issue-a-refund-faq780#:~:text=Refund%20after%20180%20days%20of,PayPal%20balance%20of%20the%20recipient.
return (now() - payment.payment_date).days <= 180
def payment_refund_supported(self, payment: OrderPayment):
self.payment_partial_refund_supported(payment)
def execute_refund(self, refund: OrderRefund):
self.init_api()
try:
sale = None
for res in refund.payment.info_data['transactions'][0]['related_resources']:
for k, v in res.items():
if k == 'sale':
sale = paypalrestsdk.Sale.find(v['id'])
break
if not sale:
pp_payment = paypalrestsdk.Payment.find(refund.payment.info_data['id'])
for res in pp_payment.transactions[0].related_resources:
for k, v in res.to_dict().items():
if k == 'sale':
sale = paypalrestsdk.Sale.find(v['id'])
break
pp_refund = sale.refund({
"amount": {
"total": self.format_price(refund.amount),
"currency": refund.order.event.currency
}
})
except paypalrestsdk.exceptions.ConnectionError as e:
refund.order.log_action('pretix.event.order.refund.failed', {
'local_id': refund.local_id,
'provider': refund.provider,
'error': str(e)
})
raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(str(e)))
if not pp_refund.success():
refund.order.log_action('pretix.event.order.refund.failed', {
'local_id': refund.local_id,
'provider': refund.provider,
'error': str(pp_refund.error)
})
raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(pp_refund.error))
else:
sale = paypalrestsdk.Payment.find(refund.payment.info_data['id'])
refund.payment.info = json.dumps(sale.to_dict())
refund.info = json.dumps(pp_refund.to_dict())
refund.done()
def payment_prepare(self, request, payment_obj):
self.init_api()
try:
if request.event.settings.payment_paypal_connect_user_id:
try:
tokeninfo = Tokeninfo.create_with_refresh_token(request.event.settings.payment_paypal_connect_refresh_token)
except BadRequest as ex:
ex = json.loads(ex.content)
messages.error(request, '{}: {} ({})'.format(
_('We had trouble communicating with PayPal'),
ex['error_description'],
ex['correlation_id'])
)
return
# Even if the token has been refreshed, calling userinfo() can fail. In this case we just don't
# get the userinfo again and use the payment_paypal_connect_user_id that we already have on file
try:
userinfo = tokeninfo.userinfo()
request.event.settings.payment_paypal_connect_user_id = userinfo.email
except UnauthorizedAccess:
pass
payee = {
"email": request.event.settings.payment_paypal_connect_user_id,
# If PayPal ever offers a good way to get the MerchantID via the Identifity API,
# we should use it instead of the merchant's eMail-address
# "merchant_id": request.event.settings.payment_paypal_connect_user_id,
}
else:
payee = {}
payment = paypalrestsdk.Payment({
'header': {'PayPal-Partner-Attribution-Id': 'ramiioSoftwareentwicklung_SP'},
'intent': 'sale',
'payer': {
"payment_method": "paypal",
},
"redirect_urls": {
"return_url": eventreverse_absolute(request.event, 'plugins:paypal:return'),
"cancel_url": eventreverse_absolute(request.event, 'plugins:paypal:abort'),
},
"transactions": [
{
"item_list": {
"items": [
{
"name": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {slug}-{code}').format(
slug=self.event.slug.upper(),
code=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"quantity": 1,
"price": self.format_price(payment_obj.amount),
"currency": payment_obj.order.event.currency
}
]
},
"amount": {
"currency": request.event.currency,
"total": self.format_price(payment_obj.amount)
},
"description": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {order} for {event}').format(
event=request.event.name,
order=payment_obj.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
"payee": payee,
"custom": '{prefix}{slug}-{code}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
slug=self.event.slug.upper(),
code=payment_obj.order.code,
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
}
]
})
request.session['payment_paypal_payment'] = payment_obj.pk
return self._create_payment(request, payment)
except paypalrestsdk.exceptions.ConnectionError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('Error on creating payment: ' + str(e))
def shred_payment_info(self, obj):
if obj.info:
d = json.loads(obj.info)
new = {
'id': d.get('id'),
'payer': {
'payer_info': {
'email': '█'
}
},
'update_time': d.get('update_time'),
'transactions': [
{
'amount': t.get('amount')
} for t in d.get('transactions', [])
],
'_shredded': True
}
obj.info = json.dumps(new)
obj.save(update_fields=['info'])
for le in obj.order.all_logentries().filter(action_type="pretix.plugins.paypal.event").exclude(data=""):
d = le.parsed_data
if 'resource' in d:
d['resource'] = {
'id': d['resource'].get('id'),
'sale_id': d['resource'].get('sale_id'),
'parent_payment': d['resource'].get('parent_payment'),
}
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
def render_invoice_text(self, order: Order, payment: OrderPayment) -> str:
if order.status == Order.STATUS_PAID:
if payment.info_data.get('id', None):
try:
return '{}\r\n{}: {}\r\n{}: {}'.format(
_('The payment for this invoice has already been received.'),
_('PayPal payment ID'),
payment.info_data['id'],
_('PayPal sale ID'),
payment.info_data['transactions'][0]['related_resources'][0]['sale']['id']
)
except (KeyError, IndexError):
return '{}\r\n{}: {}'.format(
_('The payment for this invoice has already been received.'),
_('PayPal payment ID'),
payment.info_data['id']
)
else:
return super().render_invoice_text(order, payment)
return self.settings.get('_invoice_text', as_type=LazyI18nString, default='')
+31
View File
@@ -0,0 +1,31 @@
#
# 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 django.dispatch import receiver
from pretix.base.signals import register_payment_providers
@receiver(register_payment_providers, dispatch_uid="payment_paypal")
def register_payment_provider(sender, **kwargs):
from .payment import Paypal
return Paypal
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 16.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="124px" height="33px" viewBox="0 0 124 33" enable-background="new 0 0 124 33" xml:space="preserve">
<path fill="#253B80" d="M46.211,6.749h-6.839c-0.468,0-0.866,0.34-0.939,0.802l-2.766,17.537c-0.055,0.346,0.213,0.658,0.564,0.658 h3.265c0.468,0,0.866-0.34,0.939-0.803l0.746-4.73c0.072-0.463,0.471-0.803,0.938-0.803h2.165c4.505,0,7.105-2.18,7.784-6.5 c0.306-1.89,0.013-3.375-0.872-4.415C50.224,7.353,48.5,6.749,46.211,6.749z M47,13.154c-0.374,2.454-2.249,2.454-4.062,2.454 h-1.032l0.724-4.583c0.043-0.277,0.283-0.481,0.563-0.481h0.473c1.235,0,2.4,0,3.002,0.704C47.027,11.668,47.137,12.292,47,13.154z"/>
<path fill="#253B80" d="M66.654,13.075h-3.275c-0.279,0-0.52,0.204-0.563,0.481l-0.145,0.916l-0.229-0.332 c-0.709-1.029-2.29-1.373-3.868-1.373c-3.619,0-6.71,2.741-7.312,6.586c-0.313,1.918,0.132,3.752,1.22,5.031 c0.998,1.176,2.426,1.666,4.125,1.666c2.916,0,4.533-1.875,4.533-1.875l-0.146,0.91c-0.055,0.348,0.213,0.66,0.562,0.66h2.95 c0.469,0,0.865-0.34,0.939-0.803l1.77-11.209C67.271,13.388,67.004,13.075,66.654,13.075z M62.089,19.449 c-0.316,1.871-1.801,3.127-3.695,3.127c-0.951,0-1.711-0.305-2.199-0.883c-0.484-0.574-0.668-1.391-0.514-2.301 c0.295-1.855,1.805-3.152,3.67-3.152c0.93,0,1.686,0.309,2.184,0.892C62.034,17.721,62.232,18.543,62.089,19.449z"/>
<path fill="#253B80" d="M84.096,13.075h-3.291c-0.314,0-0.609,0.156-0.787,0.417l-4.539,6.686l-1.924-6.425 c-0.121-0.402-0.492-0.678-0.912-0.678h-3.234c-0.393,0-0.666,0.384-0.541,0.754l3.625,10.638l-3.408,4.811 c-0.268,0.379,0.002,0.9,0.465,0.9h3.287c0.312,0,0.604-0.152,0.781-0.408L84.564,13.97C84.826,13.592,84.557,13.075,84.096,13.075z "/>
<path fill="#179BD7" d="M94.992,6.749h-6.84c-0.467,0-0.865,0.34-0.938,0.802l-2.766,17.537c-0.055,0.346,0.213,0.658,0.562,0.658 h3.51c0.326,0,0.605-0.238,0.656-0.562l0.785-4.971c0.072-0.463,0.471-0.803,0.938-0.803h2.164c4.506,0,7.105-2.18,7.785-6.5 c0.307-1.89,0.012-3.375-0.873-4.415C99.004,7.353,97.281,6.749,94.992,6.749z M95.781,13.154c-0.373,2.454-2.248,2.454-4.062,2.454 h-1.031l0.725-4.583c0.043-0.277,0.281-0.481,0.562-0.481h0.473c1.234,0,2.4,0,3.002,0.704 C95.809,11.668,95.918,12.292,95.781,13.154z"/>
<path fill="#179BD7" d="M115.434,13.075h-3.273c-0.281,0-0.52,0.204-0.562,0.481l-0.145,0.916l-0.23-0.332 c-0.709-1.029-2.289-1.373-3.867-1.373c-3.619,0-6.709,2.741-7.311,6.586c-0.312,1.918,0.131,3.752,1.219,5.031 c1,1.176,2.426,1.666,4.125,1.666c2.916,0,4.533-1.875,4.533-1.875l-0.146,0.91c-0.055,0.348,0.213,0.66,0.564,0.66h2.949 c0.467,0,0.865-0.34,0.938-0.803l1.771-11.209C116.053,13.388,115.785,13.075,115.434,13.075z M110.869,19.449 c-0.314,1.871-1.801,3.127-3.695,3.127c-0.949,0-1.711-0.305-2.199-0.883c-0.484-0.574-0.666-1.391-0.514-2.301 c0.297-1.855,1.805-3.152,3.67-3.152c0.93,0,1.686,0.309,2.184,0.892C110.816,17.721,111.014,18.543,110.869,19.449z"/>
<path fill="#179BD7" d="M119.295,7.23l-2.807,17.858c-0.055,0.346,0.213,0.658,0.562,0.658h2.822c0.469,0,0.867-0.34,0.939-0.803 l2.768-17.536c0.055-0.346-0.213-0.659-0.562-0.659h-3.16C119.578,6.749,119.338,6.953,119.295,7.23z"/>
<path fill="#253B80" d="M7.266,29.154l0.523-3.322l-1.165-0.027H1.061L4.927,1.292C4.939,1.218,4.978,1.149,5.035,1.1 c0.057-0.049,0.13-0.076,0.206-0.076h9.38c3.114,0,5.263,0.648,6.385,1.927c0.526,0.6,0.861,1.227,1.023,1.917 c0.17,0.724,0.173,1.589,0.007,2.644l-0.012,0.077v0.676l0.526,0.298c0.443,0.235,0.795,0.504,1.065,0.812 c0.45,0.513,0.741,1.165,0.864,1.938c0.127,0.795,0.085,1.741-0.123,2.812c-0.24,1.232-0.628,2.305-1.152,3.183 c-0.482,0.809-1.096,1.48-1.825,2c-0.696,0.494-1.523,0.869-2.458,1.109c-0.906,0.236-1.939,0.355-3.072,0.355h-0.73 c-0.522,0-1.029,0.188-1.427,0.525c-0.399,0.344-0.663,0.814-0.744,1.328l-0.055,0.299l-0.924,5.855l-0.042,0.215 c-0.011,0.068-0.03,0.102-0.058,0.125c-0.025,0.021-0.061,0.035-0.096,0.035H7.266z"/>
<path fill="#179BD7" d="M23.048,7.667L23.048,7.667L23.048,7.667c-0.028,0.179-0.06,0.362-0.096,0.55 c-1.237,6.351-5.469,8.545-10.874,8.545H9.326c-0.661,0-1.218,0.48-1.321,1.132l0,0l0,0L6.596,26.83l-0.399,2.533 c-0.067,0.428,0.263,0.814,0.695,0.814h4.881c0.578,0,1.069-0.42,1.16-0.99l0.048-0.248l0.919-5.832l0.059-0.32 c0.09-0.572,0.582-0.992,1.16-0.992h0.73c4.729,0,8.431-1.92,9.513-7.476c0.452-2.321,0.218-4.259-0.978-5.622 C24.022,8.286,23.573,7.945,23.048,7.667z"/>
<path fill="#222D65" d="M21.754,7.151c-0.189-0.055-0.384-0.105-0.584-0.15c-0.201-0.044-0.407-0.083-0.619-0.117 c-0.742-0.12-1.555-0.177-2.426-0.177h-7.352c-0.181,0-0.353,0.041-0.507,0.115C9.927,6.985,9.675,7.306,9.614,7.699L8.05,17.605 l-0.045,0.289c0.103-0.652,0.66-1.132,1.321-1.132h2.752c5.405,0,9.637-2.195,10.874-8.545c0.037-0.188,0.068-0.371,0.096-0.55 c-0.313-0.166-0.652-0.308-1.017-0.429C21.941,7.208,21.848,7.179,21.754,7.151z"/>
<path fill="#253B80" d="M9.614,7.699c0.061-0.393,0.313-0.714,0.652-0.876c0.155-0.074,0.326-0.115,0.507-0.115h7.352 c0.871,0,1.684,0.057,2.426,0.177c0.212,0.034,0.418,0.073,0.619,0.117c0.2,0.045,0.395,0.095,0.584,0.15 c0.094,0.028,0.187,0.057,0.278,0.086c0.365,0.121,0.704,0.264,1.017,0.429c0.368-2.347-0.003-3.945-1.272-5.392 C20.378,0.682,17.853,0,14.622,0h-9.38c-0.66,0-1.223,0.48-1.325,1.133L0.01,25.898c-0.077,0.49,0.301,0.932,0.795,0.932h5.791 l1.454-9.225L9.614,7.699z"/>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1,6 @@
{% load i18n %}
<p>{% blocktrans trimmed %}
The total amount listed above will be withdrawn from your PayPal account after the
confirmation of your purchase.
{% endblocktrans %}</p>
@@ -0,0 +1,6 @@
{% load i18n %}
<p>{% blocktrans trimmed %}
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.
{% endblocktrans %}</p>
@@ -0,0 +1,18 @@
{% load i18n %}
{% if payment_info %}
<dl class="dl-horizontal">
<dt>{% trans "Payment ID" %}</dt>
<dd>{{ payment_info.id }}</dd>
<dt>{% trans "Sale ID" %}</dt>
<dd>{{ sale_id|default_if_none:"?" }}</dd>
<dt>{% trans "Payer" %}</dt>
<dd>{{ payment_info.payer.payer_info.email }}</dd>
<dt>{% trans "Last update" %}</dt>
<dd>{{ payment_info.update_time }}</dd>
<dt>{% trans "Total value" %}</dt>
<dd>{{ payment_info.transactions.0.amount.total }}</dd>
<dt>{% trans "Currency" %}</dt>
<dd>{{ payment_info.transactions.0.amount.currency }}</dd>
</dl>
{% endif %}
@@ -0,0 +1,12 @@
{% load i18n %}
{% if retry %}
<p>{% blocktrans trimmed %}
Our attempt to execute your Payment via PayPal has failed. Please try again or contact us.
{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed %}
We're waiting for an answer from PayPal regarding your payment. Please contact us, if this
takes more than a few hours.
{% endblocktrans %}</p>
{% endif %}
+39
View File
@@ -0,0 +1,39 @@
#
# 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 django.urls import include, re_path
from .views import abort, oauth_disconnect, success
event_patterns = [
re_path(r'^paypal/', include([
re_path(r'^abort/$', abort, name='abort'),
re_path(r'^return/$', success, name='return'),
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/abort/', abort, name='abort'),
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/return/', success, name='return'),
])),
]
urlpatterns = [
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/paypal/disconnect/',
oauth_disconnect, name='oauth.disconnect'),
]
+249
View File
@@ -0,0 +1,249 @@
#
# 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/>.
#
# 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: Flavia Bastos
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import json
import logging
from decimal import Decimal
import paypalrestsdk
import paypalrestsdk.exceptions
from django.contrib import messages
from django.db.models import Sum
from django.http import HttpResponse
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django_scopes import scopes_disabled
from pretix.base.models import Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import PaymentException
from pretix.control.permissions import event_permission_required
from pretix.helpers.http import redirect_to_url
from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.plugins.paypal.payment import Paypal
logger = logging.getLogger('pretix.plugins.paypal')
def success(request, *args, **kwargs):
pid = request.GET.get('paymentId')
token = request.GET.get('token')
payer = request.GET.get('PayerID')
request.session['payment_paypal_token'] = token
request.session['payment_paypal_payer'] = payer
urlkwargs = {}
if 'cart_namespace' in kwargs:
urlkwargs['cart_namespace'] = kwargs['cart_namespace']
if request.session.get('payment_paypal_payment'):
payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment'))
else:
payment = None
if pid == request.session.get('payment_paypal_id', None):
if payment:
prov = Paypal(request.event)
try:
resp = prov.execute_payment(request, payment)
except PaymentException as e:
messages.error(request, str(e))
urlkwargs['step'] = 'payment'
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
if resp:
return resp
else:
messages.error(request, _('Invalid response from PayPal received.'))
logger.error('Session did not contain payment_paypal_id')
urlkwargs['step'] = 'payment'
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
if payment:
return redirect_to_url(eventreverse(request.event, 'presale:event.order', kwargs={
'order': payment.order.code,
'secret': payment.order.secret
}) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else ''))
else:
urlkwargs['step'] = 'confirm'
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
def abort(request, *args, **kwargs):
messages.error(request, _('It looks like you canceled the PayPal payment'))
if request.session.get('payment_paypal_payment'):
payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment'))
else:
payment = None
if payment:
return redirect_to_url(eventreverse(request.event, 'presale:event.order', kwargs={
'order': payment.order.code,
'secret': payment.order.secret
}) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else ''))
else:
return redirect_to_url(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'payment'}))
@csrf_exempt
@require_POST
@scopes_disabled()
def webhook(request, *args, **kwargs):
event_body = request.body.decode('utf-8').strip()
event_json = json.loads(event_body)
# We do not check the signature, we just use it as a trigger to look the charge up.
if 'resource_type' not in event_json:
return HttpResponse("Invalid body, no resource_type given", status=400)
if event_json['resource_type'] not in ('sale', 'refund'):
return HttpResponse("Not interested in this resource type", status=200)
if event_json['resource_type'] == 'sale':
saleid = event_json['resource']['id']
else:
saleid = event_json['resource']['sale_id']
try:
refs = [saleid]
if event_json['resource'].get('parent_payment'):
refs.append(event_json['resource'].get('parent_payment'))
rso = ReferencedPayPalObject.objects.select_related('order', 'order__event').get(
reference__in=refs
)
event = rso.order.event
except ReferencedPayPalObject.DoesNotExist:
rso = None
if hasattr(request, 'event'):
event = request.event
else:
return HttpResponse("Unable to detect event", status=200)
prov = Paypal(event)
prov.init_api()
try:
sale = paypalrestsdk.Sale.find(saleid)
except paypalrestsdk.exceptions.ConnectionError:
logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Sale not found', status=500)
if rso and rso.payment:
payment = rso.payment
else:
payments = OrderPayment.objects.filter(order__event=event, provider='paypal',
info__icontains=sale['id'])
payment = None
for p in payments:
payment_info = p.info_data
for res in payment_info['transactions'][0]['related_resources']:
for k, v in res.items():
if k == 'sale' and v['id'] == sale['id']:
payment = p
break
if not payment:
return HttpResponse('Payment not found', status=200)
payment.order.log_action('pretix.plugins.paypal.event', data=event_json)
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['state'] in ('partially_refunded', 'refunded'):
if event_json['resource_type'] == 'refund':
try:
refund = paypalrestsdk.Refund.find(event_json['resource']['id'])
except paypalrestsdk.exceptions.ConnectionError:
logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Refund not found', status=500)
known_refunds = {r.info_data.get('id'): r for r in payment.refunds.all()}
if refund['id'] not in known_refunds:
payment.create_external_refund(
amount=abs(Decimal(refund['amount']['total'])),
info=json.dumps(refund.to_dict() if not isinstance(refund, dict) else refund)
)
elif known_refunds.get(refund['id']).state in (
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['state'] == 'completed':
known_refunds.get(refund['id']).done()
if 'total_refunded_amount' in refund:
known_sum = payment.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
total_refunded_amount = Decimal(refund['total_refunded_amount']['value'])
if known_sum < total_refunded_amount:
payment.create_external_refund(
amount=total_refunded_amount - known_sum
)
elif sale['state'] == 'refunded':
known_sum = payment.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
if known_sum < payment.amount:
payment.create_external_refund(
amount=payment.amount - known_sum
)
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED,
OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED) and sale['state'] == 'completed':
try:
payment.confirm()
except Quota.QuotaExceededException:
pass
return HttpResponse(status=200)
@event_permission_required('event.settings.general:write')
@require_POST
def oauth_disconnect(request, **kwargs):
del request.event.settings.payment_paypal_connect_refresh_token
del request.event.settings.payment_paypal_connect_user_id
request.event.settings.payment_paypal__enabled = False
messages.success(request, _('Your PayPal account has been disconnected.'))
# Migrate User to PayPal v2
event = request.event
event.disable_plugin("pretix.plugins.paypal")
event.enable_plugin("pretix.plugins.paypal2")
event.save()
return redirect_to_url(reverse('control:event.settings.payment.provider', kwargs={
'organizer': request.event.organizer.slug,
'event': request.event.slug,
'provider': 'paypal_settings'
}))
@@ -1,54 +0,0 @@
# Generated by Django 5.2.17 on 2026-09-08 08:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("pretixbase", "0310_question_valid_string_length_min"),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.CreateModel(
name="ReferencedPayPalObject",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
(
"reference",
models.CharField(db_index=True, max_length=190, unique=True),
),
(
"order",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.order",
),
),
(
"payment",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.orderpayment",
),
),
],
options={
"db_table": "paypal_referencedpaypalobject",
},
),
],
database_operations=[]
)
]
+1 -1
View File
@@ -68,7 +68,7 @@ from pretix.plugins.paypal2.client.core.paypal_http_client import (
from pretix.plugins.paypal2.client.customer.partner_referral_create_request import (
PartnerReferralCreateRequest,
)
from pretix.plugins.paypal2.models import ReferencedPayPalObject
from pretix.plugins.paypal.models import ReferencedPayPalObject
logger = logging.getLogger('pretix.plugins.paypal2')
+3 -3
View File
@@ -67,10 +67,10 @@ from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal2.client.customer.partners_merchantintegrations_get_request import (
PartnersMerchantIntegrationsGetRequest,
)
from pretix.plugins.paypal2.models import ReferencedPayPalObject
from pretix.plugins.paypal2.payment import (
PaypalMethod, PaypalMethod as Paypal, PaypalWallet,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.presale.views import get_cart
from pretix.presale.views.cart import cart_session
@@ -350,8 +350,8 @@ def webhook(request, *args, **kwargs):
return HttpResponse("Invalid body, no event_type given", status=400)
if event_json['event_type'].startswith('PAYMENT.SALE.'):
logger.info(f"Received PPv1 webhook: {json.dumps(event_json)}")
return HttpResponse("PayPal V1 no longer supported", status=400)
from pretix.plugins.paypal.views import webhook
return webhook(request, *args, **kwargs)
# V1/V2 Sorting -- End
# We do not check the signature, we just use it as a trigger to look the charge up.
+21 -42
View File
@@ -98,7 +98,9 @@ from pretix.presale.signals import (
question_form_fields_overrides,
)
from pretix.presale.utils import customer_login
from pretix.presale.views import CartMixin, get_cart, get_cart_is_free
from pretix.presale.views import (
CartMixin, get_cart_is_free, get_cart_positions,
)
from pretix.presale.views.cart import (
_items_from_post_data, cart_session, create_empty_cart_id,
get_or_create_cart_id,
@@ -497,7 +499,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
self.request = request
# check whether addons are applicable
if get_cart(request).filter(item__addons__isnull=False).exists():
if get_cart_positions(request).filter(item__addons__isnull=False).exists():
return True
# don't re-check whether cross-selling is applicable if we're already past the AddOnsStep
@@ -1090,26 +1092,18 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
return False
for cp in self._positions_for_questions:
answ = {
aw.question_id: aw for aw in cp.answerlist
}
question_cache = {
q.pk: q for q in cp.item.questions_to_ask
qc_cache = {
qc.pk: qc for qq in cp.item.relevant_questionnaires for qc in qq.childlist
}
def question_is_visible(parentid, qvals):
if parentid not in question_cache:
if parentid not in qc_cache:
return False
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
parentqc = qc_cache[parentid]
if parentqc.dependency_question_id and not question_is_visible(parentqc.dependency_question_id, parentqc.dependency_values):
return False
if parentid not in answ:
return False
return (
('True' in qvals and answ[parentid].answer == 'True')
or ('False' in qvals and answ[parentid].answer == 'False')
or (any(qval in [o.identifier for o in answ[parentid].options.all()] for qval in qvals))
)
answer_values = cp.get_dependency_answer_values(parentqc)
return any(qval in answer_values for qval in qvals)
def question_is_required(q):
return (
@@ -1118,31 +1112,16 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
)
if not self.all_optional:
for q in cp.item.questions_to_ask:
if question_is_required(q) and q.id not in answ:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_names_required', as_type=bool) \
and not cp.attendee_name_parts:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_emails_required', as_type=bool) \
and cp.attendee_email is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_company_required', as_type=bool) \
and cp.company is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_addresses_required', as_type=bool) \
and (cp.street is None and cp.city is None and cp.country is None):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
for qq in cp.item.relevant_questionnaires:
for qc in qq.childlist:
if qc.user_datafield_id and question_is_required(qc) and qc.user_datafield_id not in cp.answer_cache:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if qc.system_datafield and question_is_required(qc) and not cp.get_system_answer(qc.system_datafield):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
responses = question_form_fields.send(sender=self.request.event, position=cp)
form_data = cp.meta_info_data.get('question_form_data', {})
+2 -3
View File
@@ -247,15 +247,14 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
continue
if item.hidden_if_item_available:
time_available = item.hidden_if_item_available.is_available()
if item.hidden_if_item_available.has_variations:
item._dependency_available = any(
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK
# is_available on variant is evaluated called by available_variations
for var in item.hidden_if_item_available.available_variations
) and time_available
)
else:
q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
time_available = item.hidden_if_item_available.is_available()
item._dependency_available = (q[0] == Quota.AVAILABILITY_OK) and time_available
if item._dependency_available and item.hidden_if_item_available_mode == Item.UNAVAIL_MODE_HIDDEN:
item._remove = True
@@ -204,7 +204,7 @@
{% endif %}
{% endif %}
{% for q in line.questions %}
<dt>{{ q.question }}</dt>
<dt>{{ q.label }}</dt>
<dd>
{% include "pretixpresale/event/fragment_question_answer.html" with request=request question=q answer=q.answer %}
</dd>
@@ -139,6 +139,9 @@
{% if event_logo and event_logo_show_title %}
<h2 class="content-header">
{{ event.name }}
{% if request.event.settings.show_dates_on_frontpage %}
<small>{{ event.get_date_range_display_as_html }}</small>
{% endif %}
</h2>
{% endif %}
{% if frontpage_text %}
@@ -22,14 +22,12 @@
{% trans "Change account information" %}
</a>
</li>
{% if not customer.provider %}
<li>
<a href="{% eventurl request.organizer "presale:organizer.customer.password" %}">
{% icon "key" %}
{% trans "Change password" %}
</a>
</li>
{% endif %}
<li>
<a href="{% eventurl request.organizer "presale:organizer.customer.password" %}">
{% icon "key" %}
{% trans "Change password" %}
</a>
</li>
</ul>
</dd>
<dd>
+28 -17
View File
@@ -41,7 +41,7 @@ from itertools import groupby
from django.conf import settings
from django.contrib import messages
from django.db.models import Exists, OuterRef, Prefetch, Sum
from django.db.models import Exists, OuterRef, Prefetch, Q, Sum
from django.utils import translation
from django.utils.functional import cached_property
from django.utils.timezone import now
@@ -54,6 +54,7 @@ from pretix.base.models import (
CartPosition, Customer, InvoiceAddress, ItemAddOn, OrderFee, Question,
QuestionAnswer, QuestionOption, TaxRule,
)
from pretix.base.models.items import QuestionnaireChild
from pretix.base.models.orders import CheckoutSession
from pretix.base.services.cart import get_fees
from pretix.base.services.pricing import apply_rounding
@@ -95,7 +96,7 @@ class CartMixin:
"""
A list of this users cart position
"""
return list(get_cart(self.request))
return list(get_cart_positions(self.request))
@cached_property
def cart_session(self):
@@ -399,7 +400,7 @@ def cart_exists(request):
return bool(request._cart_cache)
def get_cart(request):
def get_cart_positions(request):
from pretix.presale.views.cart import get_or_create_cart_id
if not hasattr(request, '_cart_cache'):
@@ -407,8 +408,11 @@ def get_cart(request):
if not cart_id:
request._cart_cache = CartPosition.objects.none()
else:
qqs = request.event.questions.all()
qqs = qqs.filter(ask_during_checkin=False, hidden=False, container_type=Question.ContainerType.ORDERPOSITION)
qqs = request.event.questionnaires.all()
qqs = qqs.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=request.sales_channel.identifier),
type='PS'
)
request._cart_cache = CartPosition.objects.filter(
cart_id=cart_id, event=request.event
).annotate(
@@ -429,18 +433,23 @@ def get_cart(request):
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('children', QuestionnaireChild.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='childlist')
),
to_attr='relevant_questionnaires')
)
by_id = {cp.pk: cp for cp in request._cart_cache}
for cp in request._cart_cache:
@@ -450,6 +459,8 @@ def get_cart(request):
cp.addon_to = by_id[cp.addon_to_id]
return request._cart_cache
get_cart = get_cart_positions # legacy compatibility
def get_cart_total(request):
"""
@@ -501,7 +512,7 @@ def get_cart_is_free(request):
if not hasattr(request, '_cart_free_cache'):
cs = cart_session(request)
pos = get_cart(request)
pos = get_cart_positions(request)
ia = get_cart_invoice_address(request)
try:
fees = get_fees(event=request.event, request=request, invoice_address=ia,
@@ -401,9 +401,6 @@ let form_handlers = function (el) {
if (tagName !== 'div' && tagName !== 'button') {
$toggling = dependent.closest('.form-group')
}
if ($toggling.find(".has-error").length > 0) {
enabled = true // Don't hide error message that makes form unsubmittable
}
if (ev) {
if (enabled) {
$toggling.stop().slideDown()
@@ -0,0 +1,146 @@
<script lang="ts">
import QuestionnaireElement from './QuestionnaireElement.vue';
import * as api from './api';
import { Questionnaire } from './model';
import { i18n_any, QUESTION_TYPE, sort, localeComp, numericComp, groupBy, _ } from './helper';
import {Ref, ref} from 'vue';
import { SlickList, SlickItem } from 'vue-slicksort';
const items_list = await api.getItems();
const categories_list = await api.getCategories();
const categories = Object.fromEntries(categories_list.map(cat => [cat.id, cat]));
categories['null'] = { position: -1, internal_name: _('Uncategorized') };
sort(items_list, numericComp(item => categories[item.category]?.position), numericComp(item => item.position));
console.log("items_list", items_list);
const grouped_items = [...groupBy(items_list, item => categories[item.category])];
console.log("grouped_items", grouped_items);
const all_questionnaires: (Omit<Questionnaire, 'id'> & { _new_id?: number, id?: number })[] = await api.getQuestionnaires();
const order_questionnaires = ref(all_questionnaires.filter(q => q.type.startsWith('O')));
const position_questionnaires = ref(all_questionnaires.filter(q => q.type.startsWith('P')));
const datafields = ref(await api.getDatafields());
function saveQuestionnaire(questionnaire) {
if (questionnaire.id) {
api.updateQuestionnaire(questionnaire.id, questionnaire);
} else {
api.createQuestionnaire(questionnaire);
}
}
export default {
components: {
QuestionnaireElement, SlickList, SlickItem,
},
methods: {
i18n_any,
addPositionQuestionnaire() {
position_questionnaires.value.push({
all_sales_channels: false, children: [], limit_sales_channels: [], position: 0,
items: [], internal_name: "Unnamed questionnaire", type: "PS",
_new_id: Date.now(),
});
},
addOrderQuestionnaire() {
order_questionnaires.value.push({
all_sales_channels: false, children: [], limit_sales_channels: [], position: 0,
items: [], internal_name: "Unnamed questionnaire", type: "OS",
_new_id: Date.now(),
});
},
saveData() {
for (const questionnaire of order_questionnaires.value) {
saveQuestionnaire(questionnaire);
}
for (const questionnaire of position_questionnaires.value) {
saveQuestionnaire(questionnaire);
}
},
},
data() {
return {
order_questionnaires,
position_questionnaires,
datafields,
items: items_list,
selected_product: ref(""),
grouped_items,
categories,
}
}
}
</script>
<style>
.hidden-questionnaire { opacity: 0.3; }
.questionnaires-list { margin-right: 180px; }
.question-edit-buttons { float:right; }
.question-edit-buttons div { position: absolute; margin-left: 10px; min-width: 100px; }
.question-edit-buttons button { }
.form-group { margin-bottom: 30px }
.questionnaires-editor.product-selected .panel .panel-heading {}
.questionnaires-editor.product-selected .panel { margin-bottom: 0; border: 0 none; border-bottom: 1px solid #ddd; box-shadow: none; border-radius: 0; }
.questionnaires-editor.product-selected .panel .panel-heading { font-style: italic; background: white; border: 0 none; }
.filter-row { background: #f8e6ff; border: 1px solid #e3cbed; padding: 10px; }
.debuginfo { font-size: 70%; background: rgba(200, 200, 200, 0.5); }
.dependency-info { position: absolute; }
.dependency-info > span { }
.category-header { margin: 8px 0 -5px 0; font-weight: bold; color: #737373; }
</style>
<template>
<div class="questionnaires-editor">
<p class="filter-row">
Order questionnaires
</p>
<div class="questionnaires-list">
<SlickList axis="y" v-model:list="order_questionnaires" useDragHandle appendTo="#orderQuestionnaireListParent" id="orderQuestionnaireListParent">
<SlickItem v-for="(questionnaire, index) in order_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="datafields"
:grouped_items="null"
:selected_product="null" />
</SlickItem>
</SlickList>
</div>
<p>
<button class="btn btn-default" @click="addOrderQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
<button class="btn btn-default" @click="saveData()"><i class="fa fa-save"></i> Speichern</button>
</p>
</div>
<div :class="`questionnaires-editor ${selected_product ? 'product-selected':''}`">
<p class="filter-row">
Questionnaires for product:
<select v-model="selected_product">
<option value="">(all)</option>
<optgroup v-for="[category, items] in grouped_items" :label="category.internal_name || i18n_any(category.name)">
<option v-for="item in items" :value="item.id">
{{ item.internal_name || i18n_any(item.name) }}
</option>
</optgroup>
</select>
</p>
<div class="questionnaires-list">
<SlickList axis="y" v-model:list="position_questionnaires" useDragHandle appendTo="#questionnaireListParent" id="questionnaireListParent">
<SlickItem v-for="(questionnaire, index) in position_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="datafields"
:grouped_items="grouped_items"
:selected_product="selected_product" />
</SlickItem>
</SlickList>
</div>
<p>
<button class="btn btn-default" @click="addPositionQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
<button class="btn btn-default" @click="saveData()"><i class="fa fa-save"></i> Speichern</button>
</p>
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { ref, useId, defineProps } from 'vue';
import {getEventLocales} from "./api";
const locales = getEventLocales();
const props = defineProps(['value', 'id']);
if (!props.value) props.value = {};
</script>
<template>
<div class="i18n-form-group" :id="id">
<textarea v-for="locale in locales" cols="40" rows="2" :lang="locale" dir="ltr" class="form-control" title="Englisch" :id="`${id}_${locale}`" :placeholder="locale" v-model="value[locale]"></textarea>
</div>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref, useId } from 'vue';
const dialog = ref<HTMLDialogElement>();
const props = defineProps({
classes: {
type: String,
default: "",
},
title: '',
});
const visible = ref(false);
const showModal = () => {
dialog.value?.showModal();
visible.value = true;
};
defineExpose({
show: showModal,
close: (returnVal?: string): void => dialog.value?.close(returnVal),
visible,
});
const id = useId();
</script>
<template>
<dialog
ref="dialog" class="modal-card"
@close="visible = false"
closedby="any"
:aria-labelledby="`${id}-title`"
>
<form
v-if="visible"
method="dialog" class="modal-card-inner form-horizontal"
:class="{
[props.classes]: props.classes,
}"
>
<div class="modal-card-content">
<h2 :id="`${id}-title`" class="modal-card-title h3">{{ title }}</h2>
<slot />
</div>
</form>
</dialog>
</template>
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { i18n_any, QUESTION_TYPE, QUESTION_TYPE_LABEL, SYSTEM_DATAFIELDS } from './helper';
import NativeDialog from './NativeDialog.vue';
import I18nTextField from './I18nTextField.vue';
import {useId, ref, computed} from 'vue'
import { DragHandle } from 'vue-slicksort';
import {getDatafieldEditUrl} from "./api";
const id = useId();
const props = defineProps(['question', 'datafields', 'editable', 'possible_dependencies'])
const emit = defineEmits(['removeSelf']);
const gettext = (window as any).gettext;
const question = ref(props.question);
const df = typeof question.value.question === 'number' ?
props.datafields.find(el => el.id === question.value.question) :
typeof question.value.question === 'string' ?
SYSTEM_DATAFIELDS[question.value.question] :
null;
const dependency_values_options = computed(() => props.datafields.find(el => el.id === question.value.dependency_question)?.options);
const dependency_values_resolved = computed(() => question.value.dependency_values.map(ident => i18n_any(dependency_values_options.value.find(opt => opt.identifier === ident)?.answer) ?? ident));
if (!question.value.label) question.value.label = {};
if (!question.value.help_text) question.value.help_text = {};
const editor = ref();
</script>
<template>
<div class="form-group">
<div class="question-edit-buttons" v-if="editable"><div class="btn-group">
<DragHandle tag="button" class="btn btn-default"><i class="fa fa-arrows"></i></DragHandle>
<button class="btn btn-default" @click="editor.show()"><i class="fa fa-edit"></i></button>
</div></div>
<template v-if="df">
<div v-if="question.dependency_question" class="dependency-info debuginfo">
<span><span class="fa fa-link"></span> {{ question.dependency_question }} = {{ dependency_values_resolved }}</span>
</div>
<div class="col-md-3 control-label label-empty" v-if="df.type === QUESTION_TYPE.BOOLEAN"></div>
<label class="col-md-3 control-label" :for="id" v-else>
{{ i18n_any(question.label) }}
</label>
<div class="col-md-9">
<input :id="id" type="text" v-if="df.type === QUESTION_TYPE.STRING || df.type === QUESTION_TYPE.PHONENUMBER" class="form-control">
<textarea :id="id" v-if="df.type === QUESTION_TYPE.TEXT" class="form-control"></textarea>
<div class="checkbox" v-if="df.type === QUESTION_TYPE.BOOLEAN">
<label :for="id">
<input :id="id" type="checkbox"> {{ i18n_any(question.label) }}
</label>
</div>
<input :id="id" type="number" v-if="df.type === QUESTION_TYPE.NUMBER" class="form-control">
<input :id="id" type="file" v-if="df.type === QUESTION_TYPE.FILE" class="form-control">
<select :id="id" class="form-control"
v-if="df.type === QUESTION_TYPE.CHOICE || df.type === QUESTION_TYPE.COUNTRYCODE">
<option></option>
<option v-for="opt in df.options">{{ i18n_any(opt.answer) }}</option>
</select>
<div class="checkbox" v-if="df.type === QUESTION_TYPE.CHOICE_MULTIPLE" v-for="(opt, index) in df.options">
<label :for="`${id}-${index}`">
<input :id="`${id}-${index}`" type="checkbox"> {{ i18n_any(opt.answer) }}
</label>
</div>
<div class="help-block">{{ i18n_any(question.help_text) }}</div>
</div>
</template>
<div v-else class="col-md-12">
<h3>{{ i18n_any(question.label) }}</h3>
<p>{{ i18n_any(question.help_text) }}</p>
</div>
</div>
<Teleport to="body">
<NativeDialog ref="editor" class="modal-card"
title="Edit question">
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Question') }}
</label>
<div class="col-md-9">
<I18nTextField :value="question.label"/>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Help text') }}
</label>
<div class="col-md-9">
<I18nTextField :value="question.help_text"/>
<div class="help-block">Wenn diese Frage noch weitere Erklärung braucht, können Sie sie hier eintragen.</div>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
Data field
</label>
<div class="col-md-9">
<p class="form-control-static">
<template v-if="typeof question.question === 'number'">
{{ df.internal_name }}
<a :href="getDatafieldEditUrl(df.id)" target="_blank">Manage data field details</a>
</template>
<template v-else>
{{ question.question }}
</template>
</p>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
Data field type
</label>
<div class="col-md-9">
<select v-model="df.type" class="form-control" disabled>
<option v-for="(label, type) in QUESTION_TYPE_LABEL" :value="QUESTION_TYPE[type]">{{ label }}</option>
</select>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label label-empty">&nbsp;</label>
<div class="col-md-9">
<div class="checkbox">
<label>
<input type="checkbox" v-model="question.required">
{{ gettext('Required question') }}
</label>
</div>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Only visible if...') }}
</label>
<div class="col-md-9">
<select v-model="question.dependency_question" class="form-control">
<option :value="null">{{ gettext('(none)') }}</option>
<option v-for="(qc, index) in possible_dependencies" :value="qc.question">{{ i18n_any(qc.label) }}</option>
</select>
<select v-model="question.dependency_values" class="form-control" multiple>
<option v-for="(qc, index) in dependency_values_options" :value="qc.identifier">{{ i18n_any(qc.answer) }}</option>
</select>
<div v-if="question.dependency_question">
<p><span class="debuginfo">depends on {{ question.dependency_question }} = {{ dependency_values_resolved }}</span></p>
</div>
</div>
</div>
<button @click="editor.close()" class="btn btn-primary pull-right"><span class="fa fa-check"></span> Save and close</button>
<button @click="emit('removeSelf')" class="btn btn-default">Remove from questionnaire</button>
</NativeDialog>
</Teleport>
</template>
@@ -0,0 +1,163 @@
<script setup lang="ts">
import {useId, ref, computed} from 'vue'
import QuestionElement from "./QuestionElement.vue";
import {i18n_any, QUESTION_TYPE, QUESTION_TYPE_LABEL} from "./helper";
import I18nTextField from "./I18nTextField.vue";
import NativeDialog from "./NativeDialog.vue";
import { SlickList, SlickItem, DragHandle } from 'vue-slicksort';
const dlgEditor = ref();
const dlgAddExisting = ref();
const dlgAddTextblock = ref();
const newTextblockTitle = ref();
const newTextblockText = ref();
const id = useId();
const props = defineProps(['questionnaire', 'datafields', 'selected_product', 'grouped_items'])
const gettext = (window as any).gettext
function toggleItem() {
const i = props.questionnaire.items.indexOf(props.selected_product);
if (i === -1) {
props.questionnaire.items.push(props.selected_product);
} else {
props.questionnaire.items.splice(i, 1);
}
}
function addExistingDatafield(field) {
props.questionnaire.children.push({
question: field.id,
required: false,
label: {},
help_text: {},
dependency_question: null,
dependency_values: [],
});
dlgAddExisting.value.close();
}
function showAddTextblockDialog() {
newTextblockTitle.value = {};
newTextblockText.value = {};
dlgAddTextblock.value.show();
}
function addTextblock() {
props.questionnaire.children.push({
question: null,
required: false,
label: newTextblockTitle.value,
help_text: newTextblockText.value,
dependency_question: null,
dependency_values: [],
});
dlgAddTextblock.value.close();
}
const isHidden = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) === -1);
const isEditable = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) !== -1);
</script>
<template>
<div class="question-edit-buttons"><div class="btn-group">
<DragHandle tag="button" class="btn btn-default"><i class="fa fa-arrows"></i></DragHandle>
<button class="btn btn-default" @click="dlgEditor.show()"><i class="fa fa-edit"></i></button>
</div></div>
<details class="panel panel-default " :open="!!isEditable"
:class="{ 'hidden-questionnaire': isHidden }">
<summary class="panel-heading">
<input type="checkbox" @click="toggleItem()" v-if="selected_product" :checked="!isHidden">
{{ props.questionnaire.internal_name }}
</summary>
<div class="panel-body" v-if="!isHidden">
<div class="form-horizontal" :id="`questionListParent${props.questionnaire.id}`">
<SlickList axis="y" v-model:list="props.questionnaire.children" useDragHandle :appendTo="`#questionListParent${props.questionnaire.id}`">
<SlickItem v-for="(child, index) in props.questionnaire.children" :key="child.id" :index="index">
<QuestionElement
:datafields="props.datafields"
:question="child"
:editable="true"
:possible_dependencies="props.questionnaire.children.slice(0, index)"
@remove-self="questionnaire.children.splice(index, 1)" />
</SlickItem>
</SlickList>
</div>
<p v-if="true" class="btn-group" role="group">
<button class="btn btn-default" @click="dlgAddExisting.show()"><i class="fa fa-plus"></i> {{ gettext('Existing data field') }}</button>
<button class="btn btn-default" @click="newDatafield()"><i class="fa fa-plus"></i> {{ gettext('New data field') }}</button>
<button class="btn btn-default" @click="showAddTextblockDialog()"><i class="fa fa-plus"></i> {{ gettext('Text') }}</button>
</p>
</div>
</details>
<Teleport to="body">
<NativeDialog ref="dlgEditor" class="modal-card"
:title="gettext('Edit questionnaire')">
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Internal name') }}
</label>
<div class="col-md-9">
<input type="text" class="form-control" v-model="questionnaire.internal_name"/>
</div>
</div>
<div class="form-group" v-if="grouped_items">
<label class="col-md-3 control-label">
{{ gettext('Visible on products') }}
</label>
<div class="col-md-9">
<div v-for="[category, items] in grouped_items">
<div class="category-header">{{ category.internal_name || i18n_any(category.name) }}</div>
<div class="checkbox" v-for="item in items">
<label :for="id + '_' + item.id">
<input :id="id + '_' + item.id" type="checkbox" :checked="questionnaire.items.indexOf(item.id) !== -1"> {{ item.internal_name || i18n_any(item.name) }}
</label>
</div>
</div>
</div>
</div>
<button @click="dlgEditor.close()" class="btn btn-primary pull-right"><span class="fa fa-check"></span> {{ gettext('Save and close') }}</button>
<button class="btn btn-default">{{ gettext('Delete') }}</button>
</NativeDialog>
<NativeDialog ref="dlgAddExisting" class="modal-card"
:title="gettext('Add existing data field')">
<div class="list-group">
<a href="javascript:" @click="addExistingDatafield(field)" v-for="field in datafields" class="list-group-item">{{ field.internal_name }}</a>
</div>
<button @click="dlgAddExisting.close()" class="btn btn-default pull-right">{{ gettext('Cancel') }}</button>
</NativeDialog>
<NativeDialog ref="dlgAddTextblock" class="modal-card"
:title="gettext('Add sub heading')">
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Title') }}
</label>
<div class="col-md-9">
<I18nTextField :value="newTextblockTitle"/>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Text') }}
</label>
<div class="col-md-9">
<I18nTextField :value="newTextblockText"/>
</div>
</div>
<button @click="addTextblock()" class="btn btn-default pull-right">{{ gettext('OK') }}</button>
<button @click="dlgAddTextblock.close()" class="btn btn-default pull-right">{{ gettext('Cancel') }}</button>
</NativeDialog>
</Teleport>
</template>
@@ -0,0 +1,67 @@
import {ApiListResponse, Datafield, Questionnaire, Item} from "./model";
const organizer_slug = document.body.getAttribute('data-organizer'),
event_slug = document.body.getAttribute('data-event');
async function api_get(resource) {
return await $.getJSON(`/api/v1/${resource}?_nocache=${+new Date()}`);
}
async function api_get_all<T>(resource): Promise<T[]> {
let next = `/api/v1/${resource}?_nocache=${+new Date()}`;
const result: T[] = [];
while (next) {
const response: ApiListResponse<T> = await $.getJSON(next);
result.push(...response.results);
next = response.next;
console.log('api_get_all: '+ resource, next, response, result)
}
return result;
}
async function api_json_request(resource, method, json_body) {
return await (await fetch(`/api/v1/${resource}`, {
body: JSON.stringify(json_body),
method: method,
headers: {
"Content-Type": "application/json",
"X-CSRFToken": $('[name=csrfmiddlewaretoken]').val() as string,
},
})).json();
}
export async function getDatafields() {
return await api_get_all<Datafield>(`organizers/${organizer_slug}/events/${event_slug}/datafields/`);
}
export async function getQuestionnaires() {
return await api_get_all<Questionnaire>(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`);
}
export async function updateQuestionnaire(id, data) {
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/${id}/`, 'PATCH', data);
}
export async function createQuestionnaire(data) {
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`, 'POST', data);
}
export async function getItems() {
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/items/`);
}
export async function getCategories() {
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/categories/`);
}
function get_json_script_value(id) {
return JSON.parse(document.getElementById(id).innerText);
}
export function getEventLocales() {
return get_json_script_value('event_locales');
}
export function getDatafieldEditUrl(datafield_id) {
return get_json_script_value('datafield_edit_url').replace('/0/', `/${datafield_id}/`);
}
@@ -0,0 +1,97 @@
export function i18n_any(data) {
if (!data) return null;
const preferred = document.body.getAttribute("data-pretixlocale");
if (data[preferred]) return data[preferred];
return Object.values(data)[0];
}
function freezeRec(o) {
return Object.freeze(Object.fromEntries(Object.entries(o).map(([k, v]) => [k, v && Object.getPrototypeOf(v) === Object.prototype ? freezeRec(v) : v])))
}
export function localeComp(fn) {
return function(a, b) {
return fn(a).localeCompare(fn(b));
}
}
export function numericComp(fn) {
return function(a, b) {
return fn(a) - fn(b);
}
}
export function pick(key) {
return function(obj) {
return obj[key];
}
}
export function sort(array, ...orderBy) {
array.sort(function(a, b) {
for(let comp of orderBy) {
const result = comp(a, b);
if (result !== 0) {
return result;
}
}
return 0;
});
}
export function *groupBy(array, key) {
let lastKey, lastArray;
for(const x of array){
const k = key(x);
if (lastKey !== k || !lastArray) {
if (lastArray) {
yield [lastKey, lastArray];
}
lastKey = k; lastArray = [x];
} else {
lastArray.push(x);
}
}
if (lastArray) {
yield [lastKey, lastArray];
}
}
export const QUESTION_TYPE = {
NUMBER: "N",
STRING: "S",
TEXT: "T",
BOOLEAN: "B",
CHOICE: "C",
CHOICE_MULTIPLE: "M",
FILE: "F",
DATE: "D",
TIME: "H",
DATETIME: "W",
COUNTRYCODE: "CC",
PHONENUMBER: "TEL",
};
export const _ = x => x;
export const QUESTION_TYPE_LABEL = {
NUMBER: _("Number"),
STRING: _("Text (one line)"),
TEXT: _("Multiline text"),
BOOLEAN: _("Yes/No"),
CHOICE: _("Choose one from a list"),
CHOICE_MULTIPLE: _("Choose multiple from a list"),
FILE: _("File upload"),
DATE: _("Date"),
TIME: _("Time"),
DATETIME: _("Date and time"),
COUNTRYCODE: _("Country code (ISO 3166-1 alpha-2)"),
PHONENUMBER: _("Phone number"),
};
export const SYSTEM_DATAFIELDS = freezeRec({
'attendee_name_parts': { label: _('Attendee name'), type: QUESTION_TYPE.STRING },
'attendee_email': { label: _('Attendee email'), type: QUESTION_TYPE.STRING },
'company': { label: _('Company'), type: QUESTION_TYPE.STRING },
'street': { label: _('Street'), type: QUESTION_TYPE.STRING },
'zipcode': { label: _('ZIP code'), type: QUESTION_TYPE.STRING },
'city': { label: _('City'), type: QUESTION_TYPE.STRING },
'country': { label: _('Country'), type: QUESTION_TYPE.COUNTRYCODE },
});
@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#questionnaires-editor')
@@ -0,0 +1,65 @@
export type ApiListResponse<T> = {
count: number,
next: string | null,
previous: string | null,
results: T[],
};
// from webcheckin/i18n.ts
export type I18nString = string | Record<string, string> | null | undefined;
export type Datafield = {
id: number,
// question: I18nString,
type: string,
required: boolean,
//items: number[],
options: any[],
// position: number,
//ask_during_checkin: boolean,
show_during_checkin: boolean,
identifier: string,
// dependency_question: string | null,
// dependency_values []
// hidden: boolean,
// dependency_value null
print_on_invoice: boolean,
// help_text: I18nString,
valid_number_min: null | number,
valid_number_max: null | number,
valid_date_min: null | string,
valid_date_max: null | string,
valid_datetime_min: null | string,
valid_datetime_max: null | string,
valid_string_length_max: null | number,
valid_file_portrait: boolean,
internal_name: string,
};
export type Questionnaire = {
id: number,
internal_name: string,
type: string,
items: number[],
position: number,
all_sales_channels: boolean,
limit_sales_channels: string[],
children: QuestionnaireChild[],
};
export type QuestionnaireChild = {
question: string | number,
required: boolean,
label: I18nString,
help_text: I18nString,
dependency_question: number | null,
dependency_values: null | string[],
};
export type Item = {
id: number,
category: number,
name: I18nString,
internal_name: string | null,
};
@@ -780,6 +780,8 @@ fieldset.accordion-panel > legend {
}
}
/* These classes are not only used for tax rules, but also for event meta properties */
.tax-rules-formset {
margin-left: -15px;
margin-right: -15px;
@@ -802,6 +804,7 @@ fieldset.accordion-panel > legend {
padding-bottom: 5px;
}
.batch-select-label {
display: block;
width: 100%;
+1 -24
View File
@@ -109,30 +109,7 @@ let form_handlers = function (el) {
}
$(this).datetimepicker(opts)
})
el.find("button[data-wait-seconds-enable], input[data-wait-seconds-enable]").each(function(i, input) {
var s = parseInt(input.getAttribute("data-wait-seconds-enable")) || 0;
var time = $("time", input) || $("time").appendTo(input);
// for a11y do not disable input, but do not allow submit
function disable_submit(e) {
e.preventDefault();
}
if (s) {
input.addEventListener("click", disable_submit);
}
function wait() {
time.attr("datetime", s+"s");
if (s > 0) {
time.text("(" + s + "s)");
window.setTimeout(wait, 1000);
s--;
} else {
time.remove();
input.disabled = false;
input.removeEventListener("click", disable_submit);
}
}
wait();
});
el.find('.input-item-count-dec, .input-item-count-inc').on('click', function (e) {
e.preventDefault()
let step = parseFloat(this.getAttribute('data-step'))
@@ -0,0 +1,2 @@
- modernize the sometimes native form submitting?
- destructure props?
@@ -114,7 +114,7 @@ td(
)
.pretix-widget-event-calendar-day(v-if="day", :aria-label="dateStr") {{ daynum }}
.pretix-widget-event-calendar-events(v-if="day")
EventCalendarEvent(v-for="e in day.events", :key="e.event_url+'-'+(e.subevent||'')", :event="e")
EventCalendarEvent(v-for="e in day.events", :key="e.event_url", :event="e")
</template>
<style lang="sass">
</style>
-9
View File
@@ -61,15 +61,6 @@ def meta_prop(organizer):
return organizer.meta_properties.create(name="type", default="Concert")
@pytest.fixture
@scopes_disabled()
def meta_prop_choices(organizer):
return organizer.meta_properties.create(name="department", default="A", choices=[
{"key": "A", "label": {"en": "Group A"}},
{"key": "B", "label": {"en": "Group B"}},
])
@pytest.fixture
@scopes_disabled()
def event(organizer, meta_prop):
-229
View File
@@ -1,229 +0,0 @@
#
# 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/>.
#
import pytest
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
@pytest.fixture
def event_meta_property(organizer):
return organizer.meta_properties.create(
name="Color",
default="Red",
required=False,
choices=[
{
"key": "Red",
"label": LazyI18nString("Rot"),
"DELETE": False,
"ORDER": 1,
}
],
)
TEST_TYPE_RES = {
"name": "Color",
"default": "Red",
"required": False,
"choices": [{"key": "Red", "label": {"en": "Rot"}}],
'filter_allowed': True,
'filter_public': False,
'protected': False,
'public_label': None,
}
@pytest.mark.django_db
def test_meta_property_list(token_client, organizer, event_meta_property):
res = dict(TEST_TYPE_RES)
resp = token_client.get('/api/v1/organizers/{}/event_meta_properties/'.format(organizer.slug))
assert resp.status_code == 200
event_meta_property.refresh_from_db()
res["id"] = event_meta_property.pk
assert res in resp.data['results']
assert len(resp.data['results']) == 1
@pytest.mark.django_db
def test_meta_property_detail(token_client, organizer, event_meta_property):
res = TEST_TYPE_RES
resp = token_client.get('/api/v1/organizers/{}/event_meta_properties/{}/'.format(organizer.slug, event_meta_property.pk))
assert resp.status_code == 200
event_meta_property.refresh_from_db()
res["id"] = event_meta_property.pk
assert res == resp.data
@pytest.mark.django_db
def test_meta_property_create(token_client, organizer):
url = '/api/v1/organizers/{}/event_meta_properties/'.format(organizer.slug)
resp = token_client.post(
url,
format='json',
data={
"name": "Color",
"default": "",
"required": False,
"choices": [
{"key": {"foo": "bar"}},
"blabla",
{"label": "Green"},
{"key": "g", "label": "Green", "foo": "bar"},
],
}
)
assert resp.status_code == 400
assert str(resp.data["choices"][0][0]) == "Meta property value options must have a key of type string."
assert str(resp.data["choices"][1][0]) == "Meta property value options must be a dict."
assert str(resp.data["choices"][2][0]) == "Meta property value options must have a key of type string."
assert str(resp.data["choices"][3][0]) == "Meta property value options may only have a key and optionally a label."
resp = token_client.post(
url,
format='json',
data={
"name": "Color",
"default": "Red",
"required": False,
"choices": {"key": "r", "label": "Red"},
}
)
assert resp.status_code == 400
assert str(resp.data["choices"][0]) == 'Expected a list of items but got type "dict".'
resp = token_client.post(
url,
format='json',
data={
"name": "Color",
"default": "r",
"required": False,
"choices": [
{"key": "r", "label": {"en": "Red"}},
{"key": "r", "label": {"en": "Razzmatazz"}},
],
}
)
assert resp.status_code == 400
assert str(resp.data["choices"][0]) == "The key for each meta property value option must be unique."
choices = [
{"key": "r", "label": "Red"},
{"key": "g", "label": "Green"},
{"key": "b", "label": "Blue"},
]
resp = token_client.post(
url,
format='json',
data={
"name": "Color",
"default": "k",
"required": False,
"choices": choices,
}
)
assert resp.status_code == 400
assert str(resp.data["non_field_errors"][0]) == "You cannot set a default value that is not a valid value."
resp = token_client.post(
url,
format='json',
data={
"name": "Color",
"default": "r",
"required": False,
"choices": choices,
}
)
assert resp.status_code == 201
with scopes_disabled():
event_meta_property = organizer.meta_properties.get(id=resp.data['id'])
assert event_meta_property.name == "Color"
assert event_meta_property.default == "r"
assert event_meta_property.choices == choices
assert not event_meta_property.required
assert len(organizer.meta_properties.all()) == 1
@pytest.mark.django_db
def test_meta_property_patch(token_client, organizer, event_meta_property):
url = '/api/v1/organizers/{}/event_meta_properties/{}/'.format(organizer.slug, event_meta_property.pk)
resp = token_client.patch(
url,
format='json',
data={
# existing default is not in choices
"choices": [{'key': 'k', 'label': 'Black'}],
}
)
assert resp.status_code == 400
assert str(resp.data["non_field_errors"][0]) == "You cannot set a default value that is not a valid value."
resp = token_client.patch(
"/api/v1/organizers/{}/event_meta_properties/{}/"
.format(organizer.slug, event_meta_property.pk),
format="json",
data={
"choices": [
{"key": "r", "label": ["wrong"]},
{"key": "g", "label": {"de": {"de": 123, "en": "wrong"}}}
],
}
)
assert resp.status_code == 400
assert str(resp.data["choices"][0]["label"][0]) == "Invalid data type."
assert str(resp.data["choices"][1]["label"][0]) == "All entries must be strings."
resp = token_client.patch(
url,
format='json',
data={
"choices": [],
}
)
assert resp.status_code == 200
event_meta_property.refresh_from_db()
assert event_meta_property.choices is None
resp = token_client.patch(
url,
format='json',
data={
"required": True,
"choices": None,
}
)
assert resp.status_code == 200
event_meta_property.refresh_from_db()
assert event_meta_property.required
assert event_meta_property.choices is None
@pytest.mark.django_db
def test_meta_property_delete(token_client, organizer, event_meta_property):
resp = token_client.delete(
'/api/v1/organizers/{}/event_meta_properties/{}/'.format(organizer.slug, event_meta_property.pk),
)
assert resp.status_code == 204
assert len(organizer.meta_properties.all()) == 0
+1 -66
View File
@@ -703,7 +703,7 @@ def test_event_delete_with_clone(token_client, organizer, event, meta_prop):
@pytest.mark.django_db
def test_event_update(token_client, team, organizer, event, item, meta_prop, meta_prop_choices):
def test_event_update(token_client, organizer, event, item, meta_prop):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
@@ -782,26 +782,6 @@ def test_event_update(token_client, team, organizer, event, item, meta_prop, met
property__name=meta_prop.name, value="Workshop"
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop.name: "Workshop",
meta_prop_choices.name: "B"
}
},
format='json'
)
assert resp.status_code == 200
assert resp.data["meta_data"] == {
meta_prop.name: "Workshop",
meta_prop_choices.name: "B"
}
with scopes_disabled():
assert organizer.events.get(slug=resp.data['slug']).meta_values.filter(
property__name=meta_prop_choices.name, value="B"
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
@@ -816,51 +796,6 @@ def test_event_update(token_client, team, organizer, event, item, meta_prop, met
property__name=meta_prop.name
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop_choices.name: "invalid"
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"meta_data":["Meta data property \'department\' does not allow value \'invalid\'."]}'
meta_prop_choices.protected = True
meta_prop_choices.save()
team.all_organizer_permissions = False
team.limit_organizer_permissions = {}
team.save()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop_choices.name: "A"
}
},
format='json'
)
assert resp.status_code == 200 # silently ignored
assert resp.data["meta_data"] == {}
team.all_organizer_permissions = True
team.save()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop_choices.name: "A"
}
},
format='json'
)
assert resp.status_code == 200
assert resp.data["meta_data"] == {
meta_prop_choices.name: "A"
}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{

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