mirror of
https://github.com/pretix/pretix.git
synced 2026-08-08 10:27:49 +00:00
Compare commits
233
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2177bebf6a | ||
|
|
ea613bf271 | ||
|
|
c8bed5e3b9 | ||
|
|
d08216d8c5 | ||
|
|
4a28689690 | ||
|
|
958f75b109 | ||
|
|
81f58456e5 | ||
|
|
1ec27fff05 | ||
|
|
0294f205d0 | ||
|
|
3424aacdc3 | ||
|
|
4012889076 | ||
|
|
fe15d71cd2 | ||
|
|
4d9dfa88fe | ||
|
|
ea792e76b2 | ||
|
|
5c448279f0 | ||
|
|
e3fa1aed7f | ||
|
|
b164cf44c0 | ||
|
|
7bc79337c3 | ||
|
|
5d8ba189fa | ||
|
|
1ea1c1789c | ||
|
|
1a6edae769 | ||
|
|
c4950e714d | ||
|
|
407728cc55 | ||
|
|
9fe25544c1 | ||
|
|
8133061fe1 | ||
|
|
288ac50600 | ||
|
|
e7657a3dd3 | ||
|
|
b659534772 | ||
|
|
c11ebb1391 | ||
|
|
b20557a996 | ||
|
|
e4a0bba3bc | ||
|
|
c369ba5f60 | ||
|
|
2c876057bf | ||
|
|
b70c1b02c2 | ||
|
|
01d736361d | ||
|
|
7627e4b548 | ||
|
|
541e7412e1 | ||
|
|
79dcf60ed2 | ||
|
|
8775ca09b4 | ||
|
|
c34cdf5ec6 | ||
|
|
8a000f9892 | ||
|
|
07bf0b3118 | ||
|
|
abe938795c | ||
|
|
f1cb5a9010 | ||
|
|
6e6ca0a7bc | ||
|
|
8800b15a33 | ||
|
|
85de097497 | ||
|
|
16040f70bb | ||
|
|
9b5ed06b2e | ||
|
|
f3ffce4e5b | ||
|
|
5c98dc16b7 | ||
|
|
d814b40fcd | ||
|
|
a696c493e2 | ||
|
|
e88b539375 | ||
|
|
163cf85c86 | ||
|
|
55b6887c5e | ||
|
|
2592d3cb8d | ||
|
|
47b632eabe | ||
|
|
7f2e957092 | ||
|
|
1ff9742a08 | ||
|
|
d9f8679647 | ||
|
|
1a7e36a144 | ||
|
|
9afc71c245 | ||
|
|
1087db9529 | ||
|
|
6d62284d87 | ||
|
|
3ea7ea31b6 | ||
|
|
db147280fd | ||
|
|
d1ce4566a7 | ||
|
|
c3cfd4ea91 | ||
|
|
92e7069a8e | ||
|
|
f471091d77 | ||
|
|
351d2055f8 | ||
|
|
ca03a2556d | ||
|
|
2381af4267 | ||
|
|
9fd570a53d | ||
|
|
072c17c91a | ||
|
|
003bfb707d | ||
|
|
faf8a6b1d2 | ||
|
|
a4d10d9550 | ||
|
|
baf3cf04ec | ||
|
|
fa2009ed03 | ||
|
|
cc082e2001 | ||
|
|
930e153db4 | ||
|
|
560a369e65 | ||
|
|
d47c435489 | ||
|
|
32e0e31b15 | ||
|
|
ea819530f9 | ||
|
|
09aef94376 | ||
|
|
39fbd25fbb | ||
|
|
214ea5fcd3 | ||
|
|
2de032c2be | ||
|
|
2574b31afd | ||
|
|
5f2264daeb | ||
|
|
4295b02406 | ||
|
|
49247062bc | ||
|
|
f3db461a2a | ||
|
|
83e607b88d | ||
|
|
9609722a98 | ||
|
|
4372aa6725 | ||
|
|
71488f0a02 | ||
|
|
52a4381277 | ||
|
|
bf066909a7 | ||
|
|
1399d37827 | ||
|
|
0c0842c9db | ||
|
|
6e647b41e4 | ||
|
|
7cff4321e0 | ||
|
|
fe5d095ae7 | ||
|
|
ebc31c901a | ||
|
|
2660dfe1bc | ||
|
|
08c376a459 | ||
|
|
6901ed8b26 | ||
|
|
62f1cbadaf | ||
|
|
1386b40e6f | ||
|
|
bbcd1b86b7 | ||
|
|
6894070239 | ||
|
|
7470a7198b | ||
|
|
ddda634ef3 | ||
|
|
3f7064f12b | ||
|
|
6386c99175 | ||
|
|
eb29101d09 | ||
|
|
d007fb9566 | ||
|
|
93cbbea193 | ||
|
|
eacce6d1f8 | ||
|
|
cdd7412fbb | ||
|
|
a6d462c14e | ||
|
|
d5d928cdce | ||
|
|
e0a8b81290 | ||
|
|
6b0419e488 | ||
|
|
48104e0c9f | ||
|
|
7eefb18b67 | ||
|
|
f9022e5a5e | ||
|
|
08c5963d7b | ||
|
|
e8274527cb | ||
|
|
f77aad0eeb | ||
|
|
d5b0059c34 | ||
|
|
47a170ab62 | ||
|
|
93aebffe2c | ||
|
|
7795c711be | ||
|
|
1eb685d4ee | ||
|
|
b8c523cfac | ||
|
|
fae9fff98e | ||
|
|
92cfbd54b9 | ||
|
|
18203e0804 | ||
|
|
52ed8eeb50 | ||
|
|
1e4888bad0 | ||
|
|
c3056a8aae | ||
|
|
4a4cff3c41 | ||
|
|
ffca102a8a | ||
|
|
78f8830f90 | ||
|
|
eb594266a7 | ||
|
|
76e6803eac | ||
|
|
e19908571c | ||
|
|
779ab360df | ||
|
|
723c63008d | ||
|
|
15c194c0a3 | ||
|
|
7f847c3dd2 | ||
|
|
430c6dd269 | ||
|
|
6411648457 | ||
|
|
005b3864b1 | ||
|
|
1ee1c604cf | ||
|
|
e425105dbf | ||
|
|
738b375042 | ||
|
|
d09572d999 | ||
|
|
634754aacb | ||
|
|
14c3baa2aa | ||
|
|
dc1b62fc56 | ||
|
|
ea2c81e6dc | ||
|
|
5b34a25fb5 | ||
|
|
9467f1d08d | ||
|
|
85344a7690 | ||
|
|
3b47f4aaa7 | ||
|
|
0309b05121 | ||
|
|
ad092ae0d1 | ||
|
|
9f42d679cc | ||
|
|
9eae9c6de7 | ||
|
|
84c51e031e | ||
|
|
902ee9242e | ||
|
|
b758efd4a9 | ||
|
|
c68fe00502 | ||
|
|
b7da9bb6c1 | ||
|
|
897120d3da | ||
|
|
09abc5725e | ||
|
|
9a7d5abb1f | ||
|
|
3cee5f524b | ||
|
|
3a1a6b988e | ||
|
|
bdc720f5a2 | ||
|
|
583184af38 | ||
|
|
63d9f7cea8 | ||
|
|
c3d6fb1bd6 | ||
|
|
bb43acd257 | ||
|
|
e0744951e8 | ||
|
|
fef1e356f7 | ||
|
|
81775f5d2d | ||
|
|
4bbcc398cc | ||
|
|
a924d0a266 | ||
|
|
0754e9d049 | ||
|
|
0ebdab5389 | ||
|
|
35c5f02b59 | ||
|
|
11c332dfd6 | ||
|
|
b95847c395 | ||
|
|
8fc2e78a58 | ||
|
|
21bd00e7e6 | ||
|
|
3df9d86216 | ||
|
|
c850a35235 | ||
|
|
d794738b8f | ||
|
|
c62a6359e1 | ||
|
|
e62bed7092 | ||
|
|
477601e9f9 | ||
|
|
6024fad056 | ||
|
|
8945e55900 | ||
|
|
7251ae953d | ||
|
|
3c1b806c8e | ||
|
|
2dade31f23 | ||
|
|
504191c005 | ||
|
|
00cb77de8f | ||
|
|
99bd78f2c6 | ||
|
|
d9b691690e | ||
|
|
b1b2a688a8 | ||
|
|
3f92868dba | ||
|
|
961253bac4 | ||
|
|
f3eb2fba6d | ||
|
|
4d8c22a839 | ||
|
|
d544df098c | ||
|
|
6cfdfc2cd6 | ||
|
|
55299b1eae | ||
|
|
97857e7a67 | ||
|
|
469b777dcf | ||
|
|
333dc56ef7 | ||
|
|
2898f06f56 | ||
|
|
0d522c0eed | ||
|
|
ee44d4c968 | ||
|
|
3d37f62c51 | ||
|
|
94dd5b0350 |
@@ -71,6 +71,8 @@ Checking a ticket in
|
||||
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
|
||||
:>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``.
|
||||
:>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``.
|
||||
:>json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
|
||||
object will not reflect the simulated changes.
|
||||
|
||||
**Example request**:
|
||||
|
||||
|
||||
@@ -351,7 +351,8 @@ Endpoints
|
||||
|
||||
:<json boolean error_reason: One of ``canceled``, ``invalid``, ``unpaid``, ``product``, ``rules``, ``revoked``,
|
||||
``incomplete``, ``already_redeemed``, ``blocked``, ``invalid_time``, or ``error``. Required.
|
||||
:<json raw_barcode: The raw barcode you scanned. Required.
|
||||
:<json raw_barcode: The raw barcode or identifier you scanned. Required.
|
||||
:<json raw_source_type: The type of medium you scanned, defaults to ``barcode``. Optional.
|
||||
:<json datetime: Date and time of the scan. Optional.
|
||||
:<json type: Type of scan, defaults to ``"entry"``.
|
||||
:<json position: Internal ID of an order position you matched. Optional.
|
||||
|
||||
@@ -864,6 +864,9 @@ Generating new secrets
|
||||
|
||||
Triggers generation of new ``secret`` and ``web_secret`` attributes for both the order and all order positions.
|
||||
|
||||
Ticket secrets of order positions that have been used to issue a gift card can not
|
||||
be changed. Only the link (``web_secret``) will be changed in this case.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -895,6 +898,9 @@ Generating new secrets
|
||||
|
||||
Triggers generation of a new ``secret`` and ``web_secret`` attribute for a single order position.
|
||||
|
||||
Ticket secrets of order positions that have been used to issue a gift card can not
|
||||
be changed. Only the link (``web_secret``) will be changed in this case.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -2032,7 +2038,7 @@ Manipulating individual positions
|
||||
|
||||
* ``order`` (mandatory, specified as a string mapping to a ``code``)
|
||||
|
||||
* ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position)
|
||||
* ``addon_to`` (optional, specified as an integer mapping to ``positionid`` - the number of the position within the order, see :ref:`_order-position-resource` - of the parent position)
|
||||
|
||||
* ``item`` (mandatory)
|
||||
|
||||
@@ -2342,7 +2348,7 @@ otherwise, such as splitting an order or changing fees.
|
||||
"subevent": 562,
|
||||
"seat": "seat-guid-2",
|
||||
"price": "99.99",
|
||||
"addon_to": 12374,
|
||||
"addon_to": 1,
|
||||
"attendee_name": "Peter",
|
||||
}
|
||||
],
|
||||
|
||||
@@ -53,7 +53,7 @@ Working with the code
|
||||
---------------------
|
||||
If you do not have a recent installation of ``nodejs``, install it now::
|
||||
|
||||
curl -sL https://deb.nodesource.com/setup_17.x | sudo -E bash -
|
||||
curl -sL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt install nodejs
|
||||
|
||||
To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
|
||||
|
||||
Generated
+21
-11
@@ -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",
|
||||
@@ -2584,9 +2585,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
|
||||
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3176,9 +3177,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3352,9 +3353,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -3371,7 +3372,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -4635,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
@@ -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",
|
||||
|
||||
+13
-13
@@ -33,29 +33,29 @@ dependencies = [
|
||||
"bleach==6.4.*",
|
||||
"celery==5.6.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=49.0.0",
|
||||
"cryptography>=50.0.0",
|
||||
"css-inline==0.21.*",
|
||||
"defusedcsv>=3.0.0",
|
||||
"dnspython==2.*",
|
||||
"Django[argon2]==5.2.*",
|
||||
"django-bootstrap3==26.1",
|
||||
"django-bootstrap3==26.2",
|
||||
"django-compressor==4.6.0",
|
||||
"django-countries==8.2.*",
|
||||
"django-filter==25.1",
|
||||
"django-countries==9.0.*",
|
||||
"django-filter==26.1",
|
||||
"django-formset-js-improved==0.5.0.5",
|
||||
"django-formtools==2.6.1",
|
||||
"django-hierarkey==2.0.*,>=2.0.1",
|
||||
"django-formtools==2.7",
|
||||
"django-hierarkey==2.0.*,>=2.0.2",
|
||||
"django-hijack==3.7.*",
|
||||
"django-i18nfield==1.11.*",
|
||||
"django-libsass==0.9",
|
||||
"django-localflavor==5.0",
|
||||
"django-localflavor==5.1",
|
||||
"django-markup",
|
||||
"django-oauth-toolkit==2.3.*",
|
||||
"django-otp==1.7.*",
|
||||
"django-phonenumber-field==8.4.*",
|
||||
"django-phonenumber-field==8.5.*",
|
||||
"django-querytagger==0.0.3",
|
||||
"django-redis==7.0.*",
|
||||
"django-scopes==2.0.*",
|
||||
"django-scopes==2.1.*",
|
||||
"django-statici18n==2.7.*",
|
||||
"djangorestframework==3.17.*",
|
||||
"dnspython==2.8.*",
|
||||
@@ -67,7 +67,7 @@ dependencies = [
|
||||
"kombu==5.6.*",
|
||||
"libsass==0.23.*",
|
||||
"lxml",
|
||||
"markdown==3.10.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
"markdown==3.10.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
||||
"mt-940==4.30.*",
|
||||
"oauthlib==3.3.*",
|
||||
@@ -94,11 +94,11 @@ dependencies = [
|
||||
"redis==7.4.*",
|
||||
"reportlab==5.0.*",
|
||||
"requests==2.34.*",
|
||||
"sentry-sdk==2.64.*",
|
||||
"sentry-sdk==2.66.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2026041800",
|
||||
"tlds>=2026072401",
|
||||
"tqdm==4.*",
|
||||
"ua-parser==1.0.*",
|
||||
"vobject==0.9.*",
|
||||
@@ -112,7 +112,7 @@ dev = [
|
||||
"aiohttp==3.14.*",
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"fakeredis==2.36.*",
|
||||
"fakeredis==2.37.*",
|
||||
"flake8==7.3.*",
|
||||
"freezegun",
|
||||
"isort==8.0.*",
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2026.7.0.dev0"
|
||||
__version__ = "2026.8.0.dev0"
|
||||
|
||||
@@ -104,6 +104,7 @@ ALL_LANGUAGES = [
|
||||
('gl', _('Galician')),
|
||||
('el', _('Greek')),
|
||||
('he', _('Hebrew')),
|
||||
('hu', _('Hungarian')),
|
||||
('id', _('Indonesian')),
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
|
||||
@@ -20,8 +20,11 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db import DatabaseError
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
@@ -30,6 +33,7 @@ from pretix.api.auth.devicesecurity import (
|
||||
FullAccessSecurityProfile, get_all_security_profiles,
|
||||
)
|
||||
from pretix.base.models import Device
|
||||
from pretix.base.models.devices import DeviceLastSeen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,7 +46,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
|
||||
model = self.get_model()
|
||||
try:
|
||||
with scopes_disabled():
|
||||
device = model.objects.select_related('organizer').get(api_token=key)
|
||||
device = model.objects.select_related('organizer', 'last_seen').get(api_token=key)
|
||||
except model.DoesNotExist:
|
||||
raise exceptions.AuthenticationFailed('Invalid token.')
|
||||
|
||||
@@ -53,6 +57,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
|
||||
logging.warning(f'Connection attempt of revoked device {device.pk}.')
|
||||
raise exceptions.AuthenticationFailed('Device access has been revoked.')
|
||||
|
||||
self._update_last_seen(device)
|
||||
return AnonymousUser(), device
|
||||
|
||||
def authenticate(self, request):
|
||||
@@ -63,3 +68,22 @@ class DeviceTokenAuthentication(TokenAuthentication):
|
||||
if not profile.is_allowed(request):
|
||||
raise exceptions.PermissionDenied('Request denied by device security profile.')
|
||||
return r
|
||||
|
||||
def _update_last_seen(self, device: Device):
|
||||
try:
|
||||
try:
|
||||
last_seen_obj = device.last_seen
|
||||
except DeviceLastSeen.DoesNotExist:
|
||||
# First request from device, create model, ignore result. Use get_or_create to be safe
|
||||
# against concurrent create requests
|
||||
DeviceLastSeen.objects.get_or_create(device=device, last_seen=now())
|
||||
else:
|
||||
if now() - last_seen_obj.last_seen < timedelta(seconds=10):
|
||||
# We don't need to know the last seen info of a device to more precision than this,
|
||||
# so we can avoid some database writes if the device is bursting a lot of requests.
|
||||
return
|
||||
last_seen_obj.last_seen = now()
|
||||
last_seen_obj.save(update_fields=["last_seen"])
|
||||
except DatabaseError:
|
||||
# Do not stop the request from happening
|
||||
logger.exception("Database error while updating last_seen")
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -52,10 +51,18 @@ class BaseSecurityProfile:
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
"""
|
||||
Priority for ordering, higher will come first.
|
||||
"""
|
||||
return 100
|
||||
|
||||
|
||||
class FullAccessSecurityProfile(BaseSecurityProfile):
|
||||
identifier = 'full'
|
||||
verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)')
|
||||
priority = 1000
|
||||
|
||||
def is_allowed(self, request):
|
||||
return True
|
||||
@@ -108,6 +115,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('POST', 'api-v1:checkinrpc.annull'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
('GET', 'api-v1:reusablemedium-list'),
|
||||
('POST', 'api-v1:reusablemedium-lookup'),
|
||||
@@ -146,6 +154,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('POST', 'api-v1:checkinrpc.annull'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
)
|
||||
|
||||
@@ -182,6 +191,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('POST', 'api-v1:checkinrpc.annull'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
)
|
||||
|
||||
@@ -192,13 +202,15 @@ def get_all_security_profiles():
|
||||
if _ALL_PROFILES:
|
||||
return _ALL_PROFILES
|
||||
|
||||
types = OrderedDict()
|
||||
types = []
|
||||
for recv, ret in register_device_security_profile.send(None):
|
||||
if isinstance(ret, (list, tuple)):
|
||||
for r in ret:
|
||||
types[r.identifier] = r
|
||||
types.append(r)
|
||||
else:
|
||||
types[ret.identifier] = ret
|
||||
types.append(ret)
|
||||
types.sort(key=lambda el: el.priority, reverse=True)
|
||||
types = {r.identifier: r for r in types}
|
||||
_ALL_PROFILES = types
|
||||
return types
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.db.models import prefetch_related_objects
|
||||
from rest_framework import serializers
|
||||
@@ -135,3 +136,30 @@ class SalesChannelMigrationMixin:
|
||||
else:
|
||||
value["sales_channels"] = value["limit_sales_channels"]
|
||||
return value
|
||||
|
||||
|
||||
class CompatDecimalField(serializers.DecimalField):
|
||||
"""
|
||||
Historically, pretix recorded tax rates as decimals with two places. Today, pretix supports tax rates with up to
|
||||
four places. Since our API outputs decimals with the stored precision, this would have changed the API output from
|
||||
"19.00" to "19.0000" without warning. While this is semantically the same thing, we need to assume some pretix API
|
||||
users might run into trouble, either because they treat the value as a string and then map something
|
||||
(e.g. ``if tax_rate == "19.00"``) or process it with a language where this is a significant difference. For example,
|
||||
while in Python ``Decimal("19.00") == Decimal("19.0000")`` is true, in Java
|
||||
``(new BigDecimal("19.00")).equals(new BigDecimal("19.0000"))`` is false and only
|
||||
``(new BigDecimal("19.00")).compareTo(new BigDecimal("19.0000")) == 0`` is true.
|
||||
|
||||
Therefore, we stay backwards compatible by outputting two decimal places *as long as the trailing digits are zero-valued.
|
||||
"""
|
||||
|
||||
regex = re.compile(r"^([0-9]+\.[0-9]{2})0+$")
|
||||
|
||||
def to_representation(self, value):
|
||||
if self.localize:
|
||||
raise ValueError("localization not supported")
|
||||
value = super().to_representation(value)
|
||||
if value and "." not in value:
|
||||
return f"{value}.00"
|
||||
if m := self.regex.match(value):
|
||||
return m.group(1)
|
||||
return value
|
||||
|
||||
@@ -90,6 +90,7 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
|
||||
answers = serializers.JSONField(required=False, allow_null=True)
|
||||
exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES)
|
||||
exchange_medium_identifier = serializers.CharField(required=False)
|
||||
simulate = serializers.BooleanField(default=False, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -48,7 +48,7 @@ from rest_framework.fields import ChoiceField, Field
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
|
||||
from pretix.api.serializers import (
|
||||
CompatibleJSONField, SalesChannelMigrationMixin,
|
||||
CompatDecimalField, CompatibleJSONField, SalesChannelMigrationMixin,
|
||||
)
|
||||
from pretix.api.serializers.fields import PluginsField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
@@ -681,6 +681,7 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
rate = CompatDecimalField(max_digits=7, decimal_places=4)
|
||||
|
||||
class Meta:
|
||||
model = TaxRule
|
||||
@@ -701,7 +702,7 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
return super().save(**kwargs)
|
||||
|
||||
def validate_default(self, value):
|
||||
if not value and self.instance.default:
|
||||
if not value and (not self.instance or self.instance.default):
|
||||
raise ValidationError("You can't remove the default property, instead set it on another tax rule.")
|
||||
return value
|
||||
|
||||
|
||||
@@ -42,7 +42,9 @@ from django.utils.functional import cached_property, lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.serializers import SalesChannelMigrationMixin
|
||||
from pretix.api.serializers import (
|
||||
CompatDecimalField, SalesChannelMigrationMixin,
|
||||
)
|
||||
from pretix.api.serializers.event import MetaDataField
|
||||
from pretix.api.serializers.fields import UploadedFileField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
@@ -51,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):
|
||||
@@ -276,10 +279,10 @@ class ItemAddOnSerializer(serializers.ModelSerializer):
|
||||
return value
|
||||
|
||||
|
||||
class ItemTaxRateField(serializers.Field):
|
||||
class ItemTaxRateField(CompatDecimalField):
|
||||
def to_representation(self, i):
|
||||
if i.tax_rule:
|
||||
return str(Decimal(i.tax_rule.rate))
|
||||
return super().to_representation(Decimal(i.tax_rule.rate))
|
||||
else:
|
||||
return str(Decimal('0.00'))
|
||||
|
||||
@@ -289,7 +292,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
bundles = InlineItemBundleSerializer(many=True, required=False)
|
||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True, max_digits=7, decimal_places=4)
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
@@ -540,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:
|
||||
@@ -548,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_file_portrait')
|
||||
'valid_string_length_max', 'valid_file_portrait', 'internal_name',)
|
||||
|
||||
def validate_identifier(self, value):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
@@ -624,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_question:
|
||||
return qc.system_question
|
||||
elif qc.user_question_id:
|
||||
return qc.user_question_id
|
||||
else:
|
||||
return None
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if type(data) == int:
|
||||
return {'user_question': super().to_internal_value(data), 'system_question': None}
|
||||
elif type(data) == str or data is None:
|
||||
return {'user_question': None, 'system_question': 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_question'] or child['dependency_question']['system_question']) not in prev_questions:
|
||||
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
|
||||
|
||||
if child['user_question']:
|
||||
prev_questions[child['user_question']] = child
|
||||
if child['system_question']:
|
||||
prev_questions[child['system_question']] = 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)
|
||||
|
||||
@@ -41,7 +41,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from pretix.api.serializers import CompatibleJSONField
|
||||
from pretix.api.serializers import CompatDecimalField, CompatibleJSONField
|
||||
from pretix.api.serializers.event import SubEventSerializer
|
||||
from pretix.api.serializers.forms import form_field_to_serializer_field
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
@@ -52,6 +52,7 @@ from pretix.api.signals import order_api_details, orderposition_api_details
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.invoicing.transmission import get_transmission_types
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
|
||||
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
|
||||
@@ -381,6 +382,7 @@ class PrintLogSerializer(serializers.ModelSerializer):
|
||||
class FailedCheckinSerializer(I18nAwareModelSerializer):
|
||||
error_reason = serializers.ChoiceField(choices=Checkin.REASONS, required=True, allow_null=False)
|
||||
raw_barcode = serializers.CharField(required=True, allow_null=False)
|
||||
raw_source_type = serializers.ChoiceField(choices=[(k, v) for k, v in MEDIA_TYPES.items()], default='barcode')
|
||||
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none(), required=False, allow_null=True)
|
||||
raw_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
|
||||
raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
|
||||
@@ -390,7 +392,7 @@ class FailedCheckinSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation',
|
||||
'raw_subevent', 'nonce', 'datetime', 'type', 'position')
|
||||
'raw_subevent', 'raw_source_type', 'nonce', 'datetime', 'type', 'position')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -591,6 +593,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
country = CompatibleCountryField(source='*')
|
||||
attendee_name = serializers.CharField(required=False)
|
||||
plugin_data = OrderPositionPluginDataField(source='*', allow_null=True, read_only=True)
|
||||
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
|
||||
|
||||
class Meta:
|
||||
list_serializer_class = OrderPositionListSerializer
|
||||
@@ -747,6 +750,8 @@ class OrderPaymentDateField(serializers.DateField):
|
||||
|
||||
|
||||
class OrderFeeSerializer(I18nAwareModelSerializer):
|
||||
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
|
||||
|
||||
class Meta:
|
||||
model = OrderFee
|
||||
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule',
|
||||
@@ -1911,6 +1916,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
||||
position = LinePositionField(read_only=True)
|
||||
event_date_from = serializers.DateTimeField(read_only=True, source="period_start")
|
||||
event_date_to = serializers.DateTimeField(read_only=True, source="period_end")
|
||||
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
|
||||
|
||||
class Meta:
|
||||
model = InvoiceLine
|
||||
@@ -1994,6 +2000,7 @@ class BlockedTicketSecretSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class TransactionSerializer(I18nAwareModelSerializer):
|
||||
order = serializers.SlugRelatedField(slug_field="code", read_only=True)
|
||||
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
|
||||
@@ -79,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)
|
||||
|
||||
@@ -139,6 +139,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
return qs
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -153,6 +154,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -837,6 +839,11 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
)
|
||||
|
||||
if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate
|
||||
if simulate:
|
||||
raise CheckInError(
|
||||
gettext('You cannot simulate a medium exchange.'),
|
||||
'error'
|
||||
)
|
||||
with transaction.atomic():
|
||||
# Do exchange and check-in atomically, i.e. both succeed or both fail
|
||||
medium = perform_media_exchange(
|
||||
@@ -1064,6 +1071,7 @@ class CheckinRPCRedeemView(views.APIView):
|
||||
legacy_url_support=False,
|
||||
exchange_medium_type=s.validated_data.get('exchange_medium_type'),
|
||||
exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
|
||||
simulate=s.validated_data.get('simulate'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
# 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.db import transaction
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import viewsets
|
||||
@@ -64,6 +65,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
'limit_sales_channels',
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -78,6 +80,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -87,6 +90,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied('You cannot delete this discount because it already has '
|
||||
|
||||
@@ -45,6 +45,7 @@ from rest_framework.exceptions import (
|
||||
NotFound, PermissionDenied, ValidationError,
|
||||
)
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.mixins import UpdateModelMixin
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.auth.permission import EventCRUDPermission
|
||||
@@ -256,6 +257,7 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
copy_from = None
|
||||
if 'clone_from' in self.request.GET:
|
||||
@@ -319,6 +321,7 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied('The event can not be deleted as it already contains orders. Please set \'live\''
|
||||
@@ -354,6 +357,7 @@ class CloneEventViewSet(viewsets.ModelViewSet):
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
# Weird edge case: Requires settings permission on the event (to read) but also on the organizer (two write)
|
||||
perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken))
|
||||
@@ -512,6 +516,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
resp['X-Page-Generated'] = date
|
||||
return resp
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
original_data = self.get_serializer(instance=serializer.instance).data
|
||||
super().perform_update(serializer)
|
||||
@@ -528,6 +533,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -537,6 +543,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied('The sub-event can not be deleted as it has already been used in orders. Please set'
|
||||
@@ -565,6 +572,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
def get_queryset(self):
|
||||
return self.request.event.tax_rules.all()
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
super().perform_update(serializer)
|
||||
serializer.instance.log_action(
|
||||
@@ -574,6 +582,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -583,6 +592,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied('This tax rule can not be deleted as it is currently in use.')
|
||||
@@ -711,7 +721,7 @@ class SeatFilter(FilterSet):
|
||||
fields = ('zone_name', 'row_name', 'row_label', 'seat_number', 'seat_label', 'seat_guid', 'blocked',)
|
||||
|
||||
|
||||
class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = SeatSerializer
|
||||
queryset = Seat.objects.none()
|
||||
write_permission = 'event.settings.general:write'
|
||||
@@ -756,6 +766,7 @@ class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
}
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
super().perform_update(serializer)
|
||||
serializer.instance.event.log_action(
|
||||
@@ -765,6 +776,7 @@ class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
data={"seats": [serializer.instance.pk]},
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def bulk_change_blocked(self, blocked):
|
||||
s = SeatBulkBlockInputSerializer(
|
||||
data=self.request.data,
|
||||
|
||||
@@ -23,6 +23,7 @@ from datetime import timedelta
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
@@ -45,7 +46,8 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.organizer import TeamQuerySet
|
||||
from pretix.base.services.export import (
|
||||
export, init_event_exporters, init_organizer_exporters, multiexport,
|
||||
ExportError, export, init_event_exporters, init_organizer_exporters,
|
||||
multiexport,
|
||||
)
|
||||
from pretix.helpers.http import ChunkBasedFileResponse
|
||||
|
||||
@@ -149,8 +151,11 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
))
|
||||
exporters = []
|
||||
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
|
||||
ex._serializer = JobRunSerializer(exporter=ex)
|
||||
exporters.append(ex)
|
||||
try:
|
||||
ex._serializer = JobRunSerializer(exporter=ex)
|
||||
exporters.append(ex)
|
||||
except ExportError:
|
||||
pass
|
||||
return exporters
|
||||
|
||||
def do_export(self, cf, instance, data):
|
||||
@@ -180,8 +185,11 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
))
|
||||
exporters = []
|
||||
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
|
||||
ex._serializer = JobRunSerializer(exporter=ex)
|
||||
exporters.append(ex)
|
||||
try:
|
||||
ex._serializer = JobRunSerializer(exporter=ex)
|
||||
exporters.append(ex)
|
||||
except ExportError:
|
||||
pass
|
||||
return exporters
|
||||
|
||||
def do_export(self, cf, instance, data):
|
||||
@@ -220,6 +228,7 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
|
||||
qs = self.request.event.scheduled_exports
|
||||
return qs.select_related("owner")
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
if not self.request.user.is_authenticated:
|
||||
raise PermissionDenied('Creation of exports requires user-specific API access.')
|
||||
@@ -250,6 +259,7 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
|
||||
))
|
||||
return {e.identifier: e for e in exporters}
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner:
|
||||
# This is to prevent a possible privilege escalation where user A creates a scheduled export and
|
||||
@@ -275,6 +285,7 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
self.request.event.log_action(
|
||||
'pretix.event.export.schedule.deleted',
|
||||
@@ -302,6 +313,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
|
||||
qs = self.request.organizer.scheduled_exports
|
||||
return qs.select_related("owner")
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
if not self.request.user.is_authenticated:
|
||||
raise PermissionDenied('Creation of exports requires user-specific API access.')
|
||||
@@ -332,6 +344,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
|
||||
))
|
||||
return {e.identifier: e for e in exporters}
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner:
|
||||
# This is to prevent a possible privilege escalation where user A creates a scheduled export and
|
||||
@@ -382,6 +395,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
self.request.organizer.log_action(
|
||||
'pretix.organizer.export.schedule.deleted',
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import django_filters
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
@@ -47,13 +48,14 @@ from pretix.api.pagination import TotalOrderingFilter
|
||||
from pretix.api.serializers.item import (
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
||||
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuestionnaireSerializer, 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
|
||||
@@ -109,6 +111,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
|
||||
).all()
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -123,6 +126,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
original_data = self.get_serializer(instance=serializer.instance).data
|
||||
|
||||
@@ -139,6 +143,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied('This item cannot be deleted because it has already been ordered '
|
||||
@@ -183,6 +188,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
item = self.item
|
||||
if not item.has_variations:
|
||||
@@ -197,6 +203,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
{'value': serializer.instance.value})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.item.log_action(
|
||||
@@ -207,6 +214,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
{'value': serializer.instance.value})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied('This variation cannot be deleted because it has already been ordered '
|
||||
@@ -249,6 +257,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
|
||||
ctx['item'] = self.item
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
serializer.save(base_item=item)
|
||||
@@ -259,6 +268,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
|
||||
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.base_item.log_action(
|
||||
@@ -268,6 +278,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
|
||||
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
super().perform_destroy(instance)
|
||||
instance.base_item.log_action(
|
||||
@@ -303,6 +314,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
|
||||
ctx['item'] = self.item
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
serializer.save(item=item)
|
||||
@@ -313,6 +325,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
|
||||
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.item.log_action(
|
||||
@@ -322,6 +335,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
|
||||
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
super().perform_destroy(instance)
|
||||
instance.item.log_action(
|
||||
@@ -354,6 +368,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
ctx['item'] = self.item
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
item = self.item
|
||||
category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category'])
|
||||
@@ -365,6 +380,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.base_item.log_action(
|
||||
@@ -374,6 +390,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
super().perform_destroy(instance)
|
||||
instance.base_item.log_action(
|
||||
@@ -403,6 +420,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
def get_queryset(self):
|
||||
return self.request.event.categories.all()
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -417,6 +435,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -426,6 +445,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
for item in instance.items.all():
|
||||
item.category = None
|
||||
@@ -458,6 +478,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
def get_queryset(self):
|
||||
return self.request.event.questions.prefetch_related('options').all()
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -472,6 +493,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -481,6 +503,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
instance.log_action(
|
||||
'pretix.event.question.deleted',
|
||||
@@ -509,6 +532,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
ctx['question'] = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
|
||||
serializer.save(question=q)
|
||||
@@ -519,6 +543,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.question.log_action(
|
||||
@@ -528,6 +553,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
instance.question.log_action(
|
||||
'pretix.event.question.option.deleted',
|
||||
@@ -538,6 +564,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
|
||||
|
||||
@@ -586,6 +657,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -608,6 +680,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
ctx['request'] = self.request
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
original_data = self.get_serializer(instance=serializer.instance).data
|
||||
|
||||
@@ -663,6 +736,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
)
|
||||
serializer.instance.rebuild_cache()
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
instance.log_action(
|
||||
'pretix.event.quota.deleted',
|
||||
|
||||
@@ -1658,6 +1658,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
count_waitinglist=False,
|
||||
force=request.data.get('force', False),
|
||||
send_mail=send_mail,
|
||||
ignore_date=request.data.get('force', False),
|
||||
)
|
||||
except Quota.QuotaExceededException:
|
||||
pass
|
||||
@@ -1693,7 +1694,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
auth=self.request.auth,
|
||||
count_waitinglist=False,
|
||||
send_mail=send_mail,
|
||||
force=force)
|
||||
force=force,
|
||||
ignore_date=force)
|
||||
except Quota.QuotaExceededException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except PaymentException as e:
|
||||
|
||||
@@ -394,6 +394,7 @@ class TeamViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
return inst
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
instance.log_action('pretix.team.deleted', user=self.request.user, auth=self.request.auth)
|
||||
instance.delete()
|
||||
@@ -693,6 +694,7 @@ class MembershipTypeViewSet(viewsets.ModelViewSet):
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied("Can only be deleted if unused.")
|
||||
@@ -833,6 +835,7 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
return inst
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.allow_delete():
|
||||
raise PermissionDenied("Can only be deleted if unused.")
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import django_filters
|
||||
from django.db import transaction
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import viewsets
|
||||
@@ -62,6 +63,7 @@ class WaitingListViewSet(viewsets.ModelViewSet):
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
@@ -70,6 +72,7 @@ class WaitingListViewSet(viewsets.ModelViewSet):
|
||||
auth=self.request.auth,
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
if serializer.instance.voucher:
|
||||
raise PermissionDenied('This entry can not be changed as it has already been assigned a voucher.')
|
||||
@@ -80,6 +83,7 @@ class WaitingListViewSet(viewsets.ModelViewSet):
|
||||
auth=self.request.auth,
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
if instance.voucher:
|
||||
raise PermissionDenied('This entry can not be deleted as it has already been assigned a voucher.')
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import django_filters
|
||||
from django.db import transaction
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import viewsets
|
||||
|
||||
@@ -48,6 +49,7 @@ class WebHookViewSet(viewsets.ModelViewSet):
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
self.request.organizer.log_action(
|
||||
@@ -57,6 +59,7 @@ class WebHookViewSet(viewsets.ModelViewSet):
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
self.request.organizer.log_action(
|
||||
@@ -67,6 +70,7 @@ class WebHookViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
return inst
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
self.request.organizer.log_action(
|
||||
'pretix.webhook.changed',
|
||||
|
||||
@@ -23,6 +23,7 @@ import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape, format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext
|
||||
|
||||
@@ -35,21 +36,23 @@ def get_powered_by(request, safelink=True):
|
||||
d = gs.settings.license_check_input
|
||||
if d.get('poweredby_name'):
|
||||
if d.get('poweredby_url'):
|
||||
msg = gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>').format(
|
||||
msg = format_html(
|
||||
gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>'),
|
||||
name=d['poweredby_name'],
|
||||
a_name_attr='href="{}" target="_blank" rel="noopener"'.format(
|
||||
sl(d['poweredby_url']) if safelink else d['poweredby_url'],
|
||||
),
|
||||
a_attr='href="{}" target="_blank" rel="noopener"'.format(
|
||||
a_name_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format(
|
||||
escape(sl(d['poweredby_url'])) if safelink else escape(d['poweredby_url']),
|
||||
)),
|
||||
a_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format(
|
||||
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
|
||||
)
|
||||
))
|
||||
)
|
||||
else:
|
||||
msg = gettext('<a {a_attr}>powered by {name} based on pretix</a>').format(
|
||||
msg = format_html(
|
||||
gettext('<a {a_attr}>powered by {name} based on pretix</a>'),
|
||||
name=d['poweredby_name'],
|
||||
a_attr='href="{}" target="_blank" rel="noopener"'.format(
|
||||
a_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format(
|
||||
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
|
||||
)
|
||||
))
|
||||
)
|
||||
else:
|
||||
msg = gettext('<a %(a_attr)s>ticketing powered by pretix</a>') % {
|
||||
|
||||
@@ -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/>.
|
||||
#
|
||||
import ipaddress
|
||||
import logging
|
||||
import smtplib
|
||||
import socket
|
||||
@@ -43,6 +42,7 @@ from pretix.base.templatetags.rich_text import (
|
||||
markdown_compile_email, truelink_callback,
|
||||
)
|
||||
from pretix.helpers.format import FormattedString, SafeFormatter, format_map
|
||||
from pretix.helpers.ssrf import should_block_access
|
||||
|
||||
from pretix.base.services.placeholders import ( # noqa
|
||||
get_available_placeholders, PlaceholderContext
|
||||
@@ -57,8 +57,6 @@ logger = logging.getLogger('pretix.base.email')
|
||||
|
||||
T = TypeVar("T", bound=EmailBackend)
|
||||
|
||||
_cgnat_net = ipaddress.ip_network('100.64.0.0/10')
|
||||
|
||||
|
||||
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
|
||||
try:
|
||||
@@ -254,16 +252,9 @@ def create_connection(address, timeout=socket.getdefaulttimeout(),
|
||||
af, socktype, proto, canonname, sa = res
|
||||
|
||||
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
|
||||
ip_addr = ipaddress.ip_address(sa[0])
|
||||
check_ip4 = ip_addr.ipv4_mapped if getattr(ip_addr, "ipv4_mapped", None) else ip_addr
|
||||
if ip_addr.is_multicast:
|
||||
raise socket.error(f"Request to multicast address {sa[0]} blocked")
|
||||
if ip_addr.is_loopback or ip_addr.is_link_local:
|
||||
raise socket.error(f"Request to local address {sa[0]} blocked")
|
||||
if ip_addr.is_private:
|
||||
raise socket.error(f"Request to private address {sa[0]} blocked")
|
||||
if check_ip4 in _cgnat_net:
|
||||
raise socket.error(f"Request to RFC 6598 address {sa[0]} blocked")
|
||||
is_private, msg = should_block_access(sa)
|
||||
if is_private:
|
||||
raise socket.error(msg)
|
||||
|
||||
sock = None
|
||||
try:
|
||||
|
||||
@@ -40,11 +40,12 @@ from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
|
||||
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from ..exporter import ListExporter, OrganizerLevelExportMixin
|
||||
from ..exporter import MultiSheetListExporter, OrganizerLevelExportMixin
|
||||
from ..models import Membership
|
||||
from ..signals import register_multievent_data_exporters
|
||||
|
||||
|
||||
class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
class CustomerListExporter(OrganizerLevelExportMixin, MultiSheetListExporter):
|
||||
identifier = 'customerlist'
|
||||
verbose_name = gettext_lazy('Customer accounts')
|
||||
category = pgettext_lazy('export_category', 'Customer accounts')
|
||||
@@ -54,13 +55,20 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
def get_required_organizer_permission(cls) -> str:
|
||||
return 'organizer.customers:write'
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
return (
|
||||
('customers', _('Customers')),
|
||||
('memberships', _('Memberships')),
|
||||
)
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[]
|
||||
)
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
def iterate_customers(self, form_data):
|
||||
qs = self.organizer.customers.prefetch_related('provider')
|
||||
|
||||
headers = [
|
||||
@@ -109,6 +117,52 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
]
|
||||
yield row
|
||||
|
||||
def iterate_memberships(self, form_data):
|
||||
qs = Membership.objects.filter(
|
||||
customer__organizer=self.organizer
|
||||
).prefetch_related('membership_type').select_related('customer', 'granted_in', 'granted_in__order')
|
||||
|
||||
headers = [
|
||||
_('Customer ID'),
|
||||
_('External identifier'),
|
||||
_('Email'),
|
||||
_('Test mode'),
|
||||
_('Canceled'),
|
||||
_('Membership type'),
|
||||
_('Purchase ticket'),
|
||||
_('Start date'),
|
||||
_('Start time'),
|
||||
_('End date'),
|
||||
_('End time'),
|
||||
_('Name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Name') + ': ' + str(label))
|
||||
yield headers
|
||||
|
||||
tz = get_current_timezone()
|
||||
for obj in qs:
|
||||
row = [
|
||||
obj.customer.identifier,
|
||||
obj.customer.external_identifier,
|
||||
obj.customer.email or '',
|
||||
_('Yes') if obj.testmode else _('No'),
|
||||
_('Yes') if obj.canceled else _('No'),
|
||||
str(obj.membership_type.name),
|
||||
f'{obj.granted_in.order.code}-{obj.granted_in.positionid}' if obj.granted_in else None,
|
||||
obj.date_start.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
obj.date_start.astimezone(tz).strftime('%H:%M'),
|
||||
obj.date_end.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
obj.date_end.astimezone(tz).strftime('%H:%M'),
|
||||
obj.attendee_name or '',
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(obj.attendee_name_parts.get(k, ''))
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_customers'.format(self.organizer.slug)
|
||||
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
# 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 hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
@@ -42,13 +40,12 @@ from django.conf import settings
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
)
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.metrics import pretix_failed_logins
|
||||
from pretix.base.models import User
|
||||
from pretix.helpers.dicts import move_to_end
|
||||
from pretix.helpers.http import get_client_ip
|
||||
from pretix.helpers.ratelimit import rate_limit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,40 +82,20 @@ class LoginForm(forms.Form):
|
||||
else:
|
||||
move_to_end(self.fields, 'keep_logged_in')
|
||||
|
||||
@cached_property
|
||||
def ratelimit_key(self):
|
||||
if not settings.HAS_REDIS:
|
||||
return None
|
||||
client_ip = get_client_ip(self.request)
|
||||
if not client_ip:
|
||||
return None
|
||||
try:
|
||||
client_ip = ipaddress.ip_address(client_ip)
|
||||
except ValueError:
|
||||
# Web server not set up correctly
|
||||
return None
|
||||
if client_ip.is_private:
|
||||
# This is the private IP of the server, web server not set up correctly
|
||||
return None
|
||||
return 'pretix_login_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
|
||||
|
||||
def clean(self):
|
||||
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
|
||||
if self.ratelimit_key:
|
||||
from django_redis import get_redis_connection
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.get(self.ratelimit_key)
|
||||
if cnt and int(cnt) > 10:
|
||||
pretix_failed_logins.inc(1, reason="ratelimit")
|
||||
logger.info("Backend login rejected due to rate limit.")
|
||||
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
|
||||
rate_limit_kwargs = dict(include_ip_from_request=self.request, max_num=10, expire_time=300)
|
||||
if rate_limit("login", **rate_limit_kwargs, increase=False):
|
||||
# Check rate limit without counting up, we increase below only on failed logins
|
||||
pretix_failed_logins.inc(1, reason="ratelimit")
|
||||
logger.info("Backend login rejected due to rate limit.")
|
||||
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
|
||||
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
||||
if self.user_cache is None:
|
||||
if self.ratelimit_key:
|
||||
rc.incr(self.ratelimit_key)
|
||||
rc.expire(self.ratelimit_key, 300)
|
||||
logger.info("Backend login invalid.")
|
||||
pretix_failed_logins.inc(1, reason="invalid")
|
||||
# Count towards rate limit (result is ignored, we are checking above)
|
||||
rate_limit("login", **rate_limit_kwargs)
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['invalid_login'],
|
||||
code='invalid_login'
|
||||
|
||||
+392
-379
@@ -656,7 +656,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
orderpos = self.orderpos = kwargs.pop('orderpos', None)
|
||||
pos = cartpos or orderpos
|
||||
item = pos.item
|
||||
questions = pos.item.questions_to_ask
|
||||
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
|
||||
@@ -664,384 +664,15 @@ class BaseQuestionsForm(forms.Form):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
|
||||
if item.validity_dynamic_start_choice_day_limit:
|
||||
max_date = time_machine_now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
|
||||
else:
|
||||
max_date = None
|
||||
min_date = time_machine_now()
|
||||
initial = None
|
||||
if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership:
|
||||
if pos.used_membership.date_start >= time_machine_now():
|
||||
initial = min_date = pos.used_membership.date_start
|
||||
max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end
|
||||
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
|
||||
attrs = {}
|
||||
if max_date:
|
||||
attrs['data-max'] = max_date.date().isoformat()
|
||||
if min_date:
|
||||
attrs['data-min'] = min_date.date().isoformat()
|
||||
self.fields['requested_valid_from'] = forms.DateField(
|
||||
label=_('Start date'),
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=DatePickerWidget(attrs),
|
||||
validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())]
|
||||
)
|
||||
else:
|
||||
self.fields['requested_valid_from'] = forms.SplitDateTimeField(
|
||||
label=_('Start date'),
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=min_date,
|
||||
max_date=max_date
|
||||
),
|
||||
validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)]
|
||||
)
|
||||
self.fields['requested_valid_from'] = self.build_requested_valid_from_field(event, pos, item)
|
||||
|
||||
add_fields = {}
|
||||
|
||||
if item.ask_attendee_data and event.settings.attendee_names_asked:
|
||||
add_fields['attendee_name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.attendee_names_required and not self.all_optional,
|
||||
scheme=event.settings.name_scheme,
|
||||
titles=event.settings.name_scheme_titles,
|
||||
label=_('Attendee name'),
|
||||
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
|
||||
)
|
||||
if item.ask_attendee_data and event.settings.attendee_emails_asked:
|
||||
add_fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required and not self.all_optional,
|
||||
label=_('Attendee email'),
|
||||
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
'autocomplete': 'email'
|
||||
}
|
||||
)
|
||||
)
|
||||
if item.ask_attendee_data and event.settings.attendee_company_asked:
|
||||
add_fields['company'] = forms.CharField(
|
||||
required=event.settings.attendee_company_required and not self.all_optional,
|
||||
label=_('Company'),
|
||||
max_length=255,
|
||||
initial=(cartpos.company if cartpos else orderpos.company),
|
||||
)
|
||||
|
||||
if item.ask_attendee_data and event.settings.attendee_addresses_asked:
|
||||
add_fields['street'] = forms.CharField(
|
||||
required=self.attendee_addresses_required,
|
||||
label=_('Address'),
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
'placeholder': _('Street and Number'),
|
||||
'autocomplete': 'street-address'
|
||||
}),
|
||||
initial=(cartpos.street if cartpos else orderpos.street),
|
||||
)
|
||||
add_fields['zipcode'] = forms.CharField(
|
||||
required=False,
|
||||
max_length=30,
|
||||
label=_('ZIP code'),
|
||||
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'postal-code',
|
||||
}),
|
||||
)
|
||||
add_fields['city'] = forms.CharField(
|
||||
required=False,
|
||||
label=_('City'),
|
||||
max_length=255,
|
||||
initial=(cartpos.city if cartpos else orderpos.city),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
)
|
||||
country = (cartpos.country if cartpos else orderpos.country) or guess_country_from_request(request, event)
|
||||
add_fields['country'] = CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
required=self.attendee_addresses_required,
|
||||
label=_('Country'),
|
||||
initial=country,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'country',
|
||||
'data-trigger-address-info': 'on',
|
||||
}),
|
||||
)
|
||||
c = [('', '---')]
|
||||
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
|
||||
cc = None
|
||||
state = None
|
||||
if fprefix + 'country' in self.data:
|
||||
cc = str(self.data[fprefix + 'country'])
|
||||
elif country:
|
||||
cc = str(country)
|
||||
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
|
||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
|
||||
state = (cartpos.state if cartpos else orderpos.state)
|
||||
elif fprefix + 'state' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
add_fields['state'] = forms.ChoiceField(
|
||||
label=pgettext_lazy('address', 'State'),
|
||||
required=False,
|
||||
choices=c,
|
||||
initial=state,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'address-level1',
|
||||
}),
|
||||
)
|
||||
add_fields['state'].widget.is_required = True
|
||||
|
||||
field_positions = list(
|
||||
[
|
||||
(n, event.settings.system_question_order.get(n if n != 'state' else 'country', 0))
|
||||
for n in add_fields.keys()
|
||||
]
|
||||
)
|
||||
|
||||
for q in questions:
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
answers = [a for a in pos.answerlist if a.question_id == q.id]
|
||||
if answers:
|
||||
initial = answers[0]
|
||||
else:
|
||||
initial = None
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
help_text = rich_text(q.help_text)
|
||||
label = escape(q.question) # django-bootstrap3 calls mark_safe
|
||||
required = q.required and not self.all_optional
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
# itself.
|
||||
widget = forms.CheckboxInput(attrs={'required': 'required'})
|
||||
else:
|
||||
widget = forms.CheckboxInput()
|
||||
|
||||
if initial:
|
||||
initialbool = (initial.answer == "True")
|
||||
else:
|
||||
initialbool = False
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
label=label, required=required,
|
||||
min_value=q.valid_number_min or Decimal('0.00'),
|
||||
max_value=q.valid_number_max,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField(
|
||||
countries=CachedCountries,
|
||||
blank=True, null=True, blank_label=' ',
|
||||
).formfield(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label=' ',
|
||||
initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None),
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=label, required=required,
|
||||
help_text=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:
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=label, required=required,
|
||||
help_text=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:
|
||||
field = PortraitImageField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=PortraitImageWidget(position=pos, event=event, answer=initial, attrs={'data-portrait-photo': 'true'}),
|
||||
)
|
||||
else:
|
||||
field = ExtFileField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
|
||||
)
|
||||
elif q.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()
|
||||
if not help_text:
|
||||
if q.valid_date_min and q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).date()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.DateField(
|
||||
label=label, required=required,
|
||||
help_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 initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).time()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.TimeField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=_initial,
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
if not help_text:
|
||||
if q.valid_datetime_min and q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).astimezone(tz)
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
|
||||
field = SplitDateTimeField(
|
||||
label=label, required=required,
|
||||
help_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
|
||||
),
|
||||
)
|
||||
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 initial:
|
||||
try:
|
||||
initial = PhoneNumber().from_string(initial.answer)
|
||||
except NumberParseException:
|
||||
initial = None
|
||||
|
||||
if not initial:
|
||||
phone_prefix = guess_phone_prefix_from_request(request, event)
|
||||
if phone_prefix:
|
||||
initial = "+{}.".format(phone_prefix)
|
||||
|
||||
field = PhoneNumberField(
|
||||
label=label, required=required,
|
||||
help_text=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
|
||||
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
|
||||
field.required = False
|
||||
|
||||
add_fields['question_%s' % q.id] = field
|
||||
field_positions.append(('question_%s' % q.id, q.position))
|
||||
|
||||
field_positions.sort(key=lambda e: e[1])
|
||||
for fname, p in field_positions:
|
||||
self.fields[fname] = add_fields[fname]
|
||||
for questionnaire in questionnaires:
|
||||
for child in getattr(questionnaire, 'childlist', questionnaire.children.all()):
|
||||
if child.user_question:
|
||||
q = child.user_question
|
||||
self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos, q)
|
||||
elif child.system_question:
|
||||
self.fields[child.system_question] = self.build_system_question_field(request, event, pos, child)
|
||||
|
||||
responses = question_form_fields.send(sender=event, position=pos)
|
||||
data = pos.meta_info_data
|
||||
@@ -1066,6 +697,388 @@ class BaseQuestionsForm(forms.Form):
|
||||
else:
|
||||
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete
|
||||
|
||||
def build_requested_valid_from_field(self, event, pos, item):
|
||||
if item.validity_dynamic_start_choice_day_limit:
|
||||
max_date = time_machine_now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
|
||||
else:
|
||||
max_date = None
|
||||
min_date = time_machine_now()
|
||||
initial = None
|
||||
if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership:
|
||||
if pos.used_membership.date_start >= time_machine_now():
|
||||
initial = min_date = pos.used_membership.date_start
|
||||
max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end
|
||||
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
|
||||
attrs = {}
|
||||
if max_date:
|
||||
attrs['data-max'] = max_date.date().isoformat()
|
||||
if min_date:
|
||||
attrs['data-min'] = min_date.date().isoformat()
|
||||
return forms.DateField(
|
||||
label=_('Start date'),
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=DatePickerWidget(attrs),
|
||||
validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())]
|
||||
)
|
||||
else:
|
||||
return forms.SplitDateTimeField(
|
||||
label=_('Start date'),
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=min_date,
|
||||
max_date=max_date
|
||||
),
|
||||
validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)]
|
||||
)
|
||||
|
||||
def build_system_question_field(self, request, event, pos, qc):
|
||||
field_name = qc.system_question
|
||||
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=qc.label,
|
||||
help_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=qc.label,
|
||||
help_text=qc.help_text,
|
||||
initial=pos.attendee_email,
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
'autocomplete': 'email'
|
||||
}
|
||||
)
|
||||
)
|
||||
if field_name == 'company':
|
||||
return forms.CharField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
max_length=255,
|
||||
initial=pos.company,
|
||||
)
|
||||
|
||||
if field_name == 'street':
|
||||
return forms.CharField(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 2,
|
||||
'placeholder': _('Street and Number'),
|
||||
'autocomplete': 'street-address'
|
||||
}),
|
||||
initial=pos.street,
|
||||
)
|
||||
if field_name == 'zipcode':
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
max_length=30,
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
initial=pos.zipcode,
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'postal-code',
|
||||
}),
|
||||
)
|
||||
if field_name == 'city':
|
||||
return forms.CharField(
|
||||
required=False,
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
max_length=255,
|
||||
initial=pos.city,
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
)
|
||||
if field_name == 'country':
|
||||
country = pos.country or guess_country_from_request(request, event)
|
||||
return CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
required=qc.required and not self.all_optional,
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
initial=country,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'country',
|
||||
'data-trigger-address-info': 'on',
|
||||
}),
|
||||
)
|
||||
if field_name == 'state':
|
||||
country = pos.country or guess_country_from_request(request, event)
|
||||
c = [('', '---')]
|
||||
fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else ''
|
||||
cc = None
|
||||
state = None
|
||||
if fprefix + 'country' in self.data:
|
||||
cc = str(self.data[fprefix + 'country'])
|
||||
elif country:
|
||||
cc = str(country)
|
||||
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
|
||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
|
||||
state = (pos.state)
|
||||
elif fprefix + 'state' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
field = forms.ChoiceField(
|
||||
label=qc.label,
|
||||
help_text=qc.help_text,
|
||||
required=False,
|
||||
choices=c,
|
||||
initial=state,
|
||||
widget=forms.Select(attrs={
|
||||
'autocomplete': 'address-level1',
|
||||
}),
|
||||
)
|
||||
field.widget.is_required = True
|
||||
return field
|
||||
|
||||
def build_user_question_field(self, request, event, pos, q):
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
answers = [a for a in pos.answerlist if a.question_id == q.id]
|
||||
if answers:
|
||||
initial = answers[0]
|
||||
else:
|
||||
initial = None
|
||||
tz = ZoneInfo(event.settings.timezone)
|
||||
help_text = rich_text(q.help_text)
|
||||
label = escape(q.question) # django-bootstrap3 calls mark_safe
|
||||
required = q.required and not self.all_optional
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
# itself.
|
||||
widget = forms.CheckboxInput(attrs={'required': 'required'})
|
||||
else:
|
||||
widget = forms.CheckboxInput()
|
||||
|
||||
if initial:
|
||||
initialbool = (initial.answer == "True")
|
||||
else:
|
||||
initialbool = False
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
field = forms.DecimalField(
|
||||
label=label, required=required,
|
||||
min_value=q.valid_number_min or Decimal('0.00'),
|
||||
max_value=q.valid_number_max,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_COUNTRYCODE:
|
||||
field = CountryField(
|
||||
countries=CachedCountries,
|
||||
blank=True, null=True, blank_label=' ',
|
||||
).formfield(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label=' ',
|
||||
initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None),
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=label, required=required,
|
||||
help_text=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:
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=label, required=required,
|
||||
help_text=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:
|
||||
field = PortraitImageField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=PortraitImageWidget(position=pos, event=event, answer=initial, attrs={'data-portrait-photo': 'true'}),
|
||||
)
|
||||
else:
|
||||
field = ExtFileField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER,
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER,
|
||||
)
|
||||
elif q.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()
|
||||
if not help_text:
|
||||
if q.valid_date_min and q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).date()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.DateField(
|
||||
label=label, required=required,
|
||||
help_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 initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).time()
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
field = forms.TimeField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=_initial,
|
||||
widget=TimePickerWidget(without_seconds=True),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
if not help_text:
|
||||
if q.valid_datetime_min and q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
elif q.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"),
|
||||
)
|
||||
|
||||
if initial and initial.answer:
|
||||
try:
|
||||
_initial = dateutil.parser.parse(initial.answer).astimezone(tz)
|
||||
except dateutil.parser.ParserError:
|
||||
_initial = None
|
||||
else:
|
||||
_initial = None
|
||||
|
||||
field = SplitDateTimeField(
|
||||
label=label, required=required,
|
||||
help_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
|
||||
),
|
||||
)
|
||||
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 initial:
|
||||
try:
|
||||
initial = PhoneNumber().from_string(initial.answer)
|
||||
except NumberParseException:
|
||||
initial = None
|
||||
|
||||
if not initial:
|
||||
phone_prefix = guess_phone_prefix_from_request(request, event)
|
||||
if phone_prefix:
|
||||
initial = "+{}.".format(phone_prefix)
|
||||
|
||||
field = PhoneNumberField(
|
||||
label=label, required=required,
|
||||
help_text=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
|
||||
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
|
||||
field.required = False
|
||||
|
||||
return field
|
||||
|
||||
def clean(self):
|
||||
from pretix.base.addressvalidation import \
|
||||
validate_address # local import to prevent impact on startup time
|
||||
@@ -1121,7 +1134,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
for q in question_cache.values():
|
||||
if q.required and q.type == Question.TYPE_BOOLEAN:
|
||||
if 'question_%d' % q.pk in d and d['question_%d' % q.pk] is False:
|
||||
del d['question_%d' % q.pk]
|
||||
d['question_%d' % q.pk] = None
|
||||
|
||||
return d
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
@@ -46,6 +45,7 @@ from pytz import common_timezones
|
||||
from pretix.base.models import User
|
||||
from pretix.control.forms import SingleLanguageWidget
|
||||
from pretix.helpers.format import format_map
|
||||
from pretix.helpers.ratelimit import rate_limit
|
||||
|
||||
|
||||
class UserSettingsForm(forms.ModelForm):
|
||||
@@ -128,16 +128,11 @@ class UserPasswordChangeForm(forms.Form):
|
||||
def clean_old_pw(self):
|
||||
old_pw = self.cleaned_data.get('old_pw')
|
||||
|
||||
if settings.HAS_REDIS:
|
||||
from django_redis import get_redis_connection
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
|
||||
rc.expire('pretix_pwchange_%s' % self.user.pk, 300)
|
||||
if cnt > 10:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
if rate_limit("pwchange", self.user.pk, max_num=10, expire_time=300):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
|
||||
if not check_password(old_pw, self.user.password):
|
||||
raise forms.ValidationError(
|
||||
@@ -175,19 +170,35 @@ class UserEmailChangeForm(forms.Form):
|
||||
error_messages = {
|
||||
'duplicate_identifier': _("There already is an account associated with this email address. "
|
||||
"Please choose a different one."),
|
||||
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||
}
|
||||
old_email = forms.EmailField(label=_('Old email address'), disabled=True)
|
||||
new_email = forms.EmailField(label=_('New email address'))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user')
|
||||
self.request = kwargs.pop('request')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_new_email(self):
|
||||
email = self.cleaned_data['new_email']
|
||||
|
||||
if rate_limit("emailchange_attempt", include_ip_from_request=self.request, max_num=5, expire_time=300):
|
||||
# Rate limit lookup for conflicting email addresses to make enumeration harder
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
|
||||
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_identifier'],
|
||||
code='duplicate_identifier',
|
||||
)
|
||||
|
||||
if rate_limit("emailchange", self.user.pk, max_num=2, expire_time=300):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
return email
|
||||
|
||||
@@ -43,6 +43,10 @@ from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.helpers.format import PlainHtmlAlternativeString
|
||||
from pretix.helpers.i18n import (
|
||||
get_format_without_seconds, get_javascript_format,
|
||||
get_javascript_format_without_seconds,
|
||||
)
|
||||
|
||||
|
||||
def replace_arabic_numbers(inp):
|
||||
@@ -108,7 +112,7 @@ class DatePickerWidget(forms.DateInput):
|
||||
|
||||
|
||||
class TimePickerWidget(forms.TimeInput):
|
||||
def __init__(self, attrs=None, time_format=None):
|
||||
def __init__(self, attrs=None, time_format=None, without_seconds=False):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
@@ -117,8 +121,27 @@ class TimePickerWidget(forms.TimeInput):
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
time_attrs['autocomplete'] = 'off'
|
||||
|
||||
if time_format or without_seconds:
|
||||
# Explicitly set data-format attributes for the JS layer instead of relying on the body-wide config
|
||||
def time_format_attr():
|
||||
if without_seconds:
|
||||
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
|
||||
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
|
||||
|
||||
time_attrs['data-format'] = lazy(time_format_attr, str)
|
||||
|
||||
def time_format_attr():
|
||||
if without_seconds:
|
||||
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
|
||||
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
|
||||
|
||||
time_attrs['data-format'] = lazy(time_format_attr, str)
|
||||
|
||||
def placeholder():
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
if without_seconds:
|
||||
tf = time_format or get_format_without_seconds('TIME_INPUT_FORMATS')
|
||||
else:
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
@@ -182,7 +205,7 @@ class UploadedFileWidget(forms.ClearableFileInput):
|
||||
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
template_name = 'pretixbase/forms/widgets/splitdatetime.html'
|
||||
|
||||
def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None):
|
||||
def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None, without_seconds=False):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
@@ -205,14 +228,36 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date()
|
||||
).isoformat()
|
||||
|
||||
if date_format or time_format or without_seconds:
|
||||
# Explicitly set data-format attributes for the JS layer instead of relying on the body-wide config
|
||||
def date_format_attr():
|
||||
if without_seconds:
|
||||
return get_javascript_format_without_seconds(date_format or "DATE_INPUT_FORMATS")
|
||||
return get_javascript_format(date_format or "DATE_INPUT_FORMATS")
|
||||
|
||||
date_attrs['data-format'] = lazy(date_format_attr, str)
|
||||
|
||||
def time_format_attr():
|
||||
if without_seconds:
|
||||
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
|
||||
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
|
||||
|
||||
time_attrs['data-format'] = lazy(time_format_attr, str)
|
||||
|
||||
def date_placeholder():
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
if without_seconds:
|
||||
df = date_format or get_format_without_seconds('DATE_INPUT_FORMATS')
|
||||
else:
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
|
||||
def time_placeholder():
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
if without_seconds:
|
||||
tf = time_format or get_format_without_seconds('TIME_INPUT_FORMATS')
|
||||
else:
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
|
||||
+122
-17
@@ -19,6 +19,8 @@
|
||||
# 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 base64
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
@@ -69,11 +71,44 @@ def get_supported_language(requested_language, allowed_languages, default_langua
|
||||
return language
|
||||
|
||||
|
||||
class LocaleMiddleware(MiddlewareMixin):
|
||||
|
||||
class BaseLocaleMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
This middleware sets the correct locale and timezone
|
||||
for a request.
|
||||
This is a reduced LocaleMiddleware that uses only information contained in the WSGI request data
|
||||
to figure out the language (cookie and browser settings). We need it to have a consistent language
|
||||
for error pages that are generated from the middleware stack before we know e.g. which user is logged
|
||||
in or which event is selected.
|
||||
"""
|
||||
|
||||
def process_request(self, request: HttpRequest):
|
||||
language = get_language_from_early_request(request)
|
||||
translation.activate(language)
|
||||
set_region(None)
|
||||
request.LANGUAGE_CODE = language
|
||||
timezone.deactivate()
|
||||
|
||||
def process_response(self, request: HttpRequest, response: HttpResponse):
|
||||
language = translation.get_language()
|
||||
patch_vary_headers(response, ('Accept-Language',))
|
||||
if 'Content-Language' not in response:
|
||||
response['Content-Language'] = language
|
||||
return response
|
||||
|
||||
|
||||
class LocaleMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
This is the full LocaleMiddleware that uses all available information to figure out the correct
|
||||
language for the request using all available sources, in this order of priority:
|
||||
|
||||
- Backend: User settings
|
||||
- Language cookie
|
||||
- Frontend: Customer account settings
|
||||
- Browser settings
|
||||
- Frontend: Event/Organizer settings
|
||||
- System default
|
||||
|
||||
It needs to run late in the middleware stack to have all information available for these steps.
|
||||
For some cases, it is even ran a second time since the event is sometimes only figured out after the
|
||||
middleware stack (can happen for plugin views).
|
||||
"""
|
||||
|
||||
def process_request(self, request: HttpRequest):
|
||||
@@ -188,6 +223,24 @@ def get_default_language():
|
||||
return settings.LANGUAGE_CODE
|
||||
|
||||
|
||||
def get_language_from_early_request(request: HttpRequest) -> str:
|
||||
"""
|
||||
Analyzes the request to find what language the user wants the system to
|
||||
show using only WSGI-available information. Only languages listed in
|
||||
settings.LANGUAGES are taken into account. If the user requests a sublanguage
|
||||
where we have a main language, we send out the main language.
|
||||
"""
|
||||
global _supported
|
||||
if _supported is None:
|
||||
_supported = OrderedDict(settings.LANGUAGES)
|
||||
|
||||
return (
|
||||
get_language_from_cookie(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_default_language()
|
||||
)
|
||||
|
||||
|
||||
def get_language_from_request(request: HttpRequest) -> str:
|
||||
"""
|
||||
Analyzes the request to find what language the user wants the system to
|
||||
@@ -202,7 +255,6 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
if request.path.startswith(get_script_prefix() + 'control'):
|
||||
return (
|
||||
get_language_from_user_settings(request)
|
||||
or get_language_from_customer_settings(request)
|
||||
or get_language_from_cookie(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
@@ -262,10 +314,50 @@ def _merge_csp(a, b):
|
||||
for k, v in a.items():
|
||||
if "'unsafe-inline'" in v:
|
||||
# If we need unsafe-inline, drop any hashes or nonce as they will be ignored otherwise
|
||||
a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha-")]
|
||||
a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha256-")]
|
||||
|
||||
|
||||
def add_to_response_csp(response, csp_to_merge):
|
||||
if "Content-Security-Policy" in response:
|
||||
csp = _parse_csp(response["Content-Security-Policy"])
|
||||
else:
|
||||
csp = {}
|
||||
|
||||
_merge_csp(csp, csp_to_merge)
|
||||
|
||||
if csp:
|
||||
response["Content-Security-Policy"] = _render_csp(csp)
|
||||
|
||||
|
||||
def add_to_response_csp_via_request(request, csp_to_merge):
|
||||
_merge_csp(request._csp_to_merge, csp_to_merge)
|
||||
|
||||
|
||||
def calculate_csp_hash(data):
|
||||
hash_str = base64.b64encode(hashlib.sha256(data.encode("utf-8")).digest()).decode("ascii")
|
||||
return f"'sha256-{hash_str}'"
|
||||
|
||||
|
||||
class SecurityMiddleware(MiddlewareMixin):
|
||||
SAFE_TYPES = (
|
||||
# CSP policies are only used for:
|
||||
# - HTML and SVG in top-level contexts
|
||||
# - SVG or JS Workers delivered in embedded contexts
|
||||
# See: https://www.w3.org/TR/CSP2/#which-policy-applies
|
||||
# Therefore, we can save bandwidth on not including our (sometimes huge) policy
|
||||
# on API responses or CSS. We do however include it with other types as a precaution
|
||||
# (whitelist instead of blacklist) and we also do not whitelist JavaScript in
|
||||
# we ever add service workers to not break the protection of this feature:
|
||||
# https://www.w3.org/TR/CSP2/#sandboxing-and-workers
|
||||
'application/json',
|
||||
'text/css',
|
||||
# We used to skip CSP for PDF since it was necessary for inline previews in Safari,
|
||||
# but at the moment it does not seem to be an issue to just send it.
|
||||
)
|
||||
|
||||
def process_request(self, request):
|
||||
request._csp_to_merge = {}
|
||||
|
||||
def process_response(self, request, resp):
|
||||
if settings.DEBUG and resp.status_code >= 400:
|
||||
# Don't use CSP on debug error page as it breaks of Django's fancy error
|
||||
@@ -277,13 +369,22 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
# https://github.com/pretix/pretix/issues/765
|
||||
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
|
||||
|
||||
if not getattr(resp, '_csp_ignore', False):
|
||||
if self._needs_csp(request, resp):
|
||||
resp['Content-Security-Policy'] = _render_csp(self._build_csp(request, resp))
|
||||
elif 'Content-Security-Policy' in resp:
|
||||
del resp['Content-Security-Policy']
|
||||
|
||||
return resp
|
||||
|
||||
def _needs_csp(self, request, resp):
|
||||
if "Content-Type" in resp and resp["Content-Type"].split(";")[0] in self.SAFE_TYPES:
|
||||
return False
|
||||
|
||||
if getattr(resp, '_csp_ignore', False):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _build_csp(self, request, resp):
|
||||
url = resolve(request.path_info)
|
||||
|
||||
@@ -293,7 +394,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'object-src': ["'none'"],
|
||||
'frame-src': ['{static}'],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}"],
|
||||
'connect-src': ["{static}", "{dynamic}", "{media}"],
|
||||
'img-src': ["{static}", "{media}", "data:"],
|
||||
'font-src': ["{static}"],
|
||||
'media-src': ["{static}", "data:"],
|
||||
@@ -316,13 +417,6 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
h['style-src'] += ["'unsafe-inline'"]
|
||||
h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"]
|
||||
|
||||
if hasattr(request, 'csp_nonce'):
|
||||
nonce = f"'nonce-{request.csp_nonce}'"
|
||||
h['script-src'].append(nonce)
|
||||
if not settings.VITE_DEV_MODE:
|
||||
# can't have 'unsafe-inline' and nonce at the same time
|
||||
h['style-src'].append(nonce)
|
||||
|
||||
# Only include pay.google.com for wallet detection purposes on the Payment selection page
|
||||
if (
|
||||
url.url_name == "event.order.pay.change" or
|
||||
@@ -335,6 +429,9 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
if settings.LOG_CSP:
|
||||
h['report-uri'] = ["/csp_report/"]
|
||||
|
||||
if request._csp_to_merge:
|
||||
_merge_csp(h, request._csp_to_merge)
|
||||
|
||||
if 'Content-Security-Policy' in resp:
|
||||
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
|
||||
|
||||
@@ -400,8 +497,16 @@ class RejectInvalidInputMiddleware(MiddlewareMixin):
|
||||
if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']:
|
||||
raise BadRequest("Invalid characters in input.")
|
||||
if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded":
|
||||
if any("\x00" in value for key, value_list in request.POST.lists() for value in value_list):
|
||||
raise BadRequest("Invalid characters in input.")
|
||||
try:
|
||||
post_data = request.POST.lists()
|
||||
except BadRequest:
|
||||
# Reading request.POST wasn't possible, probably an invalid charset. Django will crash once we actually
|
||||
# use request.POST, but if we don't, let's not crash it (required for some weird payment provider
|
||||
# webhooks, e.g. computop).
|
||||
pass
|
||||
else:
|
||||
if any("\x00" in value for key, value_list in post_data for value in value_list):
|
||||
raise BadRequest("Invalid characters in input.")
|
||||
|
||||
|
||||
class CustomCommonMiddleware(CommonMiddleware):
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 4.2.8 on 2024-07-01 09:27
|
||||
import logging
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def clean_duplicate_secrets(apps, schema_editor):
|
||||
# This will autofix all possible duplicate Order.code and OrderPosition.secret values,
|
||||
# unless Order.code is already too long to append something. This would need to be fixed by
|
||||
# sysadmins manually.
|
||||
OrderPosition = apps.get_model("pretixbase", "OrderPosition")
|
||||
Order = apps.get_model("pretixbase", "Order")
|
||||
|
||||
qs = OrderPosition.all.values("secret", "order__event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
|
||||
for row in qs:
|
||||
affected = OrderPosition.all.filter(
|
||||
**{k: v for k, v in row.items() if k != "c"}
|
||||
).order_by("pk")
|
||||
logger.error(f"Found {row['c']} tickets with with the same secret \"{row['secret']}\" in organizer {row['order__event__organizer_id']}, all except one will be changed")
|
||||
for i, a in enumerate(affected):
|
||||
if i > 0:
|
||||
a.secret = a.secret + "__dupl__" + str(a.pk)
|
||||
logger.info(
|
||||
f"Ticket {a.pk} has new secret {a.secret}"
|
||||
)
|
||||
a.save(update_fields=["organizer_id", "secret"])
|
||||
|
||||
qs = Order.objects.values("code", "event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
|
||||
for row in qs:
|
||||
affected = Order.objects.filter(
|
||||
**{k: v for k, v in row.items() if k != "c"}
|
||||
).order_by("pk")
|
||||
logger.error(f"Found {row['c']} orders with with the same code \"{row['code']}\" in organizer {row['event__organizer_id']}, all except one will be changed")
|
||||
for i, a in enumerate(affected):
|
||||
if i > 0:
|
||||
if len(a.code) > 16 - len(str(a.pk)):
|
||||
raise ValueError(f"Cannot auto-fix order with duplicate code {a.code}, order code is too long already")
|
||||
a.code = a.code + str(a.pk).zfill(16 - len(a.code))
|
||||
logger.info(
|
||||
f"Order {a.pk} has new code {a.code}"
|
||||
)
|
||||
a.save(update_fields=["organizer_id", "code"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
(
|
||||
"pretixbase",
|
||||
"0301_reusablemedium_remove_orderposition",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(clean_duplicate_secrets, migrations.RunPython.noop),
|
||||
]
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 4.2.8 on 2024-07-01 09:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
(
|
||||
"pretixbase",
|
||||
"0302_resolve_duplicate_codes_and_secrets",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
"UPDATE pretixbase_order "
|
||||
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_event e WHERE e.id = pretixbase_order.event_id) "
|
||||
"WHERE pretixbase_order.organizer_id IS NULL;",
|
||||
migrations.RunSQL.noop,
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"UPDATE pretixbase_orderposition "
|
||||
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_order o LEFT JOIN pretixbase_event e ON e.id = o.event_id WHERE o.id = pretixbase_orderposition.order_id) "
|
||||
"WHERE pretixbase_orderposition.organizer_id IS NULL;",
|
||||
migrations.RunSQL.noop,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="order",
|
||||
name="organizer",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="orders",
|
||||
to="pretixbase.organizer",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="orderposition",
|
||||
name="organizer",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="order_positions",
|
||||
to="pretixbase.organizer",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-15 20:10
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0303_alter_order_organizer_alter_orderposition_organizer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='tax_rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, default=Decimal('0'), max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoiceline',
|
||||
name='tax_rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, default=Decimal('0'), max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderfee',
|
||||
name='tax_rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='tax_rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='tax_rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='taxrule',
|
||||
name='rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
|
||||
),
|
||||
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-28 11:34
|
||||
import logging
|
||||
|
||||
from django.db import IntegrityError, migrations, transaction
|
||||
from django.db.models import Count, F
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fix_cross_organizer_eventmetavalues(apps, schema_editor):
|
||||
EventMetaProperty = apps.get_model("pretixbase", "EventMetaProperty")
|
||||
EventMetaValue = apps.get_model("pretixbase", "EventMetaValue")
|
||||
|
||||
cross_org_values = EventMetaValue.objects.filter(
|
||||
event__organizer__pk__ne=F('property__organizer__pk')
|
||||
).order_by('event__organizer__slug', 'event__slug')
|
||||
for emv in cross_org_values:
|
||||
logger.warning("%s", f"Fixing cross-organizer EventMetaValue: {emv.event.organizer.slug}/{emv.event.slug}")
|
||||
logger.warning(" %s", f"{emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}")
|
||||
try:
|
||||
emv.property = emv.event.organizer.meta_properties.get(name=emv.property.name)
|
||||
if EventMetaValue.objects.filter(event=emv.event, property=emv.property).exists():
|
||||
correct = EventMetaValue.objects.get(event=emv.event, property=emv.property)
|
||||
if correct.value != emv.value:
|
||||
logger.warning(" %s", f"WARN: conflicting EventMetaValue with property in correct organizer already exists, deleting the cross-organizer one")
|
||||
else:
|
||||
logger.warning(" %s", f"OK: same-value EventMetaValue with property in correct organizer already exists, deleting the cross-organizer one")
|
||||
logger.warning(" %s", f"keeping: {correct.property.name}({correct.property.id}@{correct.property.organizer.slug}) = {repr(correct.value)}")
|
||||
emv.delete()
|
||||
else:
|
||||
logger.warning(" %s", f"OK: found existing EventMetaProperty in {emv.event.organizer.slug}, updating reference")
|
||||
logger.warning(" %s", f"after: {emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}")
|
||||
emv.save(update_fields=["property"])
|
||||
except EventMetaProperty.DoesNotExist:
|
||||
meta_prop = emv.property
|
||||
meta_prop.pk = None
|
||||
meta_prop.organizer = emv.event.organizer
|
||||
meta_prop.filter_public = False
|
||||
meta_prop.save(force_insert=True)
|
||||
logger.warning(" %s", f"WARN: found no matching EventMetaProperty, creating")
|
||||
logger.warning(" %s", f"after: {emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}")
|
||||
emv.save(update_fields=["property"])
|
||||
|
||||
|
||||
def make_eventmetaproperties_unique(apps, schema_editor):
|
||||
EventMetaProperty = apps.get_model("pretixbase", "EventMetaProperty")
|
||||
EventMetaValue = apps.get_model("pretixbase", "EventMetaValue")
|
||||
|
||||
duplicates = EventMetaProperty.objects.values('organizer', 'organizer__slug', 'name').annotate(count=Count('id')).filter(count__gt=1)
|
||||
for dup in duplicates:
|
||||
logger.warning("%s", f"Fixup duplicate property {dup['organizer__slug']} {dup['name']}")
|
||||
props = list(EventMetaProperty.objects.filter(organizer=dup['organizer'], name=dup['name']))
|
||||
|
||||
target = props[0]
|
||||
invalid = props[1:]
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
affected = EventMetaValue.objects.filter(
|
||||
event__organizer=dup['organizer'], property__in=invalid
|
||||
).update(
|
||||
property=target
|
||||
)
|
||||
logger.warning("%s", f" Switching {affected} value(s) over to {target.name}({target.id}@{target.organizer.slug})")
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.warning("%s", f" Failed to switch all value(s) over to {target.name}({target.id}@{target.organizer.slug})")
|
||||
logger.warning("%s", f" {e}")
|
||||
for prop in invalid:
|
||||
newname = f'{prop.name}_DUPLICATE_{prop.id}'
|
||||
logger.warning("%s", f" Renaming {prop.name}({prop.id}@{prop.organizer.slug}) to {newname}({prop.id}@{prop.organizer.slug})")
|
||||
prop.name = newname
|
||||
prop.filter_public = False
|
||||
prop.save()
|
||||
|
||||
else:
|
||||
for prop in invalid:
|
||||
logger.warning("%s", f" Deleting {prop.name}({prop.id}@{prop.organizer.slug})")
|
||||
prop.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0304_tax_rate_decimals"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_cross_organizer_eventmetavalues, migrations.RunPython.noop),
|
||||
migrations.RunPython(make_eventmetaproperties_unique, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-28 11:34
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0305_fixup_eventmetaproperties"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="eventmetaproperty",
|
||||
unique_together={("organizer", "name")},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.16 on 2026-08-05 08:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.database
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0306_alter_eventmetaproperty_unique_together"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DeviceLastSeen",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("last_seen", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"device",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="pretixbase.device",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="devicelastseen",
|
||||
index=pretix.helpers.database.BrinIndexIgnoredOnSQLite(
|
||||
models.F("last_seen"),
|
||||
autosummarize=True,
|
||||
name="pretixbase_device_last_seen",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,175 @@
|
||||
# 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_question=child.id,
|
||||
required=child.required,
|
||||
label=child.question,
|
||||
)
|
||||
else:
|
||||
deps[child.id] = QuestionnaireChild.objects.create(
|
||||
questionnaire=questionnaire,
|
||||
position=position + 1,
|
||||
user_question=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 questions (user-defined and system provided), along with the products for which they're asked
|
||||
questions = event.questions.all()
|
||||
children = sorted(chain((
|
||||
(item, q.position, q.id, q)
|
||||
for q in questions.filter(hidden=False)
|
||||
for item in q.items.values_list('id', 'internal_name', 'name')
|
||||
), (
|
||||
(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')
|
||||
)), key=lambda t: (t[0], t[1], t[2]))
|
||||
|
||||
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], t[2]))
|
||||
|
||||
create_grouped_item_questionnaires(event, children, 'Hidden questions for ', 'PH')
|
||||
|
||||
|
||||
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', '0307_devicelastseen'),
|
||||
]
|
||||
|
||||
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_question', 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_question', 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 remove old columns from Question model
|
||||
]
|
||||
@@ -37,7 +37,7 @@ from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
|
||||
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
|
||||
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
|
||||
QuestionOption, Questionnaire, QuestionnaireChild, Quota, SubEventItem, SubEventItemVariation,
|
||||
itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
|
||||
@@ -20,11 +20,13 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import string
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Max
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
@@ -32,6 +34,7 @@ from pretix.base.models import LoggedModel
|
||||
from pretix.base.permissions import (
|
||||
AnyPermissionOf, assert_valid_event_permission,
|
||||
)
|
||||
from pretix.helpers import BrinIndexIgnoredOnSQLite
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
@@ -287,3 +290,27 @@ class Device(LoggedModel):
|
||||
return self.get_events_with_any_permission()
|
||||
else:
|
||||
return self.organizer.events.none()
|
||||
|
||||
|
||||
class DeviceLastSeen(models.Model):
|
||||
# This is a separate model since we expect it to get A LOT of writes and PostgreSQL always
|
||||
# writes full rows and then needs to update all indexes on the row, so this is going to save a
|
||||
# lot of write traffic on the databse
|
||||
device = models.OneToOneField("Device", on_delete=models.CASCADE, related_name="last_seen")
|
||||
last_seen = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
BrinIndexIgnoredOnSQLite(
|
||||
# BRIN indexes are highly efficient on lots of updates, especially of chronological data
|
||||
# and especially if we later want to query them by range, as we likely want to.
|
||||
"last_seen",
|
||||
name="pretixbase_device_last_seen",
|
||||
autosummarize=True
|
||||
)
|
||||
]
|
||||
|
||||
@property
|
||||
def is_recent(self):
|
||||
# pretixSCAN/pretixPOS sync every 5 minutes, so 7 minutes can be considered "offline"
|
||||
return now() - self.last_seen < timedelta(minutes=7)
|
||||
|
||||
@@ -179,6 +179,12 @@ class EventMixin:
|
||||
self.date_to.astimezone(tz), ("D" if short else "l")
|
||||
)
|
||||
|
||||
def is_same_day(self):
|
||||
if not self.date_to:
|
||||
return True
|
||||
else:
|
||||
return self.date_from.astimezone(self.timezone).date() == self.date_to.astimezone(self.timezone).date()
|
||||
|
||||
def get_date_range_display(self, tz=None, force_show_end=False, as_html=False, try_to_show_times=False) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start date and the end date
|
||||
@@ -232,6 +238,9 @@ class EventMixin:
|
||||
|
||||
@property
|
||||
def timezone(self):
|
||||
# If we get rid of the shim, verify that
|
||||
# https://github.com/py-vobject/vobject/issues/117#issuecomment-5045645314
|
||||
# has been released and included
|
||||
return pytz_deprecation_shim.timezone(self.settings.timezone)
|
||||
|
||||
@property
|
||||
@@ -649,7 +658,7 @@ class Event(EventMixin, LoggedModel):
|
||||
is_remote = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("This event is remote or partially remote."),
|
||||
help_text=_("This will be used to let users know if the event is in a different timezone and let’s us calculate users’ local times."),
|
||||
help_text=_("This will be used to let users know if the event is in a different timezone, and to let us calculate the local time of a user."),
|
||||
)
|
||||
geo_lat = models.FloatField(
|
||||
verbose_name=_("Latitude"),
|
||||
@@ -1403,15 +1412,12 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
for mp in self.organizer.meta_properties.all():
|
||||
if mp.required and not self.meta_data.get(mp.name):
|
||||
issues.append(
|
||||
('<a {a_attr}>' + gettext('You need to fill the meta parameter "{property}".') + '</a>').format(
|
||||
property=mp.name,
|
||||
a_attr='href="%s#id_prop-%d-value"' % (
|
||||
reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
|
||||
mp.pk
|
||||
)
|
||||
)
|
||||
)
|
||||
issues.append(format_html(
|
||||
'<a href="{href}{href_hash}">{text}</a>',
|
||||
text=gettext('You need to fill the meta parameter "{property}".').format(property=mp.name),
|
||||
href=reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
|
||||
href_hash=f'#id_prop-{mp.pk}-value',
|
||||
))
|
||||
|
||||
responses = event_live_issues.send(self)
|
||||
for receiver, response in sorted(responses, key=lambda r: str(r[0])):
|
||||
@@ -1854,6 +1860,7 @@ class EventMetaProperty(LoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ("position", "name",)
|
||||
unique_together = ('organizer', 'name')
|
||||
|
||||
@property
|
||||
def choice_keys(self):
|
||||
|
||||
@@ -49,6 +49,7 @@ from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
from pretix.helpers.countries import FastCountryField
|
||||
from pretix.helpers.models import NormalizedDecimalField
|
||||
|
||||
|
||||
def invoice_filename(instance, filename: str) -> str:
|
||||
@@ -450,7 +451,7 @@ class InvoiceLine(models.Model):
|
||||
description = models.TextField()
|
||||
gross_value = models.DecimalField(max_digits=13, decimal_places=2)
|
||||
tax_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00'))
|
||||
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
|
||||
tax_rate = NormalizedDecimalField(max_digits=7, decimal_places=4, default=Decimal('0'))
|
||||
tax_name = models.CharField(max_length=190)
|
||||
tax_code = models.CharField(max_length=190, null=True, blank=True)
|
||||
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
|
||||
|
||||
+117
-52
@@ -885,26 +885,6 @@ class Item(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
||||
now_dt = now_dt or time_machine_now()
|
||||
subevent_item = subevent and subevent.item_overrides.get(self.pk)
|
||||
if not self.active:
|
||||
return 'active'
|
||||
elif self.available_from and self.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif self.available_until and self.available_until < now_dt:
|
||||
return 'available_until'
|
||||
elif (self.require_voucher or self.hide_without_voucher) and not has_voucher:
|
||||
return 'require_voucher'
|
||||
elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt:
|
||||
return 'available_until'
|
||||
elif self.hidden_if_item_available and self._dependency_available:
|
||||
return 'hidden_if_item_available'
|
||||
else:
|
||||
return None
|
||||
|
||||
def _get_quotas(self, ignored_quotas=None, subevent=None):
|
||||
check_quotas = set(getattr(
|
||||
self, '_subevent_quotas', # Utilize cache in product list
|
||||
@@ -1413,22 +1393,6 @@ class ItemVariation(models.Model):
|
||||
return False
|
||||
return True
|
||||
|
||||
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
||||
now_dt = now_dt or time_machine_now()
|
||||
subevent_var = subevent and subevent.var_overrides.get(self.pk)
|
||||
if not self.active:
|
||||
return 'active'
|
||||
elif self.available_from and self.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif self.available_until and self.available_until < now_dt:
|
||||
return 'available_until'
|
||||
elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt:
|
||||
return 'available_until'
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def meta_data(self):
|
||||
data = self.item.meta_data
|
||||
@@ -1605,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``)
|
||||
@@ -1677,7 +1643,7 @@ class Question(LoggedModel):
|
||||
related_name="questions",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
question = I18nTextField(
|
||||
question = I18nTextField( # to be renamed to 'internal_name'
|
||||
verbose_name=_("Question")
|
||||
)
|
||||
identifier = models.CharField(
|
||||
@@ -1692,7 +1658,7 @@ class Question(LoggedModel):
|
||||
),
|
||||
],
|
||||
)
|
||||
help_text = I18nTextField(
|
||||
help_text = I18nTextField( # 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,
|
||||
@@ -1702,22 +1668,22 @@ class Question(LoggedModel):
|
||||
choices=TYPE_CHOICES,
|
||||
verbose_name=_("Question type")
|
||||
)
|
||||
required = models.BooleanField(
|
||||
required = models.BooleanField( # to be removed, -> QuestionnaireChild
|
||||
default=False,
|
||||
verbose_name=_("Required question")
|
||||
)
|
||||
items = models.ManyToManyField(
|
||||
items = models.ManyToManyField( # 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( # to be removed, -> Questionnaire + QuestionnaireChild
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
ask_during_checkin = models.BooleanField(
|
||||
ask_during_checkin = models.BooleanField( # 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
|
||||
@@ -1727,7 +1693,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
|
||||
@@ -1736,10 +1702,10 @@ class Question(LoggedModel):
|
||||
verbose_name=_('Print answer on invoices'),
|
||||
default=False
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
dependency_question = models.ForeignKey( # 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=[]) # 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'))
|
||||
@@ -1773,9 +1739,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):
|
||||
@@ -2000,6 +1966,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_question = models.ForeignKey(
|
||||
Question,
|
||||
related_name="references",
|
||||
on_delete=models.CASCADE,
|
||||
null=True, blank=True,
|
||||
)
|
||||
system_question = 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
|
||||
|
||||
@@ -87,6 +87,7 @@ from pretix.base.timemachine import time_machine_now
|
||||
|
||||
from ...helpers import OF_SELF
|
||||
from ...helpers.countries import CachedCountries, FastCountryField
|
||||
from ...helpers.models import NormalizedDecimalField
|
||||
from ...helpers.names import build_name
|
||||
from ...testutils.middleware import debugflags_var
|
||||
from ._transactions import (
|
||||
@@ -224,8 +225,6 @@ class Order(LockModel, LoggedModel):
|
||||
"Organizer",
|
||||
related_name="orders",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
@@ -329,7 +328,7 @@ class Order(LockModel, LoggedModel):
|
||||
default="line",
|
||||
)
|
||||
|
||||
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
|
||||
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Order")
|
||||
@@ -509,20 +508,20 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
@classmethod
|
||||
def annotate_overpayments(cls, qs, results=True, refunds=True, sums=False):
|
||||
payment_sum = OrderPayment.objects.filter(
|
||||
payment_sum = OrderPayment.objects.with_scopes_disabled().filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(s=Sum('amount')).values('s')
|
||||
refund_sum = OrderRefund.objects.filter(
|
||||
refund_sum = OrderRefund.objects.with_scopes_disabled().filter(
|
||||
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
|
||||
OrderRefund.REFUND_STATE_CREATED),
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(s=Sum('amount')).values('s')
|
||||
external_refund = OrderRefund.objects.filter(
|
||||
external_refund = OrderRefund.objects.with_scopes_disabled().filter(
|
||||
state=OrderRefund.REFUND_STATE_EXTERNAL,
|
||||
order=OuterRef('pk')
|
||||
)
|
||||
pending_refund = OrderRefund.objects.filter(
|
||||
pending_refund = OrderRefund.objects.with_scopes_disabled().filter(
|
||||
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
|
||||
order=OuterRef('pk')
|
||||
)
|
||||
@@ -1448,6 +1447,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.')
|
||||
@@ -1592,53 +1597,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_question_id and qc.user_question_id in self.answer_cache:
|
||||
qc.answer = self.answer_cache[qc.user_question_id]
|
||||
#qc.answer.question = qc # cache object
|
||||
elif qc.system_question:
|
||||
qc.answer = self.get_system_answer(qc.system_question)
|
||||
#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_question_id:
|
||||
if qc.user_question_id not in self.answer_cache:
|
||||
return None
|
||||
answer = self.answer_cache[qc.user_question_id]
|
||||
return answer.to_dependency_values()
|
||||
elif qc.system_question:
|
||||
return [self.get_system_answer(qc.system_question)]
|
||||
else:
|
||||
raise ValueError('Questionnaire child without question has no answer')
|
||||
|
||||
def get_system_answer(self, system_question_name):
|
||||
if system_question_name == 'attendee_name_parts':
|
||||
return self.attendee_name_parts
|
||||
elif system_question_name == 'attendee_email':
|
||||
return self.attendee_email
|
||||
elif system_question_name == 'street':
|
||||
return self.street
|
||||
elif system_question_name == 'zipcode':
|
||||
return self.zipcode
|
||||
elif system_question_name == 'city':
|
||||
return self.city
|
||||
elif system_question_name == 'state':
|
||||
return self.state
|
||||
elif system_question_name == 'country':
|
||||
return self.country
|
||||
else:
|
||||
raise ValueError('Unknown system question name')
|
||||
|
||||
@property
|
||||
def net_price(self):
|
||||
@@ -2073,6 +2105,17 @@ class OrderPayment(models.Model):
|
||||
"""
|
||||
return '{}-P-{}'.format(self.order.code, self.local_id)
|
||||
|
||||
@property
|
||||
def global_id(self):
|
||||
"""
|
||||
The global ID of this payment, constructed by the organizer slug, event slug, and the full id.
|
||||
"""
|
||||
return "{organizer}-{event}-{full_id}".format(
|
||||
organizer=self.order.organizer.slug.upper(),
|
||||
event=self.order.event.slug.upper(),
|
||||
full_id=self.full_id,
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.local_id:
|
||||
self.local_id = (self.order.payments.aggregate(m=Max('local_id'))['m'] or 0) + 1
|
||||
@@ -2273,6 +2316,17 @@ class OrderRefund(models.Model):
|
||||
"""
|
||||
return '{}-R-{}'.format(self.order.code, self.local_id)
|
||||
|
||||
@property
|
||||
def global_id(self):
|
||||
"""
|
||||
The global ID of this refund, constructed by the organizer slug, event slug, and the full id.
|
||||
"""
|
||||
return "{organizer}-{event}-{full_id}".format(
|
||||
organizer=self.order.organizer.slug.upper(),
|
||||
event=self.order.event.slug.upper(),
|
||||
full_id=self.full_id,
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.local_id:
|
||||
self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1
|
||||
@@ -2287,9 +2341,12 @@ class OrderRefund(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ActivePositionManager(ScopedManager(organizer='order__event__organizer').__class__):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(canceled=False)
|
||||
def ActivePositionManager(**scope):
|
||||
class InnerClass(ScopedManager(**scope).__class__):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(canceled=False)
|
||||
|
||||
return InnerClass()
|
||||
|
||||
|
||||
class OrderFee(RoundingCorrectionMixin, models.Model):
|
||||
@@ -2356,8 +2413,8 @@ class OrderFee(RoundingCorrectionMixin, models.Model):
|
||||
)
|
||||
description = models.CharField(max_length=190, blank=True)
|
||||
internal_type = models.CharField(max_length=255, blank=True)
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=7, decimal_places=2,
|
||||
tax_rate = NormalizedDecimalField(
|
||||
max_digits=7, decimal_places=4,
|
||||
verbose_name=_('Tax rate')
|
||||
)
|
||||
tax_rule = models.ForeignKey(
|
||||
@@ -2379,7 +2436,7 @@ class OrderFee(RoundingCorrectionMixin, models.Model):
|
||||
canceled = models.BooleanField(default=False)
|
||||
|
||||
all = ScopedManager(organizer='order__event__organizer')
|
||||
objects = ActivePositionManager()
|
||||
objects = ActivePositionManager(organizer='order__event__organizer')
|
||||
|
||||
@property
|
||||
def net_value(self):
|
||||
@@ -2541,8 +2598,6 @@ class OrderPosition(AbstractPosition):
|
||||
"Organizer",
|
||||
related_name="order_positions",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
@@ -2555,8 +2610,8 @@ class OrderPosition(AbstractPosition):
|
||||
max_digits=13, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=7, decimal_places=2,
|
||||
tax_rate = NormalizedDecimalField(
|
||||
max_digits=7, decimal_places=4,
|
||||
verbose_name=_('Tax rate')
|
||||
)
|
||||
tax_rule = models.ForeignKey(
|
||||
@@ -2599,8 +2654,8 @@ class OrderPosition(AbstractPosition):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
all = ScopedManager(organizer='order__event__organizer')
|
||||
objects = ActivePositionManager()
|
||||
all = ScopedManager(organizer='organizer')
|
||||
objects = ActivePositionManager(organizer='organizer')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -3074,8 +3129,8 @@ class Transaction(models.Model):
|
||||
price_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=7, decimal_places=2,
|
||||
tax_rate = NormalizedDecimalField(
|
||||
max_digits=7, decimal_places=4,
|
||||
verbose_name=_('Tax rate')
|
||||
)
|
||||
tax_rule = models.ForeignKey(
|
||||
@@ -3190,8 +3245,8 @@ class CartPosition(AbstractPosition):
|
||||
verbose_name=_("Limit for extending expiration date"),
|
||||
null=True
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=7, decimal_places=2, default=Decimal('0.00'),
|
||||
tax_rate = NormalizedDecimalField(
|
||||
max_digits=7, decimal_places=4, default=Decimal('0'),
|
||||
verbose_name=_('Tax rate')
|
||||
)
|
||||
tax_code = models.CharField(
|
||||
|
||||
@@ -40,6 +40,7 @@ from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.helpers.countries import FastCountryField
|
||||
from pretix.helpers.models import NormalizedDecimalField
|
||||
|
||||
|
||||
class TaxedPrice:
|
||||
@@ -335,9 +336,9 @@ class TaxRule(LoggedModel):
|
||||
max_length=190,
|
||||
choices=TAX_CODE_LISTS,
|
||||
)
|
||||
rate = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
rate = NormalizedDecimalField(
|
||||
max_digits=7,
|
||||
decimal_places=4,
|
||||
validators=[
|
||||
MaxValueValidator(
|
||||
limit_value=Decimal("100.00"),
|
||||
|
||||
@@ -32,8 +32,10 @@
|
||||
# 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 datetime
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -421,27 +423,33 @@ class Voucher(LoggedModel):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def clean_quota_get_ignored(old_instance):
|
||||
quotas = set()
|
||||
was_valid = old_instance and (
|
||||
old_instance.valid_until is None or old_instance.valid_until >= now()
|
||||
)
|
||||
if old_instance and old_instance.block_quota and was_valid:
|
||||
if old_instance.quota:
|
||||
quotas.add(old_instance.quota)
|
||||
elif old_instance.variation:
|
||||
quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
|
||||
elif old_instance.item:
|
||||
if old_instance.item.has_variations:
|
||||
quotas |= set(
|
||||
Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
|
||||
itemvariation__item=old_instance.item,
|
||||
quota__subevent=old_instance.subevent,
|
||||
).values('quota_id'))
|
||||
)
|
||||
else:
|
||||
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
|
||||
return quotas
|
||||
def get_affected_quotas(quota, item, variation, subevent):
|
||||
if quota:
|
||||
return {quota}
|
||||
elif item and variation:
|
||||
return set(variation.quotas.filter(subevent=subevent))
|
||||
elif item and not item.has_variations:
|
||||
return set(item.quotas.filter(subevent=subevent))
|
||||
elif item and item.has_variations:
|
||||
return set(
|
||||
Quota.objects.filter(
|
||||
pk__in=Quota.variations.through.objects.filter(
|
||||
itemvariation__item=item,
|
||||
quota__subevent=subevent,
|
||||
).values('quota_id')
|
||||
)
|
||||
)
|
||||
else:
|
||||
return set()
|
||||
|
||||
@staticmethod
|
||||
def clean_quota_get_ignored(voucher_data: Union["VoucherBulkData", "Voucher"]):
|
||||
if voucher_data:
|
||||
valid = voucher_data.valid_until is None or voucher_data.valid_until >= now()
|
||||
if valid and voucher_data.block_quota and voucher_data.max_usages > voucher_data.redeemed:
|
||||
return Voucher.get_affected_quotas(voucher_data.quota, voucher_data.item, voucher_data.variation, voucher_data.subevent)
|
||||
|
||||
return set()
|
||||
|
||||
@staticmethod
|
||||
def clean_quota_check(data, cnt, old_instance, event, quota, item, variation):
|
||||
@@ -453,22 +461,8 @@ class Voucher(LoggedModel):
|
||||
if event.has_subevents and data.get('block_quota') and not data.get('subevent'):
|
||||
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
|
||||
|
||||
if quota:
|
||||
new_quotas = {quota}
|
||||
elif item and variation:
|
||||
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
|
||||
elif item and not item.has_variations:
|
||||
new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
|
||||
elif item and item.has_variations:
|
||||
new_quotas = set(
|
||||
Quota.objects.filter(
|
||||
pk__in=Quota.variations.through.objects.filter(
|
||||
itemvariation__item=item,
|
||||
quota__subevent=data.get('subevent'),
|
||||
).values('quota_id')
|
||||
)
|
||||
)
|
||||
else:
|
||||
new_quotas = Voucher.get_affected_quotas(quota, item, variation, data.get('subevent'))
|
||||
if not new_quotas:
|
||||
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
|
||||
'tickets.'))
|
||||
|
||||
@@ -644,3 +638,16 @@ class Voucher(LoggedModel):
|
||||
]
|
||||
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
|
||||
return ops
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoucherBulkData:
|
||||
item: object
|
||||
variation: object
|
||||
quota: object
|
||||
block_quota: bool
|
||||
valid_until: datetime.datetime
|
||||
subevent: object
|
||||
redeemed: int
|
||||
max_usages: int
|
||||
allow_ignore_quota: bool
|
||||
|
||||
@@ -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__)
|
||||
@@ -936,7 +936,7 @@ class BasePaymentProvider:
|
||||
"""
|
||||
Will be called if the *event administrator* views the details of a payment.
|
||||
|
||||
It should return HTML code containing information regarding the current payment
|
||||
It should return a SafeString containing HTML code, with information regarding the current payment
|
||||
status and, if applicable, next steps.
|
||||
|
||||
The default implementation returns an empty string.
|
||||
@@ -961,7 +961,7 @@ class BasePaymentProvider:
|
||||
"""
|
||||
Will be called if the *event administrator* views the details of a refund.
|
||||
|
||||
It should return HTML code containing information regarding the current refund
|
||||
It should return a SafeString containing HTML code, with information regarding the current refund
|
||||
status and, if applicable, next steps.
|
||||
|
||||
The default implementation returns an empty string.
|
||||
@@ -1148,7 +1148,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,
|
||||
@@ -1416,7 +1416,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 = []
|
||||
@@ -1706,6 +1706,58 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
)
|
||||
|
||||
|
||||
class BaseHistoricalPaymentProvider(BasePaymentProvider):
|
||||
"""
|
||||
Base class for payment providers that no longer exist but can't be deleted to make sure historical
|
||||
payments are shown correctly.
|
||||
|
||||
Subclasses are recommended to only implement:
|
||||
- identifier
|
||||
- verbose_name
|
||||
- public_name
|
||||
- payment_control_render
|
||||
- payment_control_render_short
|
||||
- refund_control_render
|
||||
- refund_control_render_short
|
||||
- render_invoice_text
|
||||
- render_invoice_stamp
|
||||
- api_payment_details
|
||||
- api_refund_details
|
||||
- shred_payment_info
|
||||
- matching_id
|
||||
- refund_matching_id
|
||||
"""
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def settings_form_fields(self) -> dict:
|
||||
return {}
|
||||
|
||||
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
|
||||
return False
|
||||
|
||||
def payment_is_valid_session(self, request: HttpRequest, payment: OrderPayment):
|
||||
return False
|
||||
|
||||
def order_change_allowed(self, order: Order, request: HttpRequest=None) -> bool:
|
||||
return False
|
||||
|
||||
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
return False
|
||||
|
||||
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
return False
|
||||
|
||||
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
|
||||
raise PaymentException(_("This payment provider exists for historical purposes only and is no longer usable."))
|
||||
|
||||
def execute_refund(self, refund: OrderRefund):
|
||||
raise PaymentException(_("This payment provider exists for historical purposes only and is no longer usable."))
|
||||
|
||||
|
||||
@receiver(register_payment_providers, dispatch_uid="payment_free")
|
||||
def register_payment_provider(sender, **kwargs):
|
||||
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment, GiftCardPayment]
|
||||
|
||||
@@ -245,6 +245,9 @@ def recv_classic(sender, **kwargs):
|
||||
|
||||
|
||||
def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_used=False, force_invalidate=False, save=True):
|
||||
if position.pk and position.issued_gift_cards.exists():
|
||||
return
|
||||
|
||||
gen = event.ticket_secret_generator
|
||||
if gen.use_revocation_list and force_invalidate_if_revokation_list_used:
|
||||
force_invalidate = True
|
||||
|
||||
@@ -53,6 +53,7 @@ from django.utils.translation import (
|
||||
)
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
@@ -916,6 +917,8 @@ class CartManager:
|
||||
if custom_price > 99_999_999_999:
|
||||
raise CartError(error_messages['price_too_high'])
|
||||
|
||||
custom_price = round_decimal(custom_price, currency=self.event.currency)
|
||||
|
||||
op = self.AddOperation(
|
||||
count=i['count'],
|
||||
item=item,
|
||||
@@ -1038,6 +1041,8 @@ class CartManager:
|
||||
if custom_price > 99_999_999_999:
|
||||
raise CartError(error_messages['price_too_high'])
|
||||
|
||||
custom_price = round_decimal(custom_price, currency=self.event.currency)
|
||||
|
||||
# Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky)
|
||||
for ca in current_addons[cp][a['item'], a['variation']]:
|
||||
if ca.listed_price != listed_price:
|
||||
|
||||
@@ -40,7 +40,7 @@ import dateutil
|
||||
import dateutil.parser
|
||||
from dateutil.tz import datetime_exists
|
||||
from django.core.files import File
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import (
|
||||
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
|
||||
OuterRef, Q, Subquery, TextField, Value, When,
|
||||
@@ -59,6 +59,7 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.signals import checkin_created, periodic_task
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.database import conditional_atomic
|
||||
from pretix.helpers.jsonlogic import Logic
|
||||
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
|
||||
from pretix.helpers.jsonlogic_query import (
|
||||
@@ -1043,10 +1044,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
if not simulate:
|
||||
_save_answers(op, answers, given_answers)
|
||||
|
||||
with transaction.atomic():
|
||||
with conditional_atomic(not simulate):
|
||||
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
|
||||
opqs = OrderPosition.all.select_related("order", "item")
|
||||
if type != Checkin.TYPE_EXIT:
|
||||
if type != Checkin.TYPE_EXIT and not simulate:
|
||||
opqs = opqs.select_for_update(of=OF_SELF)
|
||||
op = opqs.get(pk=op.pk)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ from typing import List
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from pretix.base.models import CartPosition, ItemCategory, SalesChannel
|
||||
from pretix.presale.views.event import get_grouped_items
|
||||
from pretix.presale.productlist import prepare_item_list_for_shop
|
||||
|
||||
|
||||
class DummyCategory:
|
||||
@@ -162,7 +162,7 @@ class CrossSellingService:
|
||||
]
|
||||
|
||||
def _prepare_items(self, subevent, items_qs, discount_info):
|
||||
items, _btn = get_grouped_items(
|
||||
items, _btn = prepare_item_list_for_shop(
|
||||
self.event,
|
||||
subevent=subevent,
|
||||
voucher=None,
|
||||
|
||||
@@ -24,6 +24,7 @@ from typing import List, Optional
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -96,6 +97,8 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
||||
:param valid_from_not_chosen: Set to ``True`` to indicate that the customer is in an early step of the checkout flow
|
||||
where the valid_from date is not selected yet. In this case, the valid_from date is not checked.
|
||||
"""
|
||||
if lock and not transaction.get_connection().in_atomic_block:
|
||||
raise Exception('validate_memberships_in_order(lock=True) should only be called in atomic transaction!')
|
||||
tz = event.timezone
|
||||
applicable_positions = [
|
||||
p for p in positions
|
||||
|
||||
@@ -110,6 +110,7 @@ from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.models import modelcopy
|
||||
from pretix.helpers.periodic import minimum_interval
|
||||
from pretix.presale.productlist import prepare_item_list_for_shop
|
||||
from pretix.testutils.middleware import debugflags_var
|
||||
|
||||
|
||||
@@ -790,6 +791,12 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
||||
[op.seat for op in sorted_positions if op.seat],
|
||||
shared_lock_objects=[event]
|
||||
)
|
||||
elif any(cp.voucher and cp.voucher.budget for cp in sorted_positions):
|
||||
# Voucher budgets are not guaranteed by the cart manager
|
||||
lock_objects(
|
||||
[op.voucher for op in sorted_positions if op.voucher and op.voucher.budget],
|
||||
shared_lock_objects=[event]
|
||||
)
|
||||
|
||||
q_avail = Counter()
|
||||
v_avail = Counter()
|
||||
@@ -1599,6 +1606,7 @@ class OrderChangeManager:
|
||||
'seat_forbidden': gettext_lazy('The selected product does not allow to select a seat.'),
|
||||
'tax_rule_country_blocked': gettext_lazy('The selected country is blocked by your tax rule.'),
|
||||
'gift_card_change': gettext_lazy('You cannot change the price of a position that has been used to issue a gift card.'),
|
||||
'gift_card_secret': gettext_lazy('You cannot change the ticket secret of a position that has been used to issue a gift card.'),
|
||||
'max_items_per_product': ngettext_lazy(
|
||||
"You cannot select more than %(max)s item of the product %(product)s.",
|
||||
"You cannot select more than %(max)s items of the product %(product)s.",
|
||||
@@ -1756,6 +1764,9 @@ class OrderChangeManager:
|
||||
self._operations.append(self.RegenerateSecretOperation(position))
|
||||
|
||||
def change_ticket_secret(self, position: OrderPosition, new_secret: str):
|
||||
if position.issued_gift_cards.exists():
|
||||
raise OrderError(self.error_messages['gift_card_secret'])
|
||||
|
||||
self._operations.append(self.ChangeSecretOperation(position, new_secret))
|
||||
|
||||
def change_valid_from(self, position: OrderPosition, new_value: datetime):
|
||||
@@ -1943,13 +1954,18 @@ class OrderChangeManager:
|
||||
|
||||
:param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values),
|
||||
``"count"``, and ``"price"``.
|
||||
:param limit_main_positions: By default, the method works on all methods of the order. If you set this to a
|
||||
:param limit_main_positions: By default, the method works on all positions of the order. If you set this to a
|
||||
queryset or a list of positions, all other positions and their add-ons will be kept
|
||||
untouched.
|
||||
"""
|
||||
if self._operations:
|
||||
raise ValueError("Setting addons should be the first/only operation")
|
||||
|
||||
def _allowed_on_order_sales_channel(item_or_var, order):
|
||||
return item_or_var.all_sales_channels or (
|
||||
order.sales_channel.identifier in (s.identifier for s in item_or_var.limit_sales_channels.all())
|
||||
)
|
||||
|
||||
# Prepare containers for min/max check of products
|
||||
item_counts = Counter()
|
||||
for p in self.order.positions.all():
|
||||
@@ -2043,13 +2059,11 @@ class OrderChangeManager:
|
||||
if not item.is_available() or (variation and not variation.is_available()):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
|
||||
if not item.all_sales_channels:
|
||||
if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
if not _allowed_on_order_sales_channel(item, self.order):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
|
||||
if variation and not variation.all_sales_channels:
|
||||
if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
if variation and not _allowed_on_order_sales_channel(variation, self.order):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
|
||||
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available():
|
||||
raise OrderError(error_messages['not_for_sale'])
|
||||
@@ -2097,6 +2111,36 @@ class OrderChangeManager:
|
||||
)
|
||||
item_counts[item] += 1
|
||||
|
||||
def _addon_is_available(a):
|
||||
# If an item is no longer available due to time, it should usually also be no longer
|
||||
# user-removable, because e.g. the stock has already been ordered.
|
||||
# We always set voucher=None because that's what's done when generating the form in
|
||||
# OrderChangeMixin (vouchers for addons are not supported).
|
||||
# This also prevents accidental removal through the UI because a hidden product will no longer
|
||||
# be part of the input.
|
||||
if not _allowed_on_order_sales_channel(a.item, self.order) or (
|
||||
a.variation and not _allowed_on_order_sales_channel(a.variation, self.order)
|
||||
):
|
||||
return False
|
||||
|
||||
items, _ = prepare_item_list_for_shop(
|
||||
self.order.event,
|
||||
channel=self.order.sales_channel,
|
||||
subevent=a.subevent,
|
||||
voucher=None,
|
||||
base_qs=Item.objects.filter(pk=a.item.pk),
|
||||
allow_addons=True
|
||||
)
|
||||
if (not items) or items[0].current_unavailability_reason:
|
||||
return False
|
||||
|
||||
if a.variation:
|
||||
variations = [var for var in items[0].available_variations if var.pk == a.variation.pk]
|
||||
if (not variations) or variations[0].current_unavailability_reason:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# Detect removed add-ons and create RemoveOperations
|
||||
for cp, al in list(current_addons.items()):
|
||||
for k, v in al.items():
|
||||
@@ -2106,22 +2150,7 @@ class OrderChangeManager:
|
||||
for a in current_addons[cp][k][:current_num - input_num]:
|
||||
if a.canceled:
|
||||
continue
|
||||
is_unavailable = (
|
||||
# If an item is no longer available due to time, it should usually also be no longer
|
||||
# user-removable, because e.g. the stock has already been ordered.
|
||||
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
|
||||
# not mean it should be unremovable for others.
|
||||
# This also prevents accidental removal through the UI because a hidden product will no longer
|
||||
# be part of the input.
|
||||
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
|
||||
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
|
||||
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
|
||||
or (
|
||||
not a.item.all_sales_channels and
|
||||
not a.item.limit_sales_channels.contains(self.order.sales_channel)
|
||||
)
|
||||
)
|
||||
if is_unavailable:
|
||||
if not _addon_is_available(a):
|
||||
# "Re-select" add-on
|
||||
selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1
|
||||
continue
|
||||
|
||||
@@ -801,11 +801,10 @@ def get_available_placeholders(event, base_parameters, rich=False):
|
||||
return params
|
||||
|
||||
|
||||
def get_sample_context(event, context_parameters, rich=True):
|
||||
def prepare_sample_context_for_preview(placeholder_to_sample):
|
||||
context_dict = {}
|
||||
lbl = _('This value will be replaced based on dynamic parameters.')
|
||||
for k, v in get_available_placeholders(event, context_parameters, rich=rich).items():
|
||||
sample = v.render_sample(event)
|
||||
for k, sample in placeholder_to_sample.items():
|
||||
if isinstance(sample, PlainHtmlAlternativeString):
|
||||
context_dict[k] = PlainHtmlAlternativeString(
|
||||
'<{el} class="placeholder" title="{title}">{plain}</{el}>'.format(
|
||||
@@ -830,3 +829,12 @@ def get_sample_context(event, context_parameters, rich=True):
|
||||
escape(sample)
|
||||
))
|
||||
return context_dict
|
||||
|
||||
|
||||
def get_sample_context(event, context_parameters, rich=True):
|
||||
return prepare_sample_context_for_preview(
|
||||
{
|
||||
k: v.render_sample(event)
|
||||
for k, v in get_available_placeholders(event, context_parameters, rich=rich).items()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -535,8 +535,9 @@ EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
|
||||
event_live_issues = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to determine whether an event can be taken live. If you want to
|
||||
prevent the event from going live, return a string that will be displayed to the user
|
||||
as the error message. If you don't, your receiver should return ``None``.
|
||||
prevent the event from going live, return an error message to display to the user (either
|
||||
as a SafeString containing HTML, or a string that will be HTML-escaped). If you don't,
|
||||
your receiver should return ``None``.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import importlib
|
||||
|
||||
from django import template
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from pretix.base.models import Event
|
||||
@@ -44,7 +45,7 @@ def eventsignal(event: Event, signame: str, **kwargs):
|
||||
_html = []
|
||||
for receiver, response in signal.send(event, **kwargs):
|
||||
if response:
|
||||
_html.append(response)
|
||||
_html.append(conditional_escape(response))
|
||||
return mark_safe("".join(_html))
|
||||
|
||||
|
||||
@@ -63,5 +64,5 @@ def signal(signame: str, request, **kwargs):
|
||||
_html = []
|
||||
for receiver, response in signal.send(request, **kwargs):
|
||||
if response:
|
||||
_html.append(response)
|
||||
_html.append(conditional_escape(response))
|
||||
return mark_safe("".join(_html))
|
||||
|
||||
@@ -26,6 +26,8 @@ from babel.numbers import format_currency
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.utils import formats
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from pretix.base.i18n import get_babel_locale
|
||||
|
||||
@@ -82,3 +84,19 @@ def money_numberfield_filter(value: Decimal, arg=''):
|
||||
|
||||
places = settings.CURRENCY_PLACES.get(arg, 2)
|
||||
return str(value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP))
|
||||
|
||||
|
||||
@register.filter(is_safe=True)
|
||||
def tax_rate_format(number):
|
||||
"""
|
||||
Display a Decimal to its significant decimal places, used for tax rates.
|
||||
"""
|
||||
assert isinstance(number, Decimal)
|
||||
return mark_safe(
|
||||
formats.number_format(
|
||||
number.normalize(),
|
||||
-number.as_tuple().exponent,
|
||||
use_l10n=True,
|
||||
force_grouping=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -19,12 +19,10 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
import secrets
|
||||
from urllib.parse import urljoin
|
||||
from urllib.request import urlopen
|
||||
|
||||
@@ -33,6 +31,10 @@ from django import template
|
||||
from django.conf import settings
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from pretix.base.middleware import (
|
||||
add_to_response_csp_via_request, calculate_csp_hash,
|
||||
)
|
||||
|
||||
register = template.Library()
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
_MANIFEST = {}
|
||||
@@ -234,10 +236,16 @@ def vite_importmap(context):
|
||||
if not imports:
|
||||
return ""
|
||||
|
||||
# Generate a nonce and store it on the request so the CSP middleware can allow it
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
json_string = json.dumps({"imports": imports})
|
||||
|
||||
# Calculate hash and add it to the CSP info in the request, so that will be merged into the
|
||||
# response header later on by the middleware
|
||||
request = context.get('request')
|
||||
if request:
|
||||
request.csp_nonce = nonce
|
||||
csp_hash = calculate_csp_hash(json_string)
|
||||
add_to_response_csp_via_request(request, {
|
||||
'style-src': [csp_hash],
|
||||
'script-src': [csp_hash],
|
||||
})
|
||||
|
||||
return f'<script type="importmap" nonce="{nonce}">{json.dumps({"imports": imports})}</script>'
|
||||
return f'<script type="importmap">{json_string}</script>'
|
||||
|
||||
@@ -27,11 +27,11 @@ 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 django.views.decorators.csrf import requires_csrf_token
|
||||
from sentry_sdk import last_event_id
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.middleware import get_language_from_request
|
||||
from pretix.multidomain.middlewares import requires_csrf_token
|
||||
|
||||
|
||||
def csrf_failure(request, reason=""):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ from pretix.base.forms.questions import (
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
|
||||
QuestionOption,
|
||||
QuestionOption, QuestionnaireChild,
|
||||
)
|
||||
from pretix.base.models.customers import AttendeeProfile
|
||||
from pretix.presale.signals import contact_form_fields_overrides
|
||||
@@ -56,6 +56,20 @@ class BaseQuestionsViewMixin:
|
||||
def question_form_kwargs(self, cr):
|
||||
return {}
|
||||
|
||||
@cached_property
|
||||
def order_questions_form(self):
|
||||
kwargs = {} # self.question_form_kwargs(cr)
|
||||
form = self.form_class(event=self.request.event,
|
||||
prefix='order',
|
||||
request=self.request,
|
||||
cartpos=None,
|
||||
orderpos=None,
|
||||
all_optional=self.all_optional,
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
files=(self.request.FILES if self.request.method == 'POST' else None),
|
||||
**kwargs)
|
||||
return form
|
||||
|
||||
@cached_property
|
||||
def forms(self):
|
||||
"""
|
||||
@@ -307,27 +321,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)
|
||||
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_question', 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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ def on_task_prerun(sender, task_id, task, **kwargs):
|
||||
from pretix.helpers.logs import local
|
||||
|
||||
local.request_id = task_id
|
||||
if "X-Pretix-Trace" in task.request.headers:
|
||||
if task.request.headers and "X-Pretix-Trace" in task.request.headers:
|
||||
local.trace = task.request.headers["X-Pretix-Trace"].split(" ")
|
||||
else:
|
||||
local.trace = []
|
||||
|
||||
@@ -197,10 +197,10 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
'presale_end': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'date_from': SplitDateTimePickerWidget(),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-date_from_0'}),
|
||||
'presale_start': SplitDateTimePickerWidget(),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-presale_start_0'}),
|
||||
'date_from': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-date_from_0'}, without_seconds=True),
|
||||
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-presale_start_0'}, without_seconds=True),
|
||||
'slug': SlugWidget,
|
||||
}
|
||||
|
||||
@@ -521,11 +521,11 @@ class EventUpdateForm(I18nModelForm):
|
||||
'limit_sales_channels': SafeModelMultipleChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'date_from': SplitDateTimePickerWidget(),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
|
||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
|
||||
'presale_start': SplitDateTimePickerWidget(),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
|
||||
'date_from': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
|
||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}, without_seconds=True),
|
||||
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}, without_seconds=True),
|
||||
}
|
||||
|
||||
|
||||
@@ -1905,12 +1905,6 @@ class QuickSetupForm(I18nForm):
|
||||
required=False,
|
||||
help_text=_("We'll show this publicly to allow attendees to contact you.")
|
||||
)
|
||||
contact_url = forms.URLField(
|
||||
label=_("Contact URL"),
|
||||
required=False,
|
||||
help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
|
||||
"Please note that you still need to add a contact email address that will be shared with all emails you send.")
|
||||
)
|
||||
total_quota = forms.IntegerField(
|
||||
label=_("Total capacity"),
|
||||
min_value=0,
|
||||
|
||||
@@ -39,6 +39,7 @@ from urllib.parse import urlencode
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import (
|
||||
Count, Exists, F, Max, Model, OrderBy, OuterRef, Q, QuerySet,
|
||||
)
|
||||
@@ -59,7 +60,7 @@ from pretix.base.models import (
|
||||
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
|
||||
OrderRefund, Organizer, OutgoingMail, Question, QuestionAnswer, Quota,
|
||||
SalesChannel, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite,
|
||||
Voucher,
|
||||
User, Voucher,
|
||||
)
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.base.timeframes import (
|
||||
@@ -770,7 +771,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
||||
)
|
||||
elif q.type == Question.TYPE_TIME:
|
||||
self.fields[fname] = forms.TimeField(
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
widget=TimePickerWidget(without_seconds=True),
|
||||
help_text=_('Exact matches only'),
|
||||
**kwargs,
|
||||
)
|
||||
@@ -3007,3 +3008,91 @@ class OutgoingMailFilterForm(FilterForm):
|
||||
qs = qs.order_by("-created", "-pk")
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class LogFilterForm(FilterForm):
|
||||
source = forms.ChoiceField(
|
||||
label=_('Source'),
|
||||
choices=[
|
||||
('', _('All sources')),
|
||||
('team', _('Team actions')),
|
||||
('customer', _('Customer actions')),
|
||||
('device', _('Device actions')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
device = SafeModelChoiceField(
|
||||
label=_('Device'),
|
||||
empty_label=_('All devices'),
|
||||
queryset=Device.objects.none(),
|
||||
required=False
|
||||
)
|
||||
user_email = forms.EmailField(
|
||||
label=_('User email'),
|
||||
widget=forms.EmailInput(
|
||||
attrs={"placeholder": _('All users')}
|
||||
),
|
||||
required=False
|
||||
)
|
||||
action_type = forms.CharField(
|
||||
widget=forms.HiddenInput,
|
||||
required=False,
|
||||
)
|
||||
content_type = forms.ModelChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
widget=forms.HiddenInput,
|
||||
required=False,
|
||||
)
|
||||
object = forms.IntegerField(
|
||||
widget=forms.HiddenInput,
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.organizer = kwargs.pop('organizer')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['device'].queryset = self.organizer.devices.all().order_by('device_id')
|
||||
self.fields['device'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:organizer.devices.select2', kwargs={
|
||||
'organizer': self.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('All devices'),
|
||||
}
|
||||
)
|
||||
self.fields['device'].widget.choices = self.fields['device'].choices
|
||||
self.fields['device'].label = _('Device')
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
if fdata.get('source') == 'team':
|
||||
qs = qs.filter(user__isnull=False)
|
||||
elif fdata.get('source') == 'device':
|
||||
qs = qs.filter(device__isnull=False)
|
||||
elif fdata.get('source') == 'customer':
|
||||
qs = qs.filter(user__isnull=True, device__isnull=True)
|
||||
|
||||
if fdata.get('device'):
|
||||
qs = qs.filter(device_id=fdata['device'].pk)
|
||||
|
||||
if fdata.get('action_type'):
|
||||
qs = qs.filter(action_type__in=fdata['action_type'].split(','))
|
||||
|
||||
if fdata.get('user_email'):
|
||||
try:
|
||||
user = User.objects.get(email=fdata['user_email'].lower())
|
||||
qs = qs.filter(user=user)
|
||||
except User.DoesNotExist:
|
||||
# Do not actively leak info that this user does not exist. Yes, this will likely have a
|
||||
# timing attack possibility, but with the magic that database query planners do,
|
||||
# it's probably impossible to avoid that.
|
||||
qs = qs.none()
|
||||
|
||||
if fdata.get('content_type'):
|
||||
qs = qs.filter(content_type=fdata.get('content_type'))
|
||||
|
||||
if fdata.get('object'):
|
||||
qs = qs.filter(object_id=fdata.get('object'))
|
||||
return qs
|
||||
|
||||
@@ -246,8 +246,8 @@ class QuestionForm(I18nModelForm):
|
||||
'valid_string_length_max',
|
||||
]
|
||||
widgets = {
|
||||
'valid_datetime_min': SplitDateTimePickerWidget(),
|
||||
'valid_datetime_max': SplitDateTimePickerWidget(),
|
||||
'valid_datetime_min': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'valid_datetime_max': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'valid_date_min': DatePickerWidget(),
|
||||
'valid_date_max': DatePickerWidget(),
|
||||
'items': forms.CheckboxSelectMultiple(
|
||||
@@ -1427,6 +1427,6 @@ class ItemProgramTimeForm(I18nModelForm):
|
||||
'end': forms.SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'start': SplitDateTimePickerWidget(),
|
||||
'end': SplitDateTimePickerWidget(),
|
||||
'start': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'end': SplitDateTimePickerWidget(without_seconds=True),
|
||||
}
|
||||
|
||||
@@ -562,9 +562,7 @@ class OrderPositionChangeForm(forms.Form):
|
||||
if instance.addon_to_id:
|
||||
del self.fields['operation_split']
|
||||
|
||||
if not instance.seat and not (
|
||||
instance.item.seat_category_mappings.filter(subevent=instance.subevent).exists()
|
||||
):
|
||||
if not instance.seat and not instance._seat_allowed:
|
||||
del self.fields['seat']
|
||||
|
||||
choices = [
|
||||
|
||||
@@ -39,6 +39,7 @@ from pretix.base.reldate import RelativeDateTimeField, RelativeDateWrapper
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
||||
from pretix.control.forms.rrule import RRuleForm
|
||||
from pretix.helpers.i18n import get_javascript_format_without_seconds
|
||||
from pretix.helpers.money import change_decimal_field
|
||||
|
||||
|
||||
@@ -80,11 +81,11 @@ class SubEventForm(I18nModelForm):
|
||||
'presale_end': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'date_from': SplitDateTimePickerWidget(),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
|
||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
|
||||
'presale_start': SplitDateTimePickerWidget(),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
|
||||
'date_from': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
|
||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
|
||||
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}, without_seconds=True),
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +163,7 @@ class SubEventBulkEditForm(I18nModelForm):
|
||||
self.fields[k + '_time'] = forms.TimeField(
|
||||
label=self._meta.model._meta.get_field(k).verbose_name,
|
||||
help_text=self._meta.model._meta.get_field(k).help_text,
|
||||
widget=TimePickerWidget(),
|
||||
widget=TimePickerWidget(without_seconds=True),
|
||||
required=False,
|
||||
)
|
||||
|
||||
@@ -506,6 +507,12 @@ class TimeForm(forms.Form):
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['time_from'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
|
||||
self.fields['time_to'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
|
||||
self.fields['time_admission'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
|
||||
|
||||
|
||||
TimeFormSet = formset_factory(
|
||||
TimeForm,
|
||||
|
||||
@@ -32,16 +32,20 @@
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import copy
|
||||
import csv
|
||||
from collections import namedtuple
|
||||
from collections import Counter, namedtuple
|
||||
from io import StringIO
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import EmailValidator
|
||||
from django.db.models import Count, F, Max
|
||||
from django.db.models.functions import Upper
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes.forms import SafeModelChoiceField
|
||||
|
||||
from pretix.base.email import get_available_placeholders
|
||||
@@ -50,7 +54,10 @@ from pretix.base.forms import (
|
||||
)
|
||||
from pretix.base.forms.widgets import format_placeholders_help_text
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Item, Voucher
|
||||
from pretix.base.models import Item, ItemVariation, Quota, SubEvent, Voucher
|
||||
from pretix.base.models.vouchers import VoucherBulkData
|
||||
from pretix.base.services.locking import lock_objects
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
||||
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
|
||||
from pretix.control.signals import voucher_form_validation
|
||||
@@ -105,20 +112,22 @@ class VoucherForm(I18nModelForm):
|
||||
except Item.DoesNotExist:
|
||||
pass
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.event and self.instance:
|
||||
self.event = self.instance.event
|
||||
|
||||
self.fields['tag'].widget.attrs['data-typeahead-url'] = reverse('control:event.vouchers.tags.typeahead', kwargs={
|
||||
'event': instance.event.slug,
|
||||
'organizer': instance.event.organizer.slug,
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
})
|
||||
|
||||
if instance.event.has_subevents:
|
||||
self.fields['subevent'].queryset = instance.event.subevents.all()
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': instance.event.slug,
|
||||
'organizer': instance.event.organizer.slug,
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
}
|
||||
)
|
||||
@@ -128,18 +137,19 @@ class VoucherForm(I18nModelForm):
|
||||
del self.fields['subevent']
|
||||
|
||||
choices = []
|
||||
if 'itemvar' in initial or (self.data and 'itemvar' in self.data):
|
||||
iv = self.data.get('itemvar') or initial.get('itemvar', '')
|
||||
prefix = (self.prefix + '-') if self.prefix else ''
|
||||
if 'itemvar' in initial or (self.data and prefix + 'itemvar' in self.data):
|
||||
iv = self.data.get(prefix + 'itemvar', '') or initial.get('itemvar', '') or ''
|
||||
if iv.startswith('q-'):
|
||||
q = self.instance.event.quotas.get(pk=iv[2:])
|
||||
q = self.event.quotas.get(pk=iv[2:])
|
||||
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
|
||||
elif '-' in iv:
|
||||
itemid, varid = iv.split('-')
|
||||
i = self.instance.event.items.get(pk=itemid)
|
||||
i = self.event.items.get(pk=itemid)
|
||||
v = i.variations.get(pk=varid)
|
||||
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (str(i), v.value)))
|
||||
elif iv:
|
||||
i = self.instance.event.items.get(pk=iv)
|
||||
i = self.event.items.get(pk=iv)
|
||||
if i.variations.exists():
|
||||
choices.append((str(i.pk), _('{product} – Any variation').format(product=i)))
|
||||
else:
|
||||
@@ -150,8 +160,8 @@ class VoucherForm(I18nModelForm):
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:event.vouchers.itemselect2', kwargs={
|
||||
'event': instance.event.slug,
|
||||
'organizer': instance.event.organizer.slug,
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('All products')
|
||||
}
|
||||
@@ -159,7 +169,7 @@ class VoucherForm(I18nModelForm):
|
||||
self.fields['itemvar'].required = False
|
||||
self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices
|
||||
|
||||
if self.instance.event.seating_plan or self.instance.event.subevents.filter(seating_plan__isnull=False).exists():
|
||||
if self.event.seating_plan or self.event.subevents.filter(seating_plan__isnull=False).exists():
|
||||
self.fields['seat'] = forms.CharField(
|
||||
label=_("Specific seat ID"),
|
||||
max_length=255,
|
||||
@@ -169,40 +179,45 @@ class VoucherForm(I18nModelForm):
|
||||
help_text=str(self.instance.seat) if self.instance.seat else '',
|
||||
)
|
||||
|
||||
def parse_itemvar(self, data):
|
||||
try:
|
||||
itemid = quotaid = None
|
||||
iv = data.get('itemvar', '')
|
||||
if iv.startswith('q-'):
|
||||
quotaid = iv[2:]
|
||||
elif '-' in iv:
|
||||
itemid, varid = iv.split('-')
|
||||
elif iv:
|
||||
itemid, varid = iv, None
|
||||
else:
|
||||
itemid, varid = None, None
|
||||
|
||||
if itemid:
|
||||
item = self.event.items.get(pk=itemid)
|
||||
if varid:
|
||||
variation = item.variations.get(pk=varid)
|
||||
else:
|
||||
variation = None
|
||||
quota = None
|
||||
elif quotaid:
|
||||
quota = self.event.quotas.get(pk=quotaid)
|
||||
item = None
|
||||
variation = None
|
||||
else:
|
||||
quota = None
|
||||
item = None
|
||||
variation = None
|
||||
|
||||
return (item, variation, quota)
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(_("Invalid product selected."))
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
|
||||
if not self._errors:
|
||||
try:
|
||||
itemid = quotaid = None
|
||||
iv = self.data.get('itemvar', '')
|
||||
if iv.startswith('q-'):
|
||||
quotaid = iv[2:]
|
||||
elif '-' in iv:
|
||||
itemid, varid = iv.split('-')
|
||||
elif iv:
|
||||
itemid, varid = iv, None
|
||||
else:
|
||||
itemid, varid = None, None
|
||||
|
||||
if itemid:
|
||||
self.instance.item = self.instance.event.items.get(pk=itemid)
|
||||
if varid:
|
||||
self.instance.variation = self.instance.item.variations.get(pk=varid)
|
||||
else:
|
||||
self.instance.variation = None
|
||||
self.instance.quota = None
|
||||
elif quotaid:
|
||||
self.instance.quota = self.instance.event.quotas.get(pk=quotaid)
|
||||
self.instance.item = None
|
||||
self.instance.variation = None
|
||||
else:
|
||||
self.instance.quota = None
|
||||
self.instance.item = None
|
||||
self.instance.variation = None
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(_("Invalid product selected."))
|
||||
self.instance.item, self.instance.variation, self.instance.quota = self.parse_itemvar(self.data)
|
||||
|
||||
if 'codes' in data:
|
||||
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
|
||||
@@ -214,7 +229,7 @@ class VoucherForm(I18nModelForm):
|
||||
|
||||
try:
|
||||
Voucher.clean_item_properties(
|
||||
data, self.instance.event,
|
||||
data, self.event,
|
||||
self.instance.quota, self.instance.item, self.instance.variation,
|
||||
seats_given=data.get('seat') or data.get('seats'),
|
||||
block_quota=data.get('block_quota')
|
||||
@@ -234,7 +249,7 @@ class VoucherForm(I18nModelForm):
|
||||
|
||||
try:
|
||||
Voucher.clean_subevent(
|
||||
data, self.instance.event
|
||||
data, self.event
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ValidationError({"subevent": e.message})
|
||||
@@ -250,19 +265,19 @@ class VoucherForm(I18nModelForm):
|
||||
if check_quota:
|
||||
Voucher.clean_quota_check(
|
||||
data, cnt, self.initial_instance_data,
|
||||
self.instance.event, self.instance.quota, self.instance.item, self.instance.variation
|
||||
self.event, self.instance.quota, self.instance.item, self.instance.variation
|
||||
)
|
||||
Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
|
||||
Voucher.clean_voucher_code(data, self.event, self.instance.pk)
|
||||
if 'seat' in self.fields:
|
||||
if data.get('seat'):
|
||||
self.instance.seat = Voucher.clean_seat_id(
|
||||
data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
|
||||
data, self.instance.item, self.instance.quota, self.event, self.instance.pk
|
||||
)
|
||||
self.instance.item = self.instance.seat.product
|
||||
else:
|
||||
self.instance.seat = None
|
||||
|
||||
voucher_form_validation.send(sender=self.instance.event, form=self, data=data)
|
||||
voucher_form_validation.send(sender=self.event, form=self, data=data)
|
||||
|
||||
return data
|
||||
|
||||
@@ -270,6 +285,215 @@ class VoucherForm(I18nModelForm):
|
||||
return super().save(commit)
|
||||
|
||||
|
||||
class VoucherBulkEditForm(VoucherForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.mixed_values = kwargs.pop('mixed_values')
|
||||
self.queryset = kwargs.pop('queryset')
|
||||
super().__init__(**kwargs)
|
||||
del self.fields["code"]
|
||||
self.fields.pop("seat", None)
|
||||
|
||||
def is_bulk_checked(self, fieldname):
|
||||
return self.prefix + fieldname in self.data.getlist('_bulk')
|
||||
|
||||
def clean(self):
|
||||
# We skip the parent class because it's not suited for bulk editing and implement custom validation here.
|
||||
# This does not validate *everything* we validate in VoucherForm. For example, we skip validation that one does
|
||||
# not create a voucher for an add-on product or that the seat matches the product to save on complexity.
|
||||
# This is a UX validation only anyway, since one could first create the voucher and then make the product an
|
||||
# add-on product. However, we need to validate everything that we don't want violated in the database.
|
||||
data = super(VoucherForm, self).clean()
|
||||
|
||||
if self.is_bulk_checked("itemvar"):
|
||||
data["item"], data["variation"], data["quota"] = self.parse_itemvar(data)
|
||||
|
||||
if self.is_bulk_checked("max_usages") and "max_usages" in data:
|
||||
max_redeemed = self.queryset.aggregate(m=Max("redeemed"))["m"]
|
||||
if data["max_usages"] < max_redeemed:
|
||||
raise ValidationError(_(
|
||||
"You cannot reduce the maximum number of redemptions to %(max_usages)s, because at least one "
|
||||
"of the selected vouchers has already been redeemed %(max_redeemed)s times."
|
||||
) % {"max_usages": data["max_usages"], "max_redeemed": max_redeemed})
|
||||
|
||||
# Check diff on product and quota usage based on old groups of vouchers
|
||||
if any(self.is_bulk_checked(k) for k in ("max_usages", "itemvar", "block_quota", "valid_until", "subevent")):
|
||||
quota_diff = Counter()
|
||||
|
||||
current_vouchers = self.queryset.order_by().values(
|
||||
"item", "variation", "quota", "block_quota", "valid_until", "subevent", "redeemed", "max_usages",
|
||||
"allow_ignore_quota",
|
||||
).annotate(c=Count("*"))
|
||||
item_cache = {i.pk: i for i in Item.objects.filter(pk__in=[c["item"] for c in current_vouchers])}
|
||||
var_cache = {v.pk: v for v in ItemVariation.objects.filter(pk__in=[c["variation"] for c in current_vouchers])}
|
||||
quota_cache = {q.pk: q for q in Quota.objects.filter(pk__in=[c["quota"] for c in current_vouchers])}
|
||||
subevent_cache = {s.pk: s for s in SubEvent.objects.filter(pk__in=[c["subevent"] for c in current_vouchers])}
|
||||
|
||||
for current in current_vouchers:
|
||||
bulk_count = current.pop('c')
|
||||
current = VoucherBulkData(**current)
|
||||
# Get quotas that are currently used
|
||||
if current.item:
|
||||
current.item = item_cache[current.item]
|
||||
if current.variation:
|
||||
current.variation = var_cache[current.variation]
|
||||
if current.quota:
|
||||
current.quota = quota_cache[current.quota]
|
||||
if current.subevent:
|
||||
current.subevent = subevent_cache[current.subevent]
|
||||
|
||||
old_quotas = Voucher.clean_quota_get_ignored(current)
|
||||
old_amount = max(current.max_usages - current.redeemed, 0) * bulk_count
|
||||
|
||||
# Predict state after change
|
||||
after_change = copy.copy(current)
|
||||
if self.is_bulk_checked("itemvar") and "itemvar" in data:
|
||||
after_change.item = data["item"]
|
||||
after_change.variation = data["variation"]
|
||||
after_change.quota = data["quota"]
|
||||
if self.is_bulk_checked("subevent") and "subevent" in data:
|
||||
after_change.subevent = data["subevent"]
|
||||
if self.is_bulk_checked("max_usages") and "max_usages" in data:
|
||||
after_change.max_usages = data["max_usages"]
|
||||
if self.is_bulk_checked("block_quota") and "block_quota" in data:
|
||||
after_change.block_quota = data["block_quota"]
|
||||
if self.is_bulk_checked("valid_until") and "valid_until" in data:
|
||||
after_change.valid_until = data["valid_until"]
|
||||
if self.is_bulk_checked("allow_ignore_quota") and "allow_ignore_quota" in data:
|
||||
after_change.allow_ignore_quota = data["allow_ignore_quota"]
|
||||
|
||||
if after_change.quota and self.event.has_subevents and not after_change.subevent:
|
||||
raise ValidationError(_("You cannot create a voucher that allows selection of a quota but has no date selected."))
|
||||
|
||||
if after_change.quota and after_change.subevent and after_change.quota.subevent_id != after_change.subevent.pk:
|
||||
raise ValidationError(_("The selected quota does not match the selected subevent."))
|
||||
|
||||
if after_change.block_quota and self.event.has_subevents and not after_change.subevent:
|
||||
raise ValidationError(
|
||||
_('If you want this voucher to block quota, you need to select a specific date.'))
|
||||
|
||||
if after_change.block_quota and not after_change.item and not after_change.quota:
|
||||
raise ValidationError(
|
||||
_('You need to select a specific product or quota if this voucher should reserve '
|
||||
'tickets.')
|
||||
)
|
||||
|
||||
if after_change.allow_ignore_quota:
|
||||
# todo: is this the most useful way to do this?
|
||||
continue
|
||||
|
||||
new_quotas = Voucher.clean_quota_get_ignored(after_change)
|
||||
new_amount = max(after_change.max_usages - after_change.redeemed, 0) * bulk_count
|
||||
|
||||
if new_quotas != old_quotas or new_amount != old_amount:
|
||||
for q in old_quotas:
|
||||
quota_diff[q] -= old_amount
|
||||
for q in new_quotas:
|
||||
quota_diff[q] += new_amount
|
||||
|
||||
if any(v > 0 for q, v in quota_diff.items()):
|
||||
lock_objects([q for q, v in quota_diff.items() if q.size is not None and v > 0], shared_lock_objects=[self.event])
|
||||
qa = QuotaAvailability(count_waitinglist=False)
|
||||
qa.queue(*(q for q, v in quota_diff.items() if v > 0))
|
||||
qa.compute()
|
||||
|
||||
if any(qa.results[q][0] != Quota.AVAILABILITY_OK or (qa.results[q][1] is not None and qa.results[q][1] < required)
|
||||
for q, required in quota_diff.items() if required > 0):
|
||||
raise ValidationError(_(
|
||||
'There is no sufficient quota available to perform this change.'
|
||||
))
|
||||
|
||||
has_seat = self.queryset.filter(seat__isnull=False).exists()
|
||||
if has_seat:
|
||||
if self.is_bulk_checked("max_usages"):
|
||||
raise ValidationError(_(
|
||||
'Changing the maximum number of usages in bulk is not supported if any of the selected vouchers '
|
||||
'is assigned a seat.'
|
||||
))
|
||||
if self.is_bulk_checked("subevent"):
|
||||
raise ValidationError(pgettext_lazy(
|
||||
'subevent',
|
||||
'Changing the date in bulk is not supported if any of the selected vouchers '
|
||||
'is assigned a seat.'
|
||||
))
|
||||
if self.is_bulk_checked("itemvar") and data["quota"]:
|
||||
raise ValidationError(_(
|
||||
'Changing the product to a quota is not supported if any of the selected vouchers '
|
||||
'is assigned a seat.'
|
||||
))
|
||||
|
||||
if self.is_bulk_checked("valid_until"):
|
||||
if data["valid_until"] is None or data["valid_until"] >= now():
|
||||
currently_not_blocked_seats = self.queryset.filter(
|
||||
seat__isnull=False,
|
||||
max_usages__gt=F("redeemed"),
|
||||
valid_until__lt=now(),
|
||||
)
|
||||
if self.event.has_subevents:
|
||||
subevents = self.event.subevents.filter(pk__in=currently_not_blocked_seats.values_list("subevent"))
|
||||
for se in subevents:
|
||||
conflicts = currently_not_blocked_seats.filter(
|
||||
subevent=se
|
||||
).exclude(
|
||||
seat_id__in=se.free_seats().values("pk")
|
||||
)
|
||||
if conflicts:
|
||||
raise ValidationError(_(
|
||||
'This change cannot be completed because not all assigned seats of the vouchers are '
|
||||
'still available'
|
||||
))
|
||||
else:
|
||||
conflicts = currently_not_blocked_seats.exclude(
|
||||
seat_id__in=self.event.free_seats().values("pk")
|
||||
)
|
||||
if conflicts:
|
||||
raise ValidationError(_(
|
||||
'This change cannot be completed because not all assigned seats of the vouchers are '
|
||||
'still available'
|
||||
))
|
||||
|
||||
return data
|
||||
|
||||
def save(self, commit=True):
|
||||
objs = list(self.queryset)
|
||||
fields = set()
|
||||
|
||||
check_map = {
|
||||
'price_mode': '__price',
|
||||
'value': '__price',
|
||||
}
|
||||
for k in self.fields:
|
||||
if not self.is_bulk_checked(check_map.get(k, k)):
|
||||
continue
|
||||
|
||||
if k == 'itemvar':
|
||||
fields.add("item")
|
||||
fields.add("variation")
|
||||
fields.add("quota")
|
||||
else:
|
||||
fields.add(k)
|
||||
for obj in objs:
|
||||
if k == 'itemvar':
|
||||
obj.item = self.cleaned_data["item"]
|
||||
obj.variation = self.cleaned_data["variation"]
|
||||
obj.quota = self.cleaned_data["quota"]
|
||||
else:
|
||||
setattr(obj, k, self.cleaned_data[k])
|
||||
|
||||
fields = [f for f in fields if f != 'itemvars']
|
||||
if fields:
|
||||
Voucher.objects.bulk_update(objs, fields, 200)
|
||||
|
||||
def full_clean(self):
|
||||
if len(self.data) == 0:
|
||||
# form wasn't submitted
|
||||
self._errors = ErrorDict()
|
||||
return
|
||||
super().full_clean()
|
||||
|
||||
def _post_clean(self):
|
||||
pass # skip model-level clean
|
||||
|
||||
|
||||
class VoucherBulkForm(VoucherForm):
|
||||
codes = forms.CharField(
|
||||
widget=forms.Textarea,
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
# 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 functools
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
@@ -69,6 +70,15 @@ OVERVIEW_BANLIST = [
|
||||
]
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=32)
|
||||
def object_id_to_string(model_class, **kwargs):
|
||||
# The cache is thread-local and persists requests, but it's small enough that we can accept that
|
||||
try:
|
||||
return str(model_class.objects.get(**kwargs))
|
||||
except model_class.DoesNotExist:
|
||||
return "?"
|
||||
|
||||
|
||||
class OrderChangeLogEntryType(OrderLogEntryType):
|
||||
prefix = _('The order has been changed:')
|
||||
|
||||
@@ -93,10 +103,10 @@ class OrderItemChanged(OrderChangeLogEntryType):
|
||||
def display_prefixed(self, event: Event, logentry: LogEntry, data):
|
||||
old_item = str(event.items.get(pk=data['old_item']))
|
||||
if data['old_variation']:
|
||||
old_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['old_variation']))
|
||||
old_item += ' - ' + str(object_id_to_string(ItemVariation, item__event_id=event.pk, pk=data['old_variation']))
|
||||
new_item = str(event.items.get(pk=data['new_item']))
|
||||
if data['new_variation']:
|
||||
new_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['new_variation']))
|
||||
new_item += ' - ' + str(object_id_to_string(ItemVariation, item__event_id=event.pk, pk=data['new_variation']))
|
||||
return _('Position #{posid}: {old_item} ({old_price}) changed to {new_item} ({new_price}).').format(
|
||||
posid=data.get('positionid', '?'),
|
||||
old_item=old_item, new_item=new_item,
|
||||
@@ -153,14 +163,14 @@ class OrderTaxRuleChanged(OrderChangeLogEntryType):
|
||||
if 'positionid' in data:
|
||||
return _('Tax rule of position #{posid} changed from {old_rule} to {new_rule}.').format(
|
||||
posid=data.get('positionid', '?'),
|
||||
old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '–',
|
||||
new_rule=TaxRule.objects.get(pk=data['new_taxrule']),
|
||||
old_rule=object_id_to_string(TaxRule, pk=data['old_taxrule']) if data['old_taxrule'] else '–',
|
||||
new_rule=object_id_to_string(TaxRule, pk=data['new_taxrule']),
|
||||
)
|
||||
elif 'fee' in data:
|
||||
return _('Tax rule of fee #{fee} changed from {old_rule} to {new_rule}.').format(
|
||||
fee=data.get('fee', '?'),
|
||||
old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '–',
|
||||
new_rule=TaxRule.objects.get(pk=data['new_taxrule']),
|
||||
old_rule=object_id_to_string(TaxRule, pk=data['old_taxrule']) if data['old_taxrule'] else '–',
|
||||
new_rule=object_id_to_string(TaxRule, pk=data['new_taxrule']),
|
||||
)
|
||||
|
||||
|
||||
@@ -204,7 +214,7 @@ class OrderCanceled(OrderChangeLogEntryType):
|
||||
def display_prefixed(self, event: Event, logentry: LogEntry, data):
|
||||
old_item = str(event.items.get(pk=data['old_item']))
|
||||
if data['old_variation']:
|
||||
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
|
||||
old_item += ' - ' + object_id_to_string(ItemVariation, pk=data['old_variation'])
|
||||
return _('Position #{posid} ({old_item}, {old_price}) canceled.').format(
|
||||
posid=data.get('positionid', '?'),
|
||||
old_item=old_item,
|
||||
@@ -219,7 +229,7 @@ class OrderPositionAdded(OrderChangeLogEntryType):
|
||||
def display_prefixed(self, event: Event, logentry: LogEntry, data):
|
||||
item = str(event.items.get(pk=data['item']))
|
||||
if data['variation']:
|
||||
item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['variation']))
|
||||
item += ' - ' + object_id_to_string(ItemVariation, item__event_id=event.pk, pk=data['variation'])
|
||||
if data['addon_to']:
|
||||
addon_to = OrderPosition.objects.get(order__event=event, pk=data['addon_to'])
|
||||
return _('Position #{posid} created: {item} ({price}) as an add-on to position #{addon_to}.').format(
|
||||
@@ -283,7 +293,7 @@ class OrderChangedSplit(OrderChangeLogEntryType):
|
||||
def display_prefixed(self, event: Event, logentry: LogEntry, data):
|
||||
old_item = str(event.items.get(pk=data['old_item']))
|
||||
if data['old_variation']:
|
||||
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
|
||||
old_item += ' - ' + object_id_to_string(ItemVariation, pk=data['old_variation'])
|
||||
url = reverse('control:event.order', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
@@ -339,6 +349,7 @@ class OrderChangedSplitFrom(OrderLogEntryType):
|
||||
'pretix.event.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
|
||||
})
|
||||
class CheckinErrorLogEntryType(OrderLogEntryType):
|
||||
|
||||
def display(self, logentry: LogEntry, data):
|
||||
return self.display_plain(self.plain, logentry, data)
|
||||
|
||||
@@ -353,10 +364,7 @@ class CheckinErrorLogEntryType(OrderLogEntryType):
|
||||
event = logentry.event
|
||||
|
||||
if 'list' in data and event:
|
||||
try:
|
||||
data['list'] = event.checkin_lists.get(pk=data.get('list')).name
|
||||
except CheckinList.DoesNotExist:
|
||||
data['list'] = _("(unknown)")
|
||||
data['list'] = object_id_to_string(CheckinList, event_id=event.id, pk=data['list'])
|
||||
else:
|
||||
data['list'] = _("(unknown)")
|
||||
|
||||
@@ -853,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.'),
|
||||
|
||||
@@ -37,7 +37,7 @@ from urllib.parse import quote, urljoin, urlparse
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import REDIRECT_FIELD_NAME, logout
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.http import Http404
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, resolve_url
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import get_script_prefix, resolve, reverse
|
||||
@@ -98,6 +98,8 @@ class PermissionMiddleware:
|
||||
super().__init__()
|
||||
|
||||
def _login_redirect(self, request):
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
|
||||
# Taken from django/contrib/auth/decorators.py
|
||||
path = request.build_absolute_uri()
|
||||
# urlparse chokes on lazy objects in Python 3, force to str
|
||||
@@ -110,10 +112,21 @@ class PermissionMiddleware:
|
||||
if ((not login_scheme or login_scheme == current_scheme) and
|
||||
(not login_netloc or login_netloc == current_netloc)):
|
||||
path = request.get_full_path()
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
|
||||
return redirect_to_login(
|
||||
path, resolved_login_url, REDIRECT_FIELD_NAME)
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
# It's not useful to return a 302 redirect on a XMLHttpRequest request, because
|
||||
# the XMLHttpRequest is unable to detect redirects.
|
||||
return HttpResponse(
|
||||
"Authentication required",
|
||||
status=401,
|
||||
headers={
|
||||
# Appending ?next= is handled by client, because it should be the top-level context url,
|
||||
# not the URL called in the background
|
||||
"X-Login-Url": resolved_login_url,
|
||||
}
|
||||
)
|
||||
|
||||
return redirect_to_login(path, resolved_login_url, REDIRECT_FIELD_NAME)
|
||||
|
||||
def __call__(self, request):
|
||||
url = resolve(request.path_info)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -39,7 +39,8 @@ from pretix.base.signals import (
|
||||
html_page_start = GlobalSignal()
|
||||
"""
|
||||
This signal allows you to put code in the beginning of the main page for every
|
||||
page in the backend. You are expected to return HTML.
|
||||
page in the backend. You are expected to return a SafeString containing HTML, or
|
||||
a string that will be HTML-escaped.
|
||||
|
||||
The ``sender`` keyword argument will contain the request.
|
||||
"""
|
||||
@@ -129,7 +130,7 @@ event_dashboard_top = EventPluginSignal()
|
||||
Arguments: 'request'
|
||||
|
||||
This signal is sent out to include custom HTML in the top part of the the event dashboard.
|
||||
Receivers should return HTML.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
An additional keyword argument ``subevent`` *can* contain a sub-event.
|
||||
@@ -140,7 +141,7 @@ event_dashboard_widgets = EventPluginSignal()
|
||||
This signal is sent out to include widgets in the event dashboard. Receivers
|
||||
should return a list of dictionaries, where each dictionary can have the keys:
|
||||
|
||||
* content (str, containing HTML)
|
||||
* content (SafeString, containing HTML)
|
||||
* display_size (str, one of "full" (whole row), "big" (half a row) or "small"
|
||||
(quarter of a row). May be ignored on small displays, default is "small")
|
||||
* priority (int, used for ordering, higher comes first, default is 1)
|
||||
@@ -157,7 +158,7 @@ Arguments: 'user'
|
||||
This signal is sent out to include widgets in the personal user dashboard. Receivers
|
||||
should return a list of dictionaries, where each dictionary can have the keys:
|
||||
|
||||
* content (str, containing HTML)
|
||||
* content (SafeString, containing HTML)
|
||||
* display_size (str, one of "full" (whole row), "big" (half a row) or "small"
|
||||
(quarter of a row). May be ignored on small displays, default is "small")
|
||||
* priority (int, used for ordering, higher comes first, default is 1)
|
||||
@@ -172,6 +173,7 @@ Arguments: 'form'
|
||||
|
||||
This signal allows you to add additional HTML to the form that is used for modifying vouchers.
|
||||
You receive the form object in the ``form`` keyword argument.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
@@ -209,6 +211,7 @@ Arguments: 'quota'
|
||||
|
||||
This signal allows you to append HTML to a Quota's detail view. You receive the
|
||||
quota as argument in the ``quota`` keyword argument.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
@@ -219,6 +222,7 @@ Arguments: 'subevent'
|
||||
|
||||
This signal allows you to append HTML to a SubEvent's detail view. You receive the
|
||||
subevent as argument in the ``subevent`` keyword argument.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
@@ -265,7 +269,8 @@ order_info = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``order``, ``request``
|
||||
|
||||
This signal is sent out to display additional information on the order detail page
|
||||
This signal is sent out to display additional information on the order detail page.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
Additionally, the argument ``order`` and ``request`` are available.
|
||||
@@ -275,7 +280,8 @@ order_approve_info = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``order``, ``request``
|
||||
|
||||
This signal is sent out to display additional information on the order approve page
|
||||
This signal is sent out to display additional information on the order approve page.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
Additionally, the argument ``order`` and ``request`` are available.
|
||||
@@ -286,6 +292,7 @@ order_position_buttons = EventPluginSignal()
|
||||
Arguments: ``order``, ``position``, ``request``
|
||||
|
||||
This signal is sent out to display additional buttons for a single position of an order.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
Additionally, the argument ``order`` and ``request`` are available.
|
||||
@@ -315,6 +322,7 @@ Arguments: 'request'
|
||||
|
||||
This signal is sent out to include template snippets on the settings page of an event
|
||||
that allows generating a pretix Widget code.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
A second keyword argument ``request`` will contain the request object.
|
||||
|
||||
@@ -56,5 +56,4 @@
|
||||
</form>
|
||||
<script type="text/plain" id="good_origin">{{ good_origin }}</script>
|
||||
<script type="text/plain" id="bad_origin_report_url">{{ bad_origin_report_url }}</script>
|
||||
<!-- pretix-login-marker -->{# marker required for ajax calls to detect that user session is over #}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
{% load escapejson %}
|
||||
{% block content %}
|
||||
<form class="form-signin" action="" method="post" id="webauthn-form">
|
||||
{% csrf_token %}
|
||||
@@ -30,8 +31,7 @@
|
||||
</form>
|
||||
{% if jsondata %}
|
||||
<script type="text/json" id="webauthn-login">
|
||||
{{ jsondata|safe }}
|
||||
|
||||
{{ jsondata|escapejson }}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% compress js %}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +72,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -102,7 +102,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -50,6 +50,46 @@
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if dkim_warning %}
|
||||
<div class="alert alert-danger">
|
||||
<p>
|
||||
{{ dkim_warning }}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Your new DKIM record should be set up as a CNAME record like this:" %}
|
||||
</p>
|
||||
<pre><code>{{ dkim_hostname }} CNAME {{ dkim_cname }}</code></pre>
|
||||
<p>
|
||||
{% trans "Please keep in mind that updates to DNS might require multiple hours to take effect." %}
|
||||
</p>
|
||||
</div>
|
||||
{% elif dkim_cname %}
|
||||
<div class="alert alert-success">
|
||||
{% blocktrans trimmed %}
|
||||
We found a DKIM record on your domain for this system. Great!
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if dmarc_warning %}
|
||||
<div class="alert alert-danger">
|
||||
<p>
|
||||
{{ dmarc_warning }}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Your new DMARC record could look like this:" %}
|
||||
</p>
|
||||
<pre><code>_dmarc.{{ hostname }} TXT "v=DMARC1; p=quarantine; sp=none; adkim=r; aspf=r;"</code></pre>
|
||||
<p>
|
||||
{% trans "Please keep in mind that updates to DNS might require multiple hours to take effect." %}
|
||||
</p>
|
||||
</div>
|
||||
{% elif dkim_cname %}
|
||||
<div class="alert alert-success">
|
||||
{% blocktrans trimmed %}
|
||||
We found a DMARC record on your domain for this system. Great!
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if verification %}
|
||||
<h3>{% trans "Verification" %}</h3>
|
||||
<p>
|
||||
@@ -70,7 +110,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if spf_warning %}
|
||||
{% if spf_warning or dkim_warning or dmarc_warning %}
|
||||
<div class="form-group submit-group">
|
||||
<a href="" class="btn btn-default btn-save">
|
||||
{% trans "Cancel" %}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{% load i18n %}
|
||||
{% if has_overpaid_orders %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This event contains <strong>overpaid orders</strong>, for example due to duplicate payment attempts.
|
||||
You should review the cases and consider refunding the overpaid amount to the user.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}?status=overpaid"
|
||||
class="btn btn-primary">{% trans "Show overpaid orders" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_pending_refunds %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This event contains <strong>pending refunds</strong> that you should take care of.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.orders.refunds" event=request.event.slug organizer=request.event.organizer.slug %}"
|
||||
class="btn btn-primary">{% trans "Show pending refunds" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_cancellation_requests %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This event contains <strong>requested cancellations</strong> that you should take care of.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}?status=rc"
|
||||
class="btn btn-primary">{% trans "Show orders requesting cancellation" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_pending_approvals %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This event contains <strong>pending approvals</strong> that you should take care of.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}?status=pa"
|
||||
class="btn btn-primary">{% trans "Show orders pending approval" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_pending_orders_with_full_payment %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This event contains <strong>fully paid orders</strong> that are not marked as paid, probably
|
||||
because no quota was left at the time their payment arrived. You should review the cases and consider
|
||||
either refunding the customer or creating more space.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}?status=pendingpaid"
|
||||
class="btn btn-primary">{% trans "Show affected orders" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_sync_problems %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
Orders in this event could not be <strong>synced to an external system</strong> as configured.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.datasync.failedjobs" event=request.event.slug organizer=request.event.organizer.slug %}"
|
||||
class="btn btn-primary">{% trans "Show sync problems" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -14,6 +14,7 @@
|
||||
{% else %}
|
||||
{{ request.event.get_date_range_display }}
|
||||
{% endif %}
|
||||
<span id="warnings_loading" class="fa fa-cog fa-spin"></span>
|
||||
</small>
|
||||
</h1>
|
||||
<div class="helper-space-below">
|
||||
@@ -31,63 +32,7 @@
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% if has_overpaid_orders %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This event contains <strong>overpaid orders</strong>, for example due to duplicate payment attempts.
|
||||
You should review the cases and consider refunding the overpaid amount to the user.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}?status=overpaid"
|
||||
class="btn btn-primary">{% trans "Show overpaid orders" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_pending_refunds %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This event contains <strong>pending refunds</strong> that you should take care of.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.orders.refunds" event=request.event.slug organizer=request.event.organizer.slug %}"
|
||||
class="btn btn-primary">{% trans "Show pending refunds" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_cancellation_requests %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This event contains <strong>requested cancellations</strong> that you should take care of.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}?status=rc"
|
||||
class="btn btn-primary">{% trans "Show orders requesting cancellation" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_pending_approvals %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This event contains <strong>pending approvals</strong> that you should take care of.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}?status=pa"
|
||||
class="btn btn-primary">{% trans "Show orders pending approval" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_pending_orders_with_full_payment %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This event contains <strong>fully paid orders</strong> that are not marked as paid, probably
|
||||
because no quota was left at the time their payment arrived. You should review the cases and consider
|
||||
either refunding the customer or creating more space.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.orders" event=request.event.slug organizer=request.event.organizer.slug %}?status=pendingpaid"
|
||||
class="btn btn-primary">{% trans "Show affected orders" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_sync_problems %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
Orders in this event could not be <strong>synced to an external system</strong> as configured.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.datasync.failedjobs" event=request.event.slug organizer=request.event.organizer.slug %}"
|
||||
class="btn btn-primary">{% trans "Show sync problems" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="warnings_target"></div>
|
||||
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
|
||||
|
||||
{% if request.event.has_subevents %}
|
||||
@@ -106,7 +51,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% elif w.link %}
|
||||
@@ -114,7 +59,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x´"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -122,7 +67,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
<ul>
|
||||
{% for issue in issues %}
|
||||
<li>{{ issue|safe }}</li>
|
||||
<li>{{ issue }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@
|
||||
</p>
|
||||
<ul>
|
||||
{% for issue in issues %}
|
||||
<li>{{ issue|safe }}</li>
|
||||
<li>{{ issue }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,41 +1,12 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load bootstrap3 %}
|
||||
{% load icon %}
|
||||
{% block title %}{% trans "Event logs" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Event logs" %}</h1>
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<input type="hidden" name="content_type" value="{{ request.GET.content_type }}">
|
||||
<input type="hidden" name="object" value="{{ request.GET.object }}">
|
||||
<p>
|
||||
<select name="user" class="form-control">
|
||||
<option value="">{% trans "All actions" %}</option>
|
||||
<option value="yes" {% if request.GET.user == "yes" %}selected="selected"{% endif %}>
|
||||
{% trans "Team actions" %}
|
||||
</option>
|
||||
<option value="no" {% if request.GET.user == "no" %}selected="selected"{% endif %}>
|
||||
{% trans "Customer actions" %}
|
||||
</option>
|
||||
{% for up in userlist %}
|
||||
{% if up.user__id %}
|
||||
<option value="{{ up.user__id }}"
|
||||
{% if request.GET.user == up.user__id %}selected="selected"{% endif %}>
|
||||
{{ up.user__email }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for d in devicelist %}
|
||||
{% if d.device__id %}
|
||||
<option value="d-{{ d.device__id }}"
|
||||
{% if "d-" in request.GET.user and request.GET.user|slice:"2:" == d.device__id|slugify %}selected="selected"{% endif %}>
|
||||
{{ d.device__name }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
</p>
|
||||
</form>
|
||||
{% include "pretixcontrol/fragment_log_filter_form.html" %}
|
||||
<ul class="list-group">
|
||||
{% for log in logs %}
|
||||
<li class="list-group-item logentry">
|
||||
@@ -95,5 +66,5 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% include "pretixcontrol/pagination_huge.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<table class="table table-payment-providers">
|
||||
<tbody>
|
||||
{% for provider in providers %}
|
||||
<tr>
|
||||
<tr{% if provider.highlight %} class="success-left"{% endif %}>
|
||||
<td>
|
||||
<strong>{{ provider.verbose_name }}</strong>
|
||||
</td>
|
||||
@@ -56,7 +56,7 @@
|
||||
<td colspan="4">
|
||||
<br>
|
||||
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
|
||||
<a href="{{ plugin_settings_url }}#tab-0-1-open" class="btn btn-default">
|
||||
<a href="{{ plugin_settings_url }}?go=payment#tab-0-1-open" class="btn btn-default">
|
||||
<i class="fa fa-plus"></i> {% trans "Enable additional payment plugins" %}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
</div>
|
||||
<form action="" method="post" class="form-horizontal form-plugins">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="go" value="{{ request.GET.go }}">
|
||||
<div id="plugin_search_results" class="panel panel-default collapse">
|
||||
<div class="panel-heading">
|
||||
<button type="button" class="close" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
|
||||
@@ -193,7 +193,6 @@
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_field form.contact_mail layout="control" %}
|
||||
{% bootstrap_field form.contact_url layout="control" %}
|
||||
{% bootstrap_field form.imprint_url layout="control" %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load icon %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Filter" %}
|
||||
</h3>
|
||||
</div>
|
||||
<form class="panel-body filter-form" action="" method="get">
|
||||
<div class="row">
|
||||
<div class="col-md-3 col-xs-6">
|
||||
{% bootstrap_field filter_form.source %}
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6">
|
||||
{% bootstrap_field filter_form.device %}
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6">
|
||||
{% bootstrap_field filter_form.user_email %}
|
||||
</div>
|
||||
</div>
|
||||
{{ filter_form.action_type }}
|
||||
{{ filter_form.content_type }}
|
||||
{{ filter_form.object }}
|
||||
{% if filter_form.is_valid and filter_form.cleaned_data.action_type %}
|
||||
{% icon "filter" %} <code>{{ filter_form.cleaned_data.action_type }}</code>
|
||||
{% endif %}
|
||||
{% if filter_form.is_valid and filter_form.cleaned_data.content_type and filter_form.cleaned_data.object %}
|
||||
{% icon "filter" %} {% trans "Specific object selected" %}
|
||||
{% endif %}
|
||||
<div class="text-right flip">
|
||||
<button class="btn btn-primary btn-lg" type="submit">
|
||||
<span class="fa fa-filter"></span>
|
||||
{% trans "Filter" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -156,11 +156,11 @@
|
||||
<br/>
|
||||
<small class="text-muted">
|
||||
{% if not i.tax_rule.price_includes_tax %}
|
||||
{% blocktrans trimmed with rate=i.tax_rule.rate|floatformat:-2 taxname=i.tax_rule.name %}
|
||||
{% blocktrans trimmed with rate=i.tax_rule.rate|tax_rate_format taxname=i.tax_rule.name %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with rate=i.tax_rule.rate|floatformat:-2 taxname=i.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=i.tax_rule.rate|tax_rate_format taxname=i.tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
@@ -12,142 +12,113 @@
|
||||
{% 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" %}
|
||||
{% bootstrap_field form.items layout="control" %}
|
||||
{% 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_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> </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> </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" %}
|
||||
{% bootstrap_field form.ask_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.show_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.hidden layout="control" %}
|
||||
{% bootstrap_field form.print_on_invoice layout="control" %}
|
||||
|
||||
<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_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> </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> </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" %}
|
||||
{% bootstrap_field form.ask_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.show_during_checkin layout="control" %}
|
||||
{% bootstrap_field form.hidden layout="control" %}
|
||||
{% bootstrap_field form.print_on_invoice layout="control" %}
|
||||
|
||||
|
||||
<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: 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
|
||||
@@ -12,7 +12,7 @@
|
||||
{% csrf_token %}
|
||||
{% 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 %}" 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 %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new data field" %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -20,39 +20,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 %}">
|
||||
<tbody>
|
||||
{% for q in questions %}
|
||||
<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 %}
|
||||
@@ -63,42 +51,17 @@
|
||||
{% if q.pk and q.ask_during_checkin %}
|
||||
<span class="fa fa-check-square text-muted" data-toggle="tooltip" title="{% trans "Ask during check-in" %}"></span>
|
||||
{% endif %}
|
||||
|
||||
</td>
|
||||
<td>
|
||||
{% if q.pk and q.hidden %}
|
||||
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Hidden question" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% 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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% load wrap_in %}
|
||||
{% block title %}
|
||||
{% trans "Cancel order" %}
|
||||
{% endblock %}
|
||||
@@ -26,7 +27,7 @@
|
||||
{% if form.cancellation_fee %}
|
||||
{% if fee %}
|
||||
{% with fee|money:request.event.currency as f %}
|
||||
<p>{% blocktrans trimmed with fee="<strong>"|add:f|add:"</strong>"|safe %}
|
||||
<p>{% blocktrans trimmed with fee=f|wrap_in:"strong" %}
|
||||
The configured cancellation fee for a self-service cancellation would be {{ fee }} for this
|
||||
order, but for a cancellation performed by you, you need to set the cancellation fee here:
|
||||
{% endblocktrans %}</p>
|
||||
|
||||
@@ -284,6 +284,14 @@
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
{% bootstrap_field position.form.operation_secret layout='inline' %}
|
||||
{% if position.issued_gift_cards.exists %}
|
||||
<div class="alert alert-info">
|
||||
{% blocktrans trimmed %}
|
||||
Ticket secrets of order positions that have been used to issue a gift card can not
|
||||
be changed. Only the link will be changed in this case.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -602,60 +602,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"
|
||||
@@ -683,12 +632,27 @@
|
||||
{% endif %}
|
||||
{% elif q.type == "M" %}
|
||||
{{ q.answer.to_string_i18n|rich_text_snippet }}
|
||||
{% else %}
|
||||
{% elif q.type %}
|
||||
{{ q.answer.to_string_i18n|linebreaksbr }}
|
||||
{% else %}{# TODO: proper separation of QuestionAnswer objects and system answers...... #}
|
||||
{{ q.answer|linebreaksbr }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<em>{% trans "not answered" %}</em>
|
||||
{% endif %}
|
||||
{% if q.system_question == "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 %}
|
||||
@@ -705,7 +669,7 @@
|
||||
{% if line.tax_rate %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=line.tax_rate|floatformat:-2 taxname=line.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=line.tax_rate|tax_rate_format taxname=line.tax_rule.name|default:s_taxes %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -715,7 +679,7 @@
|
||||
{% if line.tax_rate and line.price %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=line.tax_rate|floatformat:-2 taxname=line.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=line.tax_rate|tax_rate_format taxname=line.tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -755,7 +719,7 @@
|
||||
{% if fee.tax_rate %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=fee.tax_rate|floatformat:-2 taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=fee.tax_rate|tax_rate_format taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -765,7 +729,7 @@
|
||||
{% if fee.tax_rate %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=fee.tax_rate|floatformat:-2 taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=fee.tax_rate|tax_rate_format taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -833,7 +797,7 @@
|
||||
<strong>{% trans "Pending total" %}</strong>
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
|
||||
<strong>{{ order.pending_sum|money:event.currency }}</strong>
|
||||
<strong>{{ pending_sum|money:event.currency }}</strong>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
@@ -903,7 +867,7 @@
|
||||
<tr>
|
||||
<td colspan="1"></td>
|
||||
<td colspan="5">
|
||||
{{ p.html_info|safe }}
|
||||
{{ p.html_info }}
|
||||
{% if staff_session %}
|
||||
<p>
|
||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandpayment data-id="{{ p.pk }}">
|
||||
@@ -1018,7 +982,7 @@
|
||||
</dl>
|
||||
{% endif %}
|
||||
{% if r.html_info %}
|
||||
{{ r.html_info|safe }}
|
||||
{{ r.html_info }}
|
||||
{% endif %}
|
||||
{% if staff_session %}
|
||||
<p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load bootstrap3 %}
|
||||
{% load escapejson %}
|
||||
{% block inner %}
|
||||
<h1>{% trans "Connect to device:" %} {{ device.name }}</h1>
|
||||
|
||||
@@ -18,7 +19,7 @@
|
||||
{% trans "Open the app that you want to connect and optionally reset it to the original state." %}
|
||||
</li>
|
||||
<li>{% trans "Scan the following configuration code:" %}<br><br>
|
||||
<script type="text/json" data-replace-with-qr>{{ qrdata|safe }}</script><br>
|
||||
<script type="application/json" data-replace-with-qr>{{ qrdata|escapejson_dumps }}</script><br>
|
||||
{% trans "If your app/device does not support scanning a QR code, you can also enter the following information:" %}
|
||||
<br>
|
||||
<strong>{% trans "System URL:" %}</strong> <code id="system_url">{{ settings.SITE_URL }}</code>
|
||||
|
||||
@@ -118,6 +118,10 @@
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<span class="fa fa-fw {% if d.last_seen and d.last_seen.is_recent %}fa-check-circle text-success{% else %}fa-circle text-danger{% endif %}"
|
||||
data-toggle="tooltip"
|
||||
title="{% if d.last_seen %}{% blocktrans with time=d.last_seen.last_seen|date:"SHORT_DATETIME_FORMAT" %}Last seen: {{ time }}{% endblocktrans %}{% else %}{% trans "No recent contact" %}{% endif %}"
|
||||
></span>
|
||||
{{ d.device_id }}
|
||||
</td>
|
||||
<td>
|
||||
@@ -125,6 +129,7 @@
|
||||
<del>{% endif %}
|
||||
{{ d.name }}
|
||||
{% if d.revoked %}</del>{% endif %}
|
||||
|
||||
{% if d.gate %}
|
||||
<br>
|
||||
<small class="text-muted">{{ d.gate.name }}</small>
|
||||
@@ -137,6 +142,15 @@
|
||||
</td>
|
||||
<td>
|
||||
{{ d.software_brand|default_if_none:"" }} {{ d.software_version|default_if_none:"" }}
|
||||
{% if staff_session %}
|
||||
<details class="admin-only">
|
||||
<summary>
|
||||
<i class="fa fa-angle-down collapse-indicator"></i>
|
||||
{% trans "Details" %}
|
||||
</summary>
|
||||
<pre class="admin-only">{{ d.info|pprint }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if d.initialized %}
|
||||
|
||||
@@ -4,24 +4,7 @@
|
||||
{% block title %}{% trans "Organizer logs" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Organizer logs" %}</h1>
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<input type="hidden" name="content_type" value="{{ request.GET.content_type }}">
|
||||
<input type="hidden" name="object" value="{{ request.GET.object }}">
|
||||
<p>
|
||||
<select name="user" class="form-control">
|
||||
<option value="">{% trans "All actions" %}</option>
|
||||
{% for up in userlist %}
|
||||
{% if up.user__id %}
|
||||
<option value="{{ up.user__id }}"
|
||||
{% if request.GET.user == up.user__id %}selected="selected"{% endif %}>
|
||||
{{ up.user__email }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
</p>
|
||||
</form>
|
||||
{% include "pretixcontrol/fragment_log_filter_form.html" %}
|
||||
<ul class="list-group">
|
||||
{% for log in logs %}
|
||||
<li class="list-group-item logentry">
|
||||
@@ -81,5 +64,5 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% include "pretixcontrol/pagination_huge.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -560,11 +560,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/plain" id="schema-url">{% static "schema/pdf-layout.schema.json" %}</script>
|
||||
<script type="text/javascript" src="{% static "pdfjs/pdf.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "ajv/ajv2020.bundle.min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "fabric/fabric.min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/editor.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "schema/pdf-layout.validate.js" %}"></script>
|
||||
<img src="{% static 'pretixpresale/pdf/powered_by_pretix_dark.png' %}" id="poweredby-dark" class="sr-only">
|
||||
<img src="{% static 'pretixpresale/pdf/powered_by_pretix_white.png' %}" id="poweredby-white" class="sr-only">
|
||||
{% for family, styles in fonts.items %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load escapejson %}
|
||||
{% block title %}{% trans "Add a two-factor authentication device" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Add a two-factor authentication device" %}</h1>
|
||||
@@ -32,7 +33,7 @@
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Add a new account to the app by scanning the following barcode:" %}
|
||||
<div class="qrcode-canvas" data-qrdata="#qrdata"></div>
|
||||
<script type="application/json" data-replace-with-qr>{{ qrdata|escapejson_dumps }}</script>
|
||||
<p>
|
||||
<a data-toggle="collapse" href="#no_scan">
|
||||
{% trans "Can't scan the barcode?" %}
|
||||
@@ -81,9 +82,4 @@
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<script type="text/json" id="qrdata">
|
||||
{{ qrdata|safe }}
|
||||
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user